From e915bbd74a7878ee947d8722eef79c60dc2538f3 Mon Sep 17 00:00:00 2001 From: rucky Date: Thu, 9 Apr 2026 09:46:47 +0800 Subject: [PATCH] =?UTF-8?q?=E8=81=8A=E5=A4=A9=E9=87=8D=E5=81=9A=E5=89=8D?= =?UTF-8?q?=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 8 + ActionBars.lua | 866 +++++++++----- AuraTracker.lua | 594 ++++++++++ Bindings.xml | 145 +++ CharacterPanel.lua | 73 +- Chat.lua | 611 +++++++++- ClassSkillData.lua | 347 ------ ConfigUI.lua | 1501 ++++++++++++++++++++++--- ConsumableDB.lua | 198 ++++ ConsumableUI.lua | 778 +++++++++++++ Core.lua | 164 ++- ExtraBar.lua | 792 +++++++++++++ Factory.lua | 155 ++- Focus.lua | 242 ++-- GearScore.lua | 14 - LootDisplay.lua | 93 +- Mail.lua | 442 +++++++- MapIcons.lua | 204 ++-- MapReveal.lua | 28 +- Media.lua | 55 + Minimap.lua | 64 +- MinimapBuffs.lua | 79 +- Movers.lua | 167 ++- Nanami-UI.toc | 5 +- SetupWizard.lua | 15 +- Tooltip.lua | 72 +- TradeSkillUI.lua | 78 +- TrainerUI.lua | 160 +-- Tweaks.lua | 155 +-- Units/Party.lua | 337 +++++- Units/Pet.lua | 141 ++- Units/Player.lua | 866 +++++--------- Units/Raid.lua | 297 +++-- Units/Target.lua | 416 ++++--- Units/ToT.lua | 39 +- WorldMap.lua | 56 +- agent-tools/generate_consumable_db.py | 426 +++++++ docs/MapReveal-Standalone.md | 126 +++ img/progress.tga | Bin 1048594 -> 65554 bytes 39 files changed, 8501 insertions(+), 2308 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 AuraTracker.lua delete mode 100644 ClassSkillData.lua create mode 100644 ConsumableDB.lua create mode 100644 ConsumableUI.lua create mode 100644 ExtraBar.lua create mode 100644 agent-tools/generate_consumable_db.py create mode 100644 docs/MapReveal-Standalone.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..26835e6 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(find . -name \"*.lua\" -exec grep -l \"database\\\\|DATABASE\\\\|Encyclopedia\\\\|encyclop\" {} \\\\;)", + "Bash(ls -lah *.lua)" + ] + } +} diff --git a/ActionBars.lua b/ActionBars.lua index a3f2976..15de28a 100644 --- a/ActionBars.lua +++ b/ActionBars.lua @@ -26,7 +26,6 @@ local DEFAULTS = { showHotkey = true, showMacroName = false, rangeColoring = true, - behindGlow = true, showPetBar = true, showStanceBar = true, showRightBars = true, @@ -44,6 +43,11 @@ local DEFAULTS = { bottomOffsetY = 2, rightOffsetX = -4, rightOffsetY = -80, + rightBar1PerRow = 1, + rightBar2PerRow = 1, + bottomBar1PerRow = 12, + bottomBar2PerRow = 12, + bottomBar3PerRow = 12, } local BUTTONS_PER_ROW = 12 @@ -103,6 +107,23 @@ local function LayoutColumn(buttons, parent, size, gap) end end +local function LayoutGrid(buttons, parent, size, gap, perRow) + local count = table.getn(buttons) + if count == 0 then return end + local numCols = perRow + local numRows = math.ceil(count / perRow) + parent:SetWidth(numCols * size + math.max(numCols - 1, 0) * gap) + parent:SetHeight(numRows * size + math.max(numRows - 1, 0) * gap) + for i, b in ipairs(buttons) do + b:SetWidth(size) + b:SetHeight(size) + b:ClearAllPoints() + local col = math.fmod(i - 1, perRow) + local row = math.floor((i - 1) / perRow) + b:SetPoint("TOPLEFT", parent, "TOPLEFT", col * (size + gap), -row * (size + gap)) + end +end + -------------------------------------------------------------------------------- -- DB -------------------------------------------------------------------------------- @@ -445,12 +466,17 @@ function AB:CreateBars() local db = self:GetDB() local size = db.buttonSize local gap = db.buttonGap - local rowWidth = (size + gap) * BUTTONS_PER_ROW - gap + + local bpr1 = db.bottomBar1PerRow or 12 + local bpr1Cols = bpr1 + local bpr1Rows = math.ceil(BUTTONS_PER_ROW / bpr1) + local bar1W = bpr1Cols * size + math.max(bpr1Cols - 1, 0) * gap + local bar1H = bpr1Rows * size + math.max(bpr1Rows - 1, 0) * gap -- === BOTTOM BARS === local anchor = CreateFrame("Frame", "SFramesActionBarAnchor", UIParent) - anchor:SetWidth(rowWidth) - anchor:SetHeight(size * 3 + gap * 2) + anchor:SetWidth(bar1W) + anchor:SetHeight(bar1H) local abPos = SFramesDB and SFramesDB.Positions and SFramesDB.Positions["ActionBarBottom"] if abPos and abPos.point and abPos.relativePoint then anchor:SetPoint(abPos.point, UIParent, abPos.relativePoint, abPos.xOfs or 0, abPos.yOfs or 0) @@ -462,8 +488,8 @@ function AB:CreateBars() -- Row 1: ActionButton1-12 (safe to reparent, uses page calc) local row1 = CreateFrame("Frame", "SFramesMainBar", anchor) - row1:SetWidth(rowWidth) - row1:SetHeight(size) + row1:SetWidth(bar1W) + row1:SetHeight(bar1H) row1:SetPoint("BOTTOMLEFT", anchor, "BOTTOMLEFT", 0, 0) self.row1 = row1 @@ -483,8 +509,8 @@ function AB:CreateBars() BonusActionBarFrame:SetParent(row1) BonusActionBarFrame:ClearAllPoints() BonusActionBarFrame:SetPoint("BOTTOMLEFT", row1, "BOTTOMLEFT", 0, 0) - BonusActionBarFrame:SetWidth(rowWidth) - BonusActionBarFrame:SetHeight(size) + BonusActionBarFrame:SetWidth(bar1W) + BonusActionBarFrame:SetHeight(bar1H) BonusActionBarFrame:SetFrameLevel(row1:GetFrameLevel() + 5) for i = 1, BUTTONS_PER_ROW do @@ -505,7 +531,8 @@ function AB:CreateBars() local db = AB:GetDB() local s = db.buttonSize local g = db.buttonGap - LayoutRow(AB.bonusButtons, BonusActionBarFrame, s, g) + local bpr = db.bottomBar1PerRow or 12 + LayoutGrid(AB.bonusButtons, BonusActionBarFrame, s, g, bpr) local btnLevel = BonusActionBarFrame:GetFrameLevel() + 1 for _, btn in ipairs(AB.bonusButtons) do btn:EnableMouse(true) @@ -523,13 +550,32 @@ function AB:CreateBars() pi:SetTextColor(0.9, 0.8, 0.2, 0.9) self.pageIndicator = pi - -- Row 2: reposition the original MultiBarBottomLeft frame + -- Row 2: independent anchor for MultiBarBottomLeft + local bpr2 = db.bottomBar2PerRow or 12 + local bpr2Cols = bpr2 + local bpr2Rows = math.ceil(BUTTONS_PER_ROW / bpr2) + local bar2W = bpr2Cols * size + math.max(bpr2Cols - 1, 0) * gap + local bar2H = bpr2Rows * size + math.max(bpr2Rows - 1, 0) * gap + + local row2Anchor = CreateFrame("Frame", "SFramesRow2Anchor", UIParent) + row2Anchor:SetWidth(bar2W) + row2Anchor:SetHeight(bar2H) + row2Anchor:SetScale(db.scale) + self.row2Anchor = row2Anchor + + local r2Pos = SFramesDB and SFramesDB.Positions and SFramesDB.Positions["ActionBarRow2"] + if r2Pos and r2Pos.point and r2Pos.relativePoint then + row2Anchor:SetPoint(r2Pos.point, UIParent, r2Pos.relativePoint, r2Pos.xOfs or 0, r2Pos.yOfs or 0) + else + row2Anchor:SetPoint("BOTTOMLEFT", anchor, "TOPLEFT", 0, gap) + end + if MultiBarBottomLeft then - MultiBarBottomLeft:SetParent(anchor) + MultiBarBottomLeft:SetParent(row2Anchor) MultiBarBottomLeft:ClearAllPoints() - MultiBarBottomLeft:SetPoint("BOTTOMLEFT", row1, "TOPLEFT", 0, gap) - MultiBarBottomLeft:SetWidth(rowWidth) - MultiBarBottomLeft:SetHeight(size) + MultiBarBottomLeft:SetPoint("BOTTOMLEFT", row2Anchor, "BOTTOMLEFT", 0, 0) + MultiBarBottomLeft:SetWidth(bar2W) + MultiBarBottomLeft:SetHeight(bar2H) MultiBarBottomLeft:Show() end self.row2 = MultiBarBottomLeft @@ -544,13 +590,32 @@ function AB:CreateBars() end end - -- Row 3: reposition the original MultiBarBottomRight frame + -- Row 3: independent anchor for MultiBarBottomRight + local bpr3 = db.bottomBar3PerRow or 12 + local bpr3Cols = bpr3 + local bpr3Rows = math.ceil(BUTTONS_PER_ROW / bpr3) + local bar3W = bpr3Cols * size + math.max(bpr3Cols - 1, 0) * gap + local bar3H = bpr3Rows * size + math.max(bpr3Rows - 1, 0) * gap + + local row3Anchor = CreateFrame("Frame", "SFramesRow3Anchor", UIParent) + row3Anchor:SetWidth(bar3W) + row3Anchor:SetHeight(bar3H) + row3Anchor:SetScale(db.scale) + self.row3Anchor = row3Anchor + + local r3Pos = SFramesDB and SFramesDB.Positions and SFramesDB.Positions["ActionBarRow3"] + if r3Pos and r3Pos.point and r3Pos.relativePoint then + row3Anchor:SetPoint(r3Pos.point, UIParent, r3Pos.relativePoint, r3Pos.xOfs or 0, r3Pos.yOfs or 0) + else + row3Anchor:SetPoint("BOTTOMLEFT", row2Anchor, "TOPLEFT", 0, gap) + end + if MultiBarBottomRight then - MultiBarBottomRight:SetParent(anchor) + MultiBarBottomRight:SetParent(row3Anchor) MultiBarBottomRight:ClearAllPoints() - MultiBarBottomRight:SetPoint("BOTTOMLEFT", MultiBarBottomLeft or row1, "TOPLEFT", 0, gap) - MultiBarBottomRight:SetWidth(rowWidth) - MultiBarBottomRight:SetHeight(size) + MultiBarBottomRight:SetPoint("BOTTOMLEFT", row3Anchor, "BOTTOMLEFT", 0, 0) + MultiBarBottomRight:SetWidth(bar3W) + MultiBarBottomRight:SetHeight(bar3H) MultiBarBottomRight:Show() end self.row3 = MultiBarBottomRight @@ -592,23 +657,43 @@ function AB:CreateBars() self.gryphonRight = rightCap - -- === RIGHT-SIDE BARS === - local rightHolder = CreateFrame("Frame", "SFramesRightBarHolder", UIParent) - rightHolder:SetWidth(size * 2 + gap) - rightHolder:SetHeight((size + gap) * BUTTONS_PER_ROW - gap) - local rbPos = SFramesDB and SFramesDB.Positions and SFramesDB.Positions["ActionBarRight"] - if rbPos and rbPos.point and rbPos.relativePoint then - rightHolder:SetPoint(rbPos.point, UIParent, rbPos.relativePoint, rbPos.xOfs or 0, rbPos.yOfs or 0) - else - rightHolder:SetPoint("RIGHT", UIParent, "RIGHT", db.rightOffsetX, db.rightOffsetY) + -- === RIGHT-SIDE BARS (independent holders) === + -- Migrate legacy "ActionBarRight" position to "RightBar1" + if SFramesDB and SFramesDB.Positions then + if SFramesDB.Positions["ActionBarRight"] and not SFramesDB.Positions["RightBar1"] then + SFramesDB.Positions["RightBar1"] = SFramesDB.Positions["ActionBarRight"] + end + end + + local perRow1 = db.rightBar1PerRow or 1 + local perRow2 = db.rightBar2PerRow or 1 + local numCols1 = perRow1 + local numRows1 = math.ceil(BUTTONS_PER_ROW / perRow1) + local rb1W = numCols1 * size + math.max(numCols1 - 1, 0) * gap + local rb1H = numRows1 * size + math.max(numRows1 - 1, 0) * gap + local numCols2 = perRow2 + local numRows2 = math.ceil(BUTTONS_PER_ROW / perRow2) + local rb2W = numCols2 * size + math.max(numCols2 - 1, 0) * gap + local rb2H = numRows2 * size + math.max(numRows2 - 1, 0) * gap + + -- RightBar1: MultiBarRight + local rb1Holder = CreateFrame("Frame", "SFramesRightBar1Holder", UIParent) + rb1Holder:SetWidth(rb1W) + rb1Holder:SetHeight(rb1H) + rb1Holder:SetScale(db.scale) + self.rightBar1Holder = rb1Holder + + local rb1Pos = SFramesDB and SFramesDB.Positions and SFramesDB.Positions["RightBar1"] + if rb1Pos and rb1Pos.point and rb1Pos.relativePoint then + rb1Holder:SetPoint(rb1Pos.point, UIParent, rb1Pos.relativePoint, rb1Pos.xOfs or 0, rb1Pos.yOfs or 0) + else + rb1Holder:SetPoint("RIGHT", UIParent, "RIGHT", db.rightOffsetX, db.rightOffsetY) end - rightHolder:SetScale(db.scale) - self.rightHolder = rightHolder if MultiBarRight then - MultiBarRight:SetParent(rightHolder) + MultiBarRight:SetParent(rb1Holder) MultiBarRight:ClearAllPoints() - MultiBarRight:SetPoint("TOPRIGHT", rightHolder, "TOPRIGHT", 0, 0) + MultiBarRight:SetPoint("TOPLEFT", rb1Holder, "TOPLEFT", 0, 0) MultiBarRight:Show() end @@ -622,10 +707,24 @@ function AB:CreateBars() end end + -- RightBar2: MultiBarLeft + local rb2Holder = CreateFrame("Frame", "SFramesRightBar2Holder", UIParent) + rb2Holder:SetWidth(rb2W) + rb2Holder:SetHeight(rb2H) + rb2Holder:SetScale(db.scale) + self.rightBar2Holder = rb2Holder + + local rb2Pos = SFramesDB and SFramesDB.Positions and SFramesDB.Positions["RightBar2"] + if rb2Pos and rb2Pos.point and rb2Pos.relativePoint then + rb2Holder:SetPoint(rb2Pos.point, UIParent, rb2Pos.relativePoint, rb2Pos.xOfs or 0, rb2Pos.yOfs or 0) + else + rb2Holder:SetPoint("TOPRIGHT", rb1Holder, "TOPLEFT", -gap, 0) + end + if MultiBarLeft then - MultiBarLeft:SetParent(rightHolder) + MultiBarLeft:SetParent(rb2Holder) MultiBarLeft:ClearAllPoints() - MultiBarLeft:SetPoint("TOPRIGHT", MultiBarRight or rightHolder, "TOPLEFT", -gap, 0) + MultiBarLeft:SetPoint("TOPLEFT", rb2Holder, "TOPLEFT", 0, 0) MultiBarLeft:Show() end @@ -639,9 +738,12 @@ function AB:CreateBars() end end + -- Legacy compat: keep rightHolder reference pointing to rb1Holder + self.rightHolder = rb1Holder + -- === STANCE BAR === local stanceHolder = CreateFrame("Frame", "SFramesStanceHolder", UIParent) - stanceHolder:SetWidth(rowWidth) + stanceHolder:SetWidth(bar1W) stanceHolder:SetHeight(size) stanceHolder:SetScale(db.scale) self.stanceHolder = stanceHolder @@ -658,7 +760,7 @@ function AB:CreateBars() -- === PET BAR === local petHolder = CreateFrame("Frame", "SFramesPetHolder", UIParent) - petHolder:SetWidth(rowWidth) + petHolder:SetWidth(bar1W) petHolder:SetHeight(size) petHolder:SetScale(db.scale) self.petHolder = petHolder @@ -710,73 +812,136 @@ function AB:ApplyConfig() local size = db.buttonSize local gap = db.buttonGap - local rowWidth = (size + gap) * BUTTONS_PER_ROW - gap - local colHeight = (size + gap) * BUTTONS_PER_ROW - gap - local totalHeight = db.barCount * size + (db.barCount - 1) * gap - -- Bottom bars anchor + -- Bottom bar 1 dimensions + local bpr1 = db.bottomBar1PerRow or 12 + local bpr1Cols = bpr1 + local bpr1Rows = math.ceil(BUTTONS_PER_ROW / bpr1) + local bar1W = bpr1Cols * size + math.max(bpr1Cols - 1, 0) * gap + local bar1H = bpr1Rows * size + math.max(bpr1Rows - 1, 0) * gap + + -- Bottom bars anchor (row1 only) self.anchor:SetScale(db.scale) - self.anchor:SetWidth(rowWidth) - self.anchor:SetHeight(totalHeight) + self.anchor:SetWidth(bar1W) + self.anchor:SetHeight(bar1H) -- Row 1 - self.row1:SetWidth(rowWidth) - self.row1:SetHeight(size) - LayoutRow(self.mainButtons, self.row1, size, gap) + self.row1:SetWidth(bar1W) + self.row1:SetHeight(bar1H) + LayoutGrid(self.mainButtons, self.row1, size, gap, bpr1) -- Bonus bar (druid forms) — same layout as row 1, overlays when active if self.bonusButtons and BonusActionBarFrame then - BonusActionBarFrame:SetWidth(rowWidth) - BonusActionBarFrame:SetHeight(size) - LayoutRow(self.bonusButtons, BonusActionBarFrame, size, gap) + BonusActionBarFrame:SetWidth(bar1W) + BonusActionBarFrame:SetHeight(bar1H) + LayoutGrid(self.bonusButtons, BonusActionBarFrame, size, gap, bpr1) end - -- Row 2 + -- Bottom bar 2 dimensions + local bpr2 = db.bottomBar2PerRow or 12 + local bpr2Cols = bpr2 + local bpr2Rows = math.ceil(BUTTONS_PER_ROW / bpr2) + local bar2W = bpr2Cols * size + math.max(bpr2Cols - 1, 0) * gap + local bar2H = bpr2Rows * size + math.max(bpr2Rows - 1, 0) * gap + + -- Row 2 (independent anchor) + if self.row2Anchor then + self.row2Anchor:SetScale(db.scale) + self.row2Anchor:SetWidth(bar2W) + self.row2Anchor:SetHeight(bar2H) + end if self.row2 then - self.row2:SetWidth(rowWidth) - self.row2:SetHeight(size) + self.row2:SetWidth(bar2W) + self.row2:SetHeight(bar2H) self.row2:ClearAllPoints() - self.row2:SetPoint("BOTTOMLEFT", self.row1, "TOPLEFT", 0, gap) - LayoutRow(self.bar2Buttons, self.row2, size, gap) - if db.barCount >= 2 then self.row2:Show() else self.row2:Hide() end + self.row2:SetPoint("BOTTOMLEFT", self.row2Anchor or self.anchor, "BOTTOMLEFT", 0, 0) + LayoutGrid(self.bar2Buttons, self.row2, size, gap, bpr2) + if db.barCount >= 2 then + if self.row2Anchor then self.row2Anchor:Show() end + self.row2:Show() + else + if self.row2Anchor then self.row2Anchor:Hide() end + self.row2:Hide() + end end - -- Row 3 + -- Bottom bar 3 dimensions + local bpr3 = db.bottomBar3PerRow or 12 + local bpr3Cols = bpr3 + local bpr3Rows = math.ceil(BUTTONS_PER_ROW / bpr3) + local bar3W = bpr3Cols * size + math.max(bpr3Cols - 1, 0) * gap + local bar3H = bpr3Rows * size + math.max(bpr3Rows - 1, 0) * gap + + -- Row 3 (independent anchor) + if self.row3Anchor then + self.row3Anchor:SetScale(db.scale) + self.row3Anchor:SetWidth(bar3W) + self.row3Anchor:SetHeight(bar3H) + end if self.row3 then - self.row3:SetWidth(rowWidth) - self.row3:SetHeight(size) + self.row3:SetWidth(bar3W) + self.row3:SetHeight(bar3H) self.row3:ClearAllPoints() - self.row3:SetPoint("BOTTOMLEFT", self.row2 or self.row1, "TOPLEFT", 0, gap) - LayoutRow(self.bar3Buttons, self.row3, size, gap) - if db.barCount >= 3 then self.row3:Show() else self.row3:Hide() end + self.row3:SetPoint("BOTTOMLEFT", self.row3Anchor or self.anchor, "BOTTOMLEFT", 0, 0) + LayoutGrid(self.bar3Buttons, self.row3, size, gap, bpr3) + if db.barCount >= 3 then + if self.row3Anchor then self.row3Anchor:Show() end + self.row3:Show() + else + if self.row3Anchor then self.row3Anchor:Hide() end + self.row3:Hide() + end end - -- Right-side bars - if self.rightHolder then - self.rightHolder:SetScale(db.scale) - self.rightHolder:SetWidth(size * 2 + gap) - self.rightHolder:SetHeight(colHeight) + -- Right-side bar 1 (MultiBarRight, grid layout) + if self.rightBar1Holder then + self.rightBar1Holder:SetScale(db.scale) + local perRow1 = db.rightBar1PerRow or 1 + local numCols1 = perRow1 + local numRows1 = math.ceil(BUTTONS_PER_ROW / perRow1) + local rb1W = numCols1 * size + math.max(numCols1 - 1, 0) * gap + local rb1H = numRows1 * size + math.max(numRows1 - 1, 0) * gap + self.rightBar1Holder:SetWidth(rb1W) + self.rightBar1Holder:SetHeight(rb1H) if MultiBarRight then - MultiBarRight:SetWidth(size) - MultiBarRight:SetHeight(colHeight) + MultiBarRight:SetWidth(rb1W) + MultiBarRight:SetHeight(rb1H) + LayoutGrid(self.rightButtons, MultiBarRight, size, gap, perRow1) MultiBarRight:ClearAllPoints() - MultiBarRight:SetPoint("TOPRIGHT", self.rightHolder, "TOPRIGHT", 0, 0) - LayoutColumn(self.rightButtons, MultiBarRight, size, gap) - end - - if MultiBarLeft then - MultiBarLeft:SetWidth(size) - MultiBarLeft:SetHeight(colHeight) - MultiBarLeft:ClearAllPoints() - MultiBarLeft:SetPoint("TOPRIGHT", MultiBarRight or self.rightHolder, "TOPLEFT", -gap, 0) - LayoutColumn(self.leftButtons, MultiBarLeft, size, gap) + MultiBarRight:SetPoint("TOPLEFT", self.rightBar1Holder, "TOPLEFT", 0, 0) end if db.showRightBars then - self.rightHolder:Show() + self.rightBar1Holder:Show() else - self.rightHolder:Hide() + self.rightBar1Holder:Hide() + end + end + + -- Right-side bar 2 (MultiBarLeft, grid layout) + if self.rightBar2Holder then + self.rightBar2Holder:SetScale(db.scale) + local perRow2 = db.rightBar2PerRow or 1 + local numCols2 = perRow2 + local numRows2 = math.ceil(BUTTONS_PER_ROW / perRow2) + local rb2W = numCols2 * size + math.max(numCols2 - 1, 0) * gap + local rb2H = numRows2 * size + math.max(numRows2 - 1, 0) * gap + self.rightBar2Holder:SetWidth(rb2W) + self.rightBar2Holder:SetHeight(rb2H) + + if MultiBarLeft then + MultiBarLeft:SetWidth(rb2W) + MultiBarLeft:SetHeight(rb2H) + LayoutGrid(self.leftButtons, MultiBarLeft, size, gap, perRow2) + MultiBarLeft:ClearAllPoints() + MultiBarLeft:SetPoint("TOPLEFT", self.rightBar2Holder, "TOPLEFT", 0, 0) + end + + if db.showRightBars then + self.rightBar2Holder:Show() + else + self.rightBar2Holder:Hide() end end @@ -785,7 +950,10 @@ function AB:ApplyConfig() if alpha < 0.1 then alpha = 0.1 end if alpha > 1 then alpha = 1 end if self.anchor then self.anchor:SetAlpha(alpha) end - if self.rightHolder then self.rightHolder:SetAlpha(alpha) end + if self.row2Anchor then self.row2Anchor:SetAlpha(alpha) end + if self.row3Anchor then self.row3Anchor:SetAlpha(alpha) end + if self.rightBar1Holder then self.rightBar1Holder:SetAlpha(alpha) end + if self.rightBar2Holder then self.rightBar2Holder:SetAlpha(alpha) end if self.stanceHolder then self.stanceHolder:SetAlpha(alpha) end if self.petHolder then self.petHolder:SetAlpha(alpha) end @@ -893,6 +1061,13 @@ end -------------------------------------------------------------------------------- -- Stance bar -------------------------------------------------------------------------------- +function AB:GetTopRowAnchor() + local db = self:GetDB() + if db.barCount >= 3 and self.row3Anchor then return self.row3Anchor end + if db.barCount >= 2 and self.row2Anchor then return self.row2Anchor end + return self.anchor +end + function AB:ApplyStanceBar() local db = self:GetDB() local size = db.smallBarSize @@ -906,16 +1081,19 @@ function AB:ApplyStanceBar() end self.stanceHolder:SetScale(db.scale) - local topRow = self.row1 - if db.barCount >= 3 and self.row3 then topRow = self.row3 - elseif db.barCount >= 2 and self.row2 then topRow = self.row2 end - - self.stanceHolder:ClearAllPoints() - self.stanceHolder:SetPoint("BOTTOMLEFT", topRow, "TOPLEFT", 0, gap) local totalW = numForms * size + (numForms - 1) * gap self.stanceHolder:SetWidth(totalW) self.stanceHolder:SetHeight(size) + + self.stanceHolder:ClearAllPoints() + local positions = SFramesDB and SFramesDB.Positions + local pos = positions and positions["StanceBar"] + if pos and pos.point and pos.relativePoint then + self.stanceHolder:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + else + self.stanceHolder:SetPoint("BOTTOMLEFT", self:GetTopRowAnchor(), "TOPLEFT", 0, gap) + end self.stanceHolder:Show() for i, b in ipairs(self.stanceButtons) do @@ -951,21 +1129,24 @@ function AB:ApplyPetBar() self.petHolder:SetScale(db.scale) - self.petHolder:ClearAllPoints() - local numForms = GetNumShapeshiftForms and GetNumShapeshiftForms() or 0 - if db.showStanceBar and numForms > 0 and self.stanceHolder:IsShown() then - self.petHolder:SetPoint("BOTTOMLEFT", self.stanceHolder, "TOPLEFT", 0, gap) - else - local topRow = self.row1 - if db.barCount >= 3 and self.row3 then topRow = self.row3 - elseif db.barCount >= 2 and self.row2 then topRow = self.row2 end - self.petHolder:SetPoint("BOTTOMLEFT", topRow, "TOPLEFT", 0, gap) - end - local numPet = 10 local totalW = numPet * size + (numPet - 1) * gap self.petHolder:SetWidth(totalW) self.petHolder:SetHeight(size) + + self.petHolder:ClearAllPoints() + local positions = SFramesDB and SFramesDB.Positions + local pos = positions and positions["PetBar"] + if pos and pos.point and pos.relativePoint then + self.petHolder:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + else + local numForms = GetNumShapeshiftForms and GetNumShapeshiftForms() or 0 + if db.showStanceBar and numForms > 0 and self.stanceHolder and self.stanceHolder:IsShown() then + self.petHolder:SetPoint("BOTTOMLEFT", self.stanceHolder, "TOPLEFT", 0, gap) + else + self.petHolder:SetPoint("BOTTOMLEFT", self:GetTopRowAnchor(), "TOPLEFT", 0, gap) + end + end self.petHolder:Show() for i, b in ipairs(self.petButtons) do @@ -1050,12 +1231,219 @@ function AB:ApplyGryphon() self.gryphonRight:Show() end +-------------------------------------------------------------------------------- +-- Layout presets +-------------------------------------------------------------------------------- +AB.PRESETS = { + { id = 1, name = "经典", desc = "底部堆叠 + 右侧竖栏" }, + { id = 2, name = "宽屏", desc = "左4x3 + 底部堆叠 + 右4x3" }, + { id = 3, name = "堆叠", desc = "全部堆叠于底部中央" }, +} + +function AB:ApplyPreset(presetId) + if not self.anchor then return end + local db = self:GetDB() + if not SFramesDB.Positions then SFramesDB.Positions = {} end + local positions = SFramesDB.Positions + + local size = db.buttonSize + local gap = db.buttonGap + local smSize = db.smallBarSize + local rowW = (size + gap) * BUTTONS_PER_ROW - gap + local bottomY = db.bottomOffsetY or 2 + local step = size + gap + local leftX = -rowW / 2 + + local clearKeys = { + "ActionBarBottom", "ActionBarRow2", "ActionBarRow3", + "RightBar1", "RightBar2", "StanceBar", "PetBar", + "PlayerFrame", "TargetFrame", "PetFrame", "FocusFrame", "ToTFrame", + } + for _, key in ipairs(clearKeys) do + positions[key] = nil + end + + local numForms = GetNumShapeshiftForms and GetNumShapeshiftForms() or 0 + local hasStance = db.showStanceBar and numForms > 0 + local stanceH = hasStance and (smSize + gap) or 0 + + db.bottomBar1PerRow = 12 + db.bottomBar2PerRow = 12 + db.bottomBar3PerRow = 12 + + if presetId == 1 then + -- Classic: stacked bottom bars, vertical right side bars + db.rightBar1PerRow = 1 + db.rightBar2PerRow = 1 + db.showRightBars = true + local rx = db.rightOffsetX or -4 + local ry = db.rightOffsetY or -80 + positions["RightBar1"] = { + point = "RIGHT", relativePoint = "RIGHT", + xOfs = rx, yOfs = ry, + } + positions["RightBar2"] = { + point = "RIGHT", relativePoint = "RIGHT", + xOfs = rx - size - gap, yOfs = ry, + } + positions["StanceBar"] = { + point = "BOTTOMLEFT", relativePoint = "BOTTOM", + xOfs = leftX, yOfs = bottomY + step * 3, + } + positions["PetBar"] = { + point = "BOTTOMLEFT", relativePoint = "BOTTOM", + xOfs = leftX, yOfs = bottomY + step * 3 + stanceH, + } + + elseif presetId == 2 then + -- Widescreen: left 4x3, center stack, right 4x3 + db.rightBar1PerRow = 4 + db.rightBar2PerRow = 4 + db.showRightBars = true + positions["RightBar1"] = { + point = "BOTTOMRIGHT", relativePoint = "BOTTOM", + xOfs = -(rowW / 2 + gap * 2), yOfs = bottomY, + } + positions["RightBar2"] = { + point = "BOTTOMLEFT", relativePoint = "BOTTOM", + xOfs = rowW / 2 + gap * 2, yOfs = bottomY, + } + positions["StanceBar"] = { + point = "BOTTOMLEFT", relativePoint = "BOTTOM", + xOfs = leftX, yOfs = bottomY + step * 3, + } + positions["PetBar"] = { + point = "BOTTOMLEFT", relativePoint = "BOTTOM", + xOfs = leftX, yOfs = bottomY + step * 3 + stanceH, + } + + elseif presetId == 3 then + -- Stacked: all stacked at bottom center + db.rightBar1PerRow = 12 + db.rightBar2PerRow = 12 + db.showRightBars = true + local barH3 = 3 * step + positions["RightBar1"] = { + point = "BOTTOM", relativePoint = "BOTTOM", + xOfs = 0, yOfs = bottomY + barH3, + } + positions["RightBar2"] = { + point = "BOTTOM", relativePoint = "BOTTOM", + xOfs = 0, yOfs = bottomY + barH3 + step, + } + positions["StanceBar"] = { + point = "BOTTOMLEFT", relativePoint = "BOTTOM", + xOfs = leftX, yOfs = bottomY + barH3 + step * 2, + } + positions["PetBar"] = { + point = "BOTTOMLEFT", relativePoint = "BOTTOM", + xOfs = leftX, yOfs = bottomY + barH3 + step * 2 + stanceH, + } + local upShift = step * 2 + positions["PlayerFrame"] = { + point = "CENTER", relativePoint = "CENTER", + xOfs = -200, yOfs = -100 + upShift, + } + positions["TargetFrame"] = { + point = "CENTER", relativePoint = "CENTER", + xOfs = 200, yOfs = -100 + upShift, + } + end + + -- Castbar: centered above PetBar, explicit UIParent coords (avoids layout-engine timing issues) + local petBarPos = positions["PetBar"] + if petBarPos then + local petTopY = petBarPos.yOfs + smSize + positions["PlayerCastbar"] = { + point = "BOTTOM", relativePoint = "BOTTOM", + xOfs = 0, yOfs = petTopY + 6, + } + end + + -- Calculate Pet/Focus positions relative to Player/Target + local playerPos = positions["PlayerFrame"] + local px = playerPos and playerPos.xOfs or -200 + local py = playerPos and playerPos.yOfs or -100 + + local targetPos = positions["TargetFrame"] + local tx = targetPos and targetPos.xOfs or 200 + local ty = targetPos and targetPos.yOfs or -100 + + local pf = _G["SFramesPlayerFrame"] + local pfScale = pf and (pf:GetEffectiveScale() / UIParent:GetEffectiveScale()) or 1 + local pfW = ((pf and pf:GetWidth()) or 220) * pfScale + local pfH = ((pf and pf:GetHeight()) or 50) * pfScale + + local tf = _G["SFramesTargetFrame"] + local tfScale = tf and (tf:GetEffectiveScale() / UIParent:GetEffectiveScale()) or 1 + local tfW = ((tf and tf:GetWidth()) or 220) * tfScale + local tfH = ((tf and tf:GetHeight()) or 50) * tfScale + + local petGap, focGap + if presetId == 1 then + petGap, focGap = 75, 71 + elseif presetId == 2 then + petGap, focGap = 62, 58 + else + petGap, focGap = 52, 42 + end + + positions["PetFrame"] = { + point = "TOPLEFT", relativePoint = "CENTER", + xOfs = px - pfW / 2, + yOfs = py - pfH / 2 - petGap, + } + positions["FocusFrame"] = { + point = "TOPLEFT", relativePoint = "CENTER", + xOfs = tx - tfW / 2, + yOfs = ty - tfH / 2 - focGap, + } + positions["ToTFrame"] = { + point = "BOTTOMLEFT", relativePoint = "CENTER", + xOfs = tx + tfW / 2 + 5, + yOfs = ty - tfH / 2, + } + + db.layoutPreset = presetId + self:ApplyConfig() + + if SFrames.Movers then + local reg = SFrames.Movers:GetRegistry() + for _, key in ipairs({"PlayerFrame", "TargetFrame", "PetFrame", "FocusFrame", "ToTFrame"}) do + local entry = reg[key] + if entry and entry.frame then + SFrames.Movers:ApplyPosition(key, entry.frame, + entry.defaultPoint, entry.defaultRelativeTo, + entry.defaultRelPoint, entry.defaultX, entry.defaultY) + end + end + end + + if SFrames.Player and SFrames.Player.ApplyCastbarPosition then + SFrames.Player:ApplyCastbarPosition() + end + + if SFrames.Movers and SFrames.Movers:IsLayoutMode() then + for name, entry in pairs(SFrames.Movers:GetRegistry()) do + local frame = entry and entry.frame + local shouldSync = entry.alwaysShowInLayout + or (frame and frame.IsShown and frame:IsShown()) + if shouldSync then + SFrames.Movers:SyncMoverToFrame(name) + end + end + end + + SFrames:Print("|cff66eeff[布局预设]|r 已应用方案: " .. (self.PRESETS[presetId] and self.PRESETS[presetId].name or tostring(presetId))) +end + -------------------------------------------------------------------------------- -- Slider-based position update -------------------------------------------------------------------------------- function AB:ApplyPosition() local db = self:GetDB() local positions = SFramesDB and SFramesDB.Positions + local gap = db.buttonGap if self.anchor then self.anchor:ClearAllPoints() @@ -1066,13 +1454,44 @@ function AB:ApplyPosition() self.anchor:SetPoint("BOTTOM", UIParent, "BOTTOM", db.bottomOffsetX, db.bottomOffsetY) end end - if self.rightHolder then - self.rightHolder:ClearAllPoints() - local pos = positions and positions["ActionBarRight"] + + if self.row2Anchor then + self.row2Anchor:ClearAllPoints() + local pos = positions and positions["ActionBarRow2"] if pos and pos.point and pos.relativePoint then - self.rightHolder:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + self.row2Anchor:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) else - self.rightHolder:SetPoint("RIGHT", UIParent, "RIGHT", db.rightOffsetX, db.rightOffsetY) + self.row2Anchor:SetPoint("BOTTOMLEFT", self.anchor, "TOPLEFT", 0, gap) + end + end + + if self.row3Anchor then + self.row3Anchor:ClearAllPoints() + local pos = positions and positions["ActionBarRow3"] + if pos and pos.point and pos.relativePoint then + self.row3Anchor:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + else + self.row3Anchor:SetPoint("BOTTOMLEFT", self.row2Anchor or self.anchor, "TOPLEFT", 0, gap) + end + end + + if self.rightBar1Holder then + self.rightBar1Holder:ClearAllPoints() + local pos = positions and positions["RightBar1"] + if pos and pos.point and pos.relativePoint then + self.rightBar1Holder:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + else + self.rightBar1Holder:SetPoint("RIGHT", UIParent, "RIGHT", db.rightOffsetX, db.rightOffsetY) + end + end + + if self.rightBar2Holder then + self.rightBar2Holder:ClearAllPoints() + local pos = positions and positions["RightBar2"] + if pos and pos.point and pos.relativePoint then + self.rightBar2Holder:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + else + self.rightBar2Holder:SetPoint("TOPRIGHT", self.rightBar1Holder or UIParent, "TOPLEFT", -gap, 0) end end end @@ -1112,13 +1531,17 @@ function AB:UpdateBonusBar() local db = self:GetDB() local size = db.buttonSize local gap = db.buttonGap - local rowWidth = (size + gap) * BUTTONS_PER_ROW - gap + local bpr = db.bottomBar1PerRow or 12 + local numC = bpr + local numR = math.ceil(BUTTONS_PER_ROW / bpr) + local bW = numC * size + math.max(numC - 1, 0) * gap + local bH = numR * size + math.max(numR - 1, 0) * gap BonusActionBarFrame:ClearAllPoints() BonusActionBarFrame:SetPoint("BOTTOMLEFT", self.row1, "BOTTOMLEFT", 0, 0) - BonusActionBarFrame:SetWidth(rowWidth) - BonusActionBarFrame:SetHeight(size) + BonusActionBarFrame:SetWidth(bW) + BonusActionBarFrame:SetHeight(bH) BonusActionBarFrame:SetFrameLevel(self.row1:GetFrameLevel() + 5) - LayoutRow(self.bonusButtons, BonusActionBarFrame, size, gap) + LayoutGrid(self.bonusButtons, BonusActionBarFrame, size, gap, bpr) local btnLevel = BonusActionBarFrame:GetFrameLevel() + 1 for _, b in ipairs(self.bonusButtons) do b:EnableMouse(true) @@ -1150,91 +1573,6 @@ 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 @@ -1247,18 +1585,6 @@ function AB:SetupRangeCheck() local db = AB:GetDB() - -- 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 if not getID then return end @@ -1275,20 +1601,8 @@ function AB:SetupRangeCheck() 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 @@ -1420,16 +1734,15 @@ function AB:Initialize() local db = AB:GetDB() local size = db.buttonSize local gap = db.buttonGap + local bpr = db.bottomBar1PerRow or 12 for idx, btn in ipairs(AB.bonusButtons) do if btn == b then btn:SetWidth(size) btn:SetHeight(size) btn:ClearAllPoints() - if idx == 1 then - btn:SetPoint("BOTTOMLEFT", BonusActionBarFrame, "BOTTOMLEFT", 0, 0) - else - btn:SetPoint("LEFT", AB.bonusButtons[idx - 1], "RIGHT", gap, 0) - end + local col = math.fmod(idx - 1, bpr) + local row = math.floor((idx - 1) / bpr) + btn:SetPoint("TOPLEFT", BonusActionBarFrame, "TOPLEFT", col * (size + gap), -row * (size + gap)) break end end @@ -1455,9 +1768,22 @@ function AB:Initialize() end end + -- Shared helper: re-anchor row2/row3 inside their independent holders after + -- Blizzard code resets their positions. + local function FixRowParenting() + if not AB.anchor then return end + if AB.row2 and AB.row2Anchor then + AB.row2:ClearAllPoints() + AB.row2:SetPoint("BOTTOMLEFT", AB.row2Anchor, "BOTTOMLEFT", 0, 0) + end + if AB.row3 and AB.row3Anchor then + AB.row3:ClearAllPoints() + AB.row3:SetPoint("BOTTOMLEFT", AB.row3Anchor, "BOTTOMLEFT", 0, 0) + end + end + -- Hook MultiActionBar_Update: Blizzard 在 PLAYER_ENTERING_WORLD 等事件中调用此函数, -- 它会根据经验条/声望条的高度重设 MultiBarBottomLeft/Right 的位置,覆盖我们的布局。 - -- 仅修正 row2/row3 的锚点,不调用 ApplyConfig 避免触发 MultiBarBottomLeft:Show 再次递归。 local origMultiActionBarUpdate = MultiActionBar_Update if origMultiActionBarUpdate then local inMultiBarHook = false @@ -1465,15 +1791,7 @@ function AB:Initialize() pcall(origMultiActionBarUpdate) if AB.anchor and not inMultiBarHook then inMultiBarHook = true - local g = AB:GetDB().buttonGap - if AB.row2 then - AB.row2:ClearAllPoints() - AB.row2:SetPoint("BOTTOMLEFT", AB.row1, "TOPLEFT", 0, g) - end - if AB.row3 then - AB.row3:ClearAllPoints() - AB.row3:SetPoint("BOTTOMLEFT", AB.row2 or AB.row1, "TOPLEFT", 0, g) - end + FixRowParenting() inMultiBarHook = false end end @@ -1481,21 +1799,12 @@ function AB:Initialize() -- Hook UIParent_ManageFramePositions: Blizzard 在进出战斗、切换地图等场景调用此 -- 函数重排 MultiBar 位置(会考虑经验条/声望条高度),覆盖我们的布局。 - -- 在原函数执行完毕后立即修正 row2/row3 锚点。 local origManageFramePositions = UIParent_ManageFramePositions if origManageFramePositions then UIParent_ManageFramePositions = function(a1, a2, a3) origManageFramePositions(a1, a2, a3) - if AB.anchor and AB.row1 then - local g = AB:GetDB().buttonGap - if AB.row2 then - AB.row2:ClearAllPoints() - AB.row2:SetPoint("BOTTOMLEFT", AB.row1, "TOPLEFT", 0, g) - end - if AB.row3 then - AB.row3:ClearAllPoints() - AB.row3:SetPoint("BOTTOMLEFT", AB.row2 or AB.row1, "TOPLEFT", 0, g) - end + if AB.anchor then + FixRowParenting() end if SFrames and SFrames.Chat then SFrames.Chat:ReanchorChatFrames() @@ -1547,13 +1856,17 @@ function AB:Initialize() local db = AB:GetDB() local size = db.buttonSize local gap = db.buttonGap - local rowWidth = (size + gap) * BUTTONS_PER_ROW - gap + local bpr = db.bottomBar1PerRow or 12 + local numC = bpr + local numR = math.ceil(BUTTONS_PER_ROW / bpr) + local bW = numC * size + math.max(numC - 1, 0) * gap + local bH = numR * size + math.max(numR - 1, 0) * gap BonusActionBarFrame:ClearAllPoints() BonusActionBarFrame:SetPoint("BOTTOMLEFT", AB.row1, "BOTTOMLEFT", 0, 0) - BonusActionBarFrame:SetWidth(rowWidth) - BonusActionBarFrame:SetHeight(size) + BonusActionBarFrame:SetWidth(bW) + BonusActionBarFrame:SetHeight(bH) BonusActionBarFrame:SetFrameLevel(AB.row1:GetFrameLevel() + 5) - LayoutRow(AB.bonusButtons, BonusActionBarFrame, size, gap) + LayoutGrid(AB.bonusButtons, BonusActionBarFrame, size, gap, bpr) local btnLevel = BonusActionBarFrame:GetFrameLevel() + 1 for _, b in ipairs(AB.bonusButtons) do b:EnableMouse(true) @@ -1581,16 +1894,8 @@ function AB:Initialize() -- 进出战斗和切换区域时立即修正行间距,防止 Blizzard 布局覆盖 local function FixRowAnchors() - if not AB.anchor or not AB.row1 then return end - local g = AB:GetDB().buttonGap - if AB.row2 then - AB.row2:ClearAllPoints() - AB.row2:SetPoint("BOTTOMLEFT", AB.row1, "TOPLEFT", 0, g) - end - if AB.row3 then - AB.row3:ClearAllPoints() - AB.row3:SetPoint("BOTTOMLEFT", AB.row2 or AB.row1, "TOPLEFT", 0, g) - end + if not AB.anchor then return end + FixRowParenting() end SFrames:RegisterEvent("PLAYER_REGEN_DISABLED", FixRowAnchors) @@ -1602,21 +1907,39 @@ function AB:Initialize() -- Register movers if SFrames.Movers and SFrames.Movers.RegisterMover then if self.anchor then - SFrames.Movers:RegisterMover("ActionBarBottom", self.anchor, "底部动作条", + SFrames.Movers:RegisterMover("ActionBarBottom", self.anchor, "底部主动作条", "BOTTOM", "UIParent", "BOTTOM", db.bottomOffsetX, db.bottomOffsetY, function() AB:ApplyStanceBar(); AB:ApplyPetBar(); AB:ApplyGryphon() end) end - if self.rightHolder then - SFrames.Movers:RegisterMover("ActionBarRight", self.rightHolder, "右侧动作条", + if self.row2Anchor then + SFrames.Movers:RegisterMover("ActionBarRow2", self.row2Anchor, "底部动作条2", + "BOTTOMLEFT", "SFramesActionBarAnchor", "TOPLEFT", 0, db.buttonGap, + function() AB:ApplyStanceBar(); AB:ApplyPetBar() end) + end + if self.row3Anchor then + SFrames.Movers:RegisterMover("ActionBarRow3", self.row3Anchor, "底部动作条3", + "BOTTOMLEFT", "SFramesRow2Anchor", "TOPLEFT", 0, db.buttonGap, + function() AB:ApplyStanceBar(); AB:ApplyPetBar() end) + end + if self.rightBar1Holder then + SFrames.Movers:RegisterMover("RightBar1", self.rightBar1Holder, "右侧动作条1", "RIGHT", "UIParent", "RIGHT", db.rightOffsetX, db.rightOffsetY) end + if self.rightBar2Holder then + SFrames.Movers:RegisterMover("RightBar2", self.rightBar2Holder, "右侧动作条2", + "TOPRIGHT", "SFramesRightBar1Holder", "TOPLEFT", -db.buttonGap, 0) + end + local topAnchorName = "SFramesActionBarAnchor" + if db.barCount >= 3 and self.row3Anchor then topAnchorName = "SFramesRow3Anchor" + elseif db.barCount >= 2 and self.row2Anchor then topAnchorName = "SFramesRow2Anchor" end + if self.stanceHolder then SFrames.Movers:RegisterMover("StanceBar", self.stanceHolder, "姿态条", - "BOTTOMLEFT", "SFramesActionBarAnchor", "TOPLEFT", 0, db.buttonGap) + "BOTTOMLEFT", topAnchorName, "TOPLEFT", 0, db.buttonGap) end if self.petHolder then SFrames.Movers:RegisterMover("PetBar", self.petHolder, "宠物条", - "BOTTOMLEFT", "SFramesActionBarAnchor", "TOPLEFT", 0, db.buttonGap) + "BOTTOMLEFT", topAnchorName, "TOPLEFT", 0, db.buttonGap) end if self.gryphonLeft then SFrames.Movers:RegisterMover("GryphonLeft", self.gryphonLeft, "狮鹫(左)", @@ -1650,6 +1973,10 @@ do end end +function AB:RegisterBindButton(buttonName, bindCommand) + BUTTON_BINDING_MAP[buttonName] = bindCommand +end + -------------------------------------------------------------------------------- -- Hotkey text refresh: update the HotKey FontString on buttons to reflect -- current keybindings (works for all bars including stance and pet). @@ -1687,6 +2014,15 @@ function AB:RefreshAllHotkeys() Refresh(self.leftButtons) Refresh(self.stanceButtons) Refresh(self.petButtons) + if SFrames.ExtraBar and SFrames.ExtraBar.buttons then + local ebDb = SFrames.ExtraBar:GetDB() + if ebDb.enable then + local ebCount = math.min(ebDb.buttonCount or 12, 48) + for i = 1, ebCount do + RefreshButtonHotkey(SFrames.ExtraBar.buttons[i]) + end + end + end end local IGNORE_KEYS = { @@ -1860,6 +2196,16 @@ function AB:EnterKeyBindMode() Collect(self.leftButtons) Collect(self.stanceButtons) Collect(self.petButtons) + if SFrames.ExtraBar and SFrames.ExtraBar.buttons then + local ebDb = SFrames.ExtraBar:GetDB() + if ebDb.enable then + local ebCount = math.min(ebDb.buttonCount or 12, 48) + for i = 1, ebCount do + local b = SFrames.ExtraBar.buttons[i] + if b then table.insert(allButtons, b) end + end + end + end for _, b in ipairs(allButtons) do local ov = CreateBindOverlay(b) diff --git a/AuraTracker.lua b/AuraTracker.lua new file mode 100644 index 0000000..56fd57b --- /dev/null +++ b/AuraTracker.lua @@ -0,0 +1,594 @@ +SFrames.AuraTracker = SFrames.AuraTracker or {} + +local AT = SFrames.AuraTracker + +AT.units = AT.units or {} +AT.unitRefs = AT.unitRefs or {} +AT.durationCache = AT.durationCache or { buff = {}, debuff = {} } +AT.initialized = AT.initialized or false + +local SOURCE_PRIORITY = { + player_native = 6, + superwow = 5, + nanamiplates = 4, + shagutweaks = 3, + combat_log = 2, + estimated = 1, +} + +local function GetNow() + return GetTime and GetTime() or 0 +end + +local function IsBuffType(auraType) + return auraType == "buff" +end + +local function ClampPositive(value) + value = tonumber(value) or 0 + if value > 0 then + return value + end + return nil +end + +local function SafeUnitGUID(unit) + if unit and UnitGUID then + local ok, guid = pcall(UnitGUID, unit) + if ok and guid and guid ~= "" then + return guid + end + end + return nil +end + +local function SafeUnitName(unit) + if unit and UnitName then + local ok, name = pcall(UnitName, unit) + if ok and name and name ~= "" then + return name + end + end + return nil +end + +local function GetUnitKey(unit) + local guid = SafeUnitGUID(unit) + if guid then + return guid + end + local name = SafeUnitName(unit) + if name then + return "name:" .. name + end + return unit and ("unit:" .. unit) or nil +end + +local function DurationCacheKey(auraType, spellId, name, texture) + if spellId and spellId > 0 then + return auraType .. ":id:" .. tostring(spellId) + end + if name and name ~= "" then + return auraType .. ":name:" .. string.lower(name) + end + return auraType .. ":tex:" .. tostring(texture or "") +end + +local function BuildStateKeys(auraType, spellId, name, texture, casterKey) + local keys = {} + if spellId and spellId > 0 then + tinsert(keys, auraType .. ":id:" .. tostring(spellId)) + if casterKey and casterKey ~= "" then + tinsert(keys, auraType .. ":id:" .. tostring(spellId) .. ":caster:" .. casterKey) + end + end + if name and name ~= "" then + local lowerName = string.lower(name) + tinsert(keys, auraType .. ":name:" .. lowerName) + if casterKey and casterKey ~= "" then + tinsert(keys, auraType .. ":name:" .. lowerName .. ":caster:" .. casterKey) + end + if texture and texture ~= "" then + tinsert(keys, auraType .. ":name:" .. lowerName .. ":tex:" .. texture) + end + elseif texture and texture ~= "" then + tinsert(keys, auraType .. ":tex:" .. texture) + end + return keys +end + +local function TooltipLine1() + return getglobal("SFramesScanTooltipTextLeft1") +end + +local function CLMatch(msg, pattern) + if not msg or not pattern or pattern == "" then return nil end + local pat = string.gsub(pattern, "%%%d?%$?s", "(.+)") + pat = string.gsub(pat, "%%%d?%$?d", "(%%d+)") + for a, b, c in string.gfind(msg, pat) do + return a, b, c + end + return nil +end + +function AT:GetUnitState(unitOrGUID) + local key = unitOrGUID + if not key then return nil end + if string.find(key, "^target") or string.find(key, "^player") or string.find(key, "^party") or string.find(key, "^raid") or string.find(key, "^pet") then + key = GetUnitKey(key) + end + if not key then return nil end + if not self.units[key] then + self.units[key] = { + guid = key, + buffs = {}, + debuffs = {}, + maps = { buff = {}, debuff = {} }, + snapshotCount = 0, + lastSeen = 0, + name = nil, + level = 0, + } + end + return self.units[key] +end + +function AT:ClearUnit(unitOrGUID) + local key = unitOrGUID + if not key then return end + if not self.units[key] then + key = GetUnitKey(unitOrGUID) + end + if not key then return end + self.units[key] = nil + + for unit, guid in pairs(self.unitRefs) do + if guid == key then + self.unitRefs[unit] = nil + end + end +end + +function AT:ClearCurrentTarget() + local oldGUID = self.unitRefs["target"] + self.unitRefs["target"] = nil + if oldGUID then + local state = self.units[oldGUID] + if state then + state.maps.buff = {} + state.maps.debuff = {} + end + end +end + +function AT:RememberDuration(auraType, spellId, name, texture, duration) + duration = ClampPositive(duration) + if not duration then return end + self.durationCache[auraType][DurationCacheKey(auraType, spellId, name, texture)] = duration +end + +function AT:GetRememberedDuration(auraType, spellId, name, texture) + return self.durationCache[auraType][DurationCacheKey(auraType, spellId, name, texture)] +end + +function AT:ReadAuraName(unit, index, isBuff, auraID) + if auraID and auraID > 0 and SpellInfo then + local ok, spellName = pcall(SpellInfo, auraID) + if ok and spellName and spellName ~= "" then + return spellName + end + end + + -- Tooltip scan is expensive and can crash on invalid unit/index state. + -- Only attempt if unit still exists and the aura slot is still valid. + if not SFrames.Tooltip then return nil end + if not unit or not UnitExists or not UnitExists(unit) then return nil end + + local ok, text = pcall(function() + SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") + SFrames.Tooltip:ClearLines() + if isBuff then + SFrames.Tooltip:SetUnitBuff(unit, index) + else + SFrames.Tooltip:SetUnitDebuff(unit, index) + end + local line = TooltipLine1() + local t = line and line.GetText and line:GetText() or nil + SFrames.Tooltip:Hide() + return t + end) + + if ok and text and text ~= "" then + return text + end + return nil +end + +function AT:GetPlayerAuraTime(unit, index, isBuff, texture) + if not UnitIsUnit or not UnitIsUnit(unit, "player") then + return nil, nil, nil + end + if not GetPlayerBuff or not GetPlayerBuffTexture or not GetPlayerBuffTimeLeft then + return nil, nil, nil + end + + local filter = isBuff and "HELPFUL" or "HARMFUL" + for i = 0, 31 do + local buffIndex = GetPlayerBuff(i, filter) + if buffIndex and buffIndex >= 0 and GetPlayerBuffTexture(buffIndex) == texture then + local timeLeft = ClampPositive(GetPlayerBuffTimeLeft(buffIndex)) + if timeLeft then + return timeLeft, nil, "player_native" + end + end + end + return nil, nil, nil +end + +function AT:GetExternalTime(unit, auraType, index, state) + local isBuff = IsBuffType(auraType) + local timeLeft, duration, source + + if state and state.texture then + timeLeft, duration, source = self:GetPlayerAuraTime(unit, index, isBuff, state.texture) + if timeLeft then + return timeLeft, duration, source + end + end + + if not isBuff and NanamiPlates_SpellDB and NanamiPlates_SpellDB.UnitDebuff then + local effect, _, _, _, _, npDuration, npTimeLeft = NanamiPlates_SpellDB:UnitDebuff(unit, index) + npTimeLeft = ClampPositive(npTimeLeft) + npDuration = ClampPositive(npDuration) + if effect and effect ~= "" and (not state.name or state.name == "") then + state.name = effect + end + if npTimeLeft then + return npTimeLeft, npDuration, "nanamiplates" + end + end + + if not isBuff and ShaguTweaks and ShaguTweaks.libdebuff and ShaguTweaks.libdebuff.UnitDebuff then + local _, _, _, _, _, shaguDuration, shaguTimeLeft = ShaguTweaks.libdebuff:UnitDebuff(unit, index) + shaguTimeLeft = ClampPositive(shaguTimeLeft) + shaguDuration = ClampPositive(shaguDuration) + if shaguTimeLeft then + return shaguTimeLeft, shaguDuration, "shagutweaks" + end + end + + return nil, nil, nil +end + +function AT:ApplyTiming(state, timeLeft, duration, source, now, allowWeaker) + timeLeft = ClampPositive(timeLeft) + if not timeLeft then return false end + + duration = ClampPositive(duration) or state.duration or timeLeft + local newPriority = SOURCE_PRIORITY[source] or 0 + local oldPriority = SOURCE_PRIORITY[state.source] or 0 + + if not allowWeaker and state.expirationTime and state.expirationTime > now and oldPriority > newPriority then + return false + end + + state.duration = duration + state.expirationTime = now + timeLeft + state.appliedAt = state.expirationTime - duration + state.source = source + state.isEstimated = (source == "estimated") and 1 or nil + + self:RememberDuration(state.auraType, state.spellId, state.name, state.texture, duration) + return true +end + +function AT:CaptureAura(unit, auraType, index) + local isBuff = IsBuffType(auraType) + local texture, count, dispelType, auraID + local hasSuperWoW = SFrames.superwow_active and SpellInfo + + if isBuff then + texture, auraID = UnitBuff(unit, index) + else + texture, count, dispelType, auraID = UnitDebuff(unit, index) + end + + if not texture then + return nil + end + + local spellId = (hasSuperWoW and type(auraID) == "number" and auraID > 0) and auraID or nil + -- Only call ReadAuraName when we have a spellId (fast path) or for the first 16 slots + -- to avoid spamming tooltip API on every aura slot every UNIT_AURA event. + local name + if spellId or index <= 16 then + name = self:ReadAuraName(unit, index, isBuff, spellId) + end + + return { + auraType = auraType, + index = index, + spellId = spellId, + name = name, + texture = texture, + stacks = tonumber(count) or 0, + dispelType = dispelType, + casterKey = nil, + } +end + +function AT:FindPreviousState(unitState, auraType, index, info) + local previous = unitState.maps[auraType][index] + if previous then + local keys = BuildStateKeys(auraType, info.spellId, info.name, info.texture, info.casterKey) + local prevKeys = BuildStateKeys(auraType, previous.spellId, previous.name, previous.texture, previous.casterKey) + + for _, key in ipairs(keys) do + for _, prevKey in ipairs(prevKeys) do + if key == prevKey then + return previous + end + end + end + end + + local states = IsBuffType(auraType) and unitState.buffs or unitState.debuffs + local keys = BuildStateKeys(auraType, info.spellId, info.name, info.texture, info.casterKey) + for _, key in ipairs(keys) do + local state = states[key] + if state then + return state + end + end + + return nil +end + +function AT:UpdateSnapshotState(unitState, unit, auraType, index, info, now) + local state = self:FindPreviousState(unitState, auraType, index, info) + if not state then + state = { + guid = unitState.guid, + auraType = auraType, + firstSeenAt = now, + } + end + + state.guid = unitState.guid + state.auraType = auraType + state.spellId = info.spellId + state.name = info.name or state.name + state.texture = info.texture + state.stacks = info.stacks + state.dispelType = info.dispelType + state.casterKey = info.casterKey + state.lastSeen = now + state.index = index + + local timeLeft, duration, source = self:GetExternalTime(unit, auraType, index, state) + if timeLeft then + self:ApplyTiming(state, timeLeft, duration, source, now) + elseif state.expirationTime and state.expirationTime <= now then + state.expirationTime = nil + state.duration = nil + state.appliedAt = nil + state.source = nil + end + + return state +end + +function AT:RebuildStateMaps(unitState, auraType, slots, now) + local active = {} + local newMap = {} + + for index, state in pairs(slots) do + if state then + for _, key in ipairs(BuildStateKeys(auraType, state.spellId, state.name, state.texture, state.casterKey)) do + active[key] = state + end + newMap[index] = state + end + end + + if IsBuffType(auraType) then + unitState.buffs = active + else + unitState.debuffs = active + end + unitState.maps[auraType] = newMap + unitState.lastSeen = now +end + +function AT:ApplyCombatHint(auraType, auraName) + if not auraName or auraName == "" then return end + if auraType ~= "debuff" then return end + local guid = self.unitRefs["target"] + if not guid then return end + + local unitState = self.units[guid] + if not unitState then return end + + local active = IsBuffType(auraType) and unitState.buffs or unitState.debuffs + local state = active[auraType .. ":name:" .. string.lower(auraName)] + if not state then return end + + local remembered = self:GetRememberedDuration(auraType, state.spellId, state.name, state.texture) + if remembered then + self:ApplyTiming(state, remembered, remembered, "combat_log", GetNow(), true) + end +end + +function AT:ClearCombatHint(auraName) + if not auraName or auraName == "" then return end + local guid = self.unitRefs["target"] + if not guid then return end + + local unitState = self.units[guid] + if not unitState then return end + + local lowerName = string.lower(auraName) + for _, auraType in ipairs({ "buff", "debuff" }) do + local active = IsBuffType(auraType) and unitState.buffs or unitState.debuffs + local state = active[auraType .. ":name:" .. lowerName] + if state then + state.expirationTime = nil + state.duration = nil + state.appliedAt = nil + state.source = nil + end + end +end + +function AT:HandleCombatMessage(msg) + if not msg or msg == "" or not UnitExists or not UnitExists("target") then return end + + local targetName = SafeUnitName("target") + if not targetName then return end + + local targetUnit, auraName = CLMatch(msg, AURAADDEDOTHERHARMFUL or "%s is afflicted by %s.") + if targetUnit == targetName and auraName then + self:ApplyCombatHint("debuff", auraName) + return + end + + auraName, targetUnit = CLMatch(msg, AURAREMOVEDOTHER or "%s fades from %s.") + if targetUnit == targetName and auraName then + self:ClearCombatHint(auraName) + end +end + +function AT:HandleAuraSnapshot(unit) + if not unit then return end + if not UnitExists or not UnitExists(unit) then + if unit == "target" then + self:ClearCurrentTarget() + end + return + end + + local guid = GetUnitKey(unit) + if not guid then return end + + local now = GetNow() + local unitState = self:GetUnitState(guid) + unitState.guid = guid + unitState.name = SafeUnitName(unit) + unitState.level = (UnitLevel and UnitLevel(unit)) or 0 + unitState.lastSeen = now + + self.unitRefs[unit] = guid + + local buffSlots = {} + local debuffSlots = {} + + for i = 1, 32 do + local info = self:CaptureAura(unit, "buff", i) + if info then + buffSlots[i] = self:UpdateSnapshotState(unitState, unit, "buff", i, info, now) + end + end + + for i = 1, 32 do + local info = self:CaptureAura(unit, "debuff", i) + if info then + debuffSlots[i] = self:UpdateSnapshotState(unitState, unit, "debuff", i, info, now) + end + end + + self:RebuildStateMaps(unitState, "buff", buffSlots, now) + self:RebuildStateMaps(unitState, "debuff", debuffSlots, now) + + unitState.snapshotCount = unitState.snapshotCount + 1 +end + +function AT:GetAuraState(unit, auraType, index) + local unitState = self:GetUnitState(unit) + if not unitState then return nil end + local map = unitState.maps[auraType] + return map and map[index] or nil +end + +function AT:GetAuraTimeLeft(unit, auraType, index) + local state = self:GetAuraState(unit, auraType, index) + if not state or not state.expirationTime then + return nil + end + + local remaining = state.expirationTime - GetNow() + if remaining > 0 then + return remaining + end + return nil +end + +function AT:PurgeStaleUnits() + local now = GetNow() + local activeTargetGUID = self.unitRefs["target"] + for guid, state in pairs(self.units) do + if guid ~= activeTargetGUID and state.lastSeen and (now - state.lastSeen) > 120 then + self.units[guid] = nil + end + end +end + +function AT:OnEvent() + if event == "PLAYER_TARGET_CHANGED" then + if UnitExists and UnitExists("target") then + self:HandleAuraSnapshot("target") + else + self:ClearCurrentTarget() + end + self:PurgeStaleUnits() + return + end + + if event == "UNIT_AURA" and arg1 == "target" then + self:HandleAuraSnapshot("target") + return + end + + if event == "PLAYER_ENTERING_WORLD" then + self:ClearCurrentTarget() + self:PurgeStaleUnits() + return + end + + if arg1 and string.find(event, "CHAT_MSG_SPELL") then + self:HandleCombatMessage(arg1) + end +end + +function AT:Initialize() + if self.initialized then return end + self.initialized = true + + local frame = CreateFrame("Frame", "SFramesAuraTracker", UIParent) + self.frame = frame + + frame:RegisterEvent("PLAYER_TARGET_CHANGED") + frame:RegisterEvent("UNIT_AURA") + frame:RegisterEvent("PLAYER_ENTERING_WORLD") + local chatEvents = { + "CHAT_MSG_SPELL_SELF_DAMAGE", + "CHAT_MSG_SPELL_SELF_BUFF", + "CHAT_MSG_SPELL_PARTY_DAMAGE", + "CHAT_MSG_SPELL_PARTY_BUFF", + "CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE", + "CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF", + "CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE", + "CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF", + "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", + } + for _, ev in ipairs(chatEvents) do + frame:RegisterEvent(ev) + end + frame:SetScript("OnEvent", function() + AT:OnEvent() + end) +end diff --git a/Bindings.xml b/Bindings.xml index 2e26d22..c4337e0 100644 --- a/Bindings.xml +++ b/Bindings.xml @@ -4,4 +4,149 @@ SFrames.WorldMap:ToggleNav() end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(1) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(2) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(3) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(4) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(5) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(6) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(7) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(8) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(9) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(10) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(11) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(12) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(13) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(14) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(15) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(16) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(17) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(18) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(19) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(20) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(21) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(22) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(23) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(24) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(25) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(26) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(27) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(28) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(29) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(30) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(31) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(32) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(33) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(34) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(35) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(36) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(37) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(38) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(39) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(40) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(41) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(42) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(43) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(44) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(45) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(46) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(47) end + + + if SFrames and SFrames.ExtraBar then SFrames.ExtraBar:RunButton(48) end + diff --git a/CharacterPanel.lua b/CharacterPanel.lua index 1422773..b030f86 100644 --- a/CharacterPanel.lua +++ b/CharacterPanel.lua @@ -338,6 +338,58 @@ local BASE_SPELL_CRIT = { DRUID = 1.8, SHAMAN = 2.3, PALADIN = 0, } +-------------------------------------------------------------------------------- +-- Temporary weapon enchant crit detection (sharpening stones / scopes) +-- Scans tooltip green text for crit keywords, returns crit% bonus (e.g. 2) +-------------------------------------------------------------------------------- +local _tempEnchTip +local function GetTempEnchantCrit(slotId) + if not GetWeaponEnchantInfo then return 0 end + -- slotId 16=MainHand, 17=OffHand, 18=Ranged + local hasMain, _, _, hasOff + hasMain, _, _, hasOff = GetWeaponEnchantInfo() + if slotId == 16 and not hasMain then return 0 end + if slotId == 17 and not hasOff then return 0 end + if slotId == 18 and not hasMain and not hasOff then + -- ranged slot: some servers report via hasMain for ranged-only classes + -- try scanning anyway if there's a ranged weapon equipped + if not GetInventoryItemLink("player", 18) then return 0 end + end + + if not _tempEnchTip then + _tempEnchTip = CreateFrame("GameTooltip", "SFramesCPTempEnchTip", UIParent, "GameTooltipTemplate") + _tempEnchTip:SetPoint("BOTTOMRIGHT", UIParent, "BOTTOMRIGHT", -300, -300) + end + local tip = _tempEnchTip + tip:SetOwner(UIParent, "ANCHOR_NONE") + tip:ClearLines() + tip:SetInventoryItem("player", slotId) + local n = tip:NumLines() + if not n or n < 2 then return 0 end + + for i = 2, n do + local obj = _G["SFramesCPTempEnchTipTextLeft" .. i] + if obj then + local txt = obj:GetText() + if txt and txt ~= "" then + local r, g, b = obj:GetTextColor() + -- green text = enchant/buff line + if g > 0.8 and r < 0.5 and b < 0.5 then + -- Match patterns like: "+2% 致命一击" / "+2% Critical" / "致命一击几率提高2%" + local _, _, pct = string.find(txt, "(%d+)%%%s*致命") + if not pct then _, _, pct = string.find(txt, "致命.-(%d+)%%") end + if not pct then _, _, pct = string.find(txt, "(%d+)%%%s*[Cc]rit") end + if not pct then _, _, pct = string.find(txt, "[Cc]rit.-(%d+)%%") end + if not pct then _, _, pct = string.find(txt, "(%d+)%%%s*暴击") end + if not pct then _, _, pct = string.find(txt, "暴击.-(%d+)%%") end + if pct then return tonumber(pct) or 0 end + end + end + end + end + return 0 +end + local function CalcMeleeCrit() local _, class = UnitClass("player") class = class or "" @@ -523,8 +575,9 @@ local function FullMeleeCrit() end local gearCrit = GetGearBonus("CRIT") local talentCrit = GetTalentBonus("meleeCrit") - return baseCrit + agiCrit + gearCrit + talentCrit, - baseCrit, agiCrit, gearCrit, talentCrit + local tempCrit = GetTempEnchantCrit(16) + return baseCrit + agiCrit + gearCrit + talentCrit + tempCrit, + baseCrit, agiCrit, gearCrit, talentCrit, tempCrit end local function FullRangedCrit() local _, class = UnitClass("player") @@ -537,8 +590,9 @@ local function FullRangedCrit() end local gearCrit = GetGearBonus("RANGEDCRIT") + GetGearBonus("CRIT") local talentCrit = GetTalentBonus("rangedCrit") - return baseCrit + agiCrit + gearCrit + talentCrit, - baseCrit, agiCrit, gearCrit, talentCrit + local tempCrit = GetTempEnchantCrit(18) + return baseCrit + agiCrit + gearCrit + talentCrit + tempCrit, + baseCrit, agiCrit, gearCrit, talentCrit, tempCrit end local function FullSpellCrit() local _, class = UnitClass("player") @@ -659,6 +713,7 @@ CS.FullSpellHit = FullSpellHit CS.GetTalentDetailsFor = GetTalentDetailsFor CS.GetGearBonus = GetGearBonus CS.GetItemBonusLib = GetItemBonusLib +CS.GetTempEnchantCrit = GetTempEnchantCrit CS.AGI_PER_MELEE_CRIT = AGI_PER_MELEE_CRIT SFrames.CharacterPanel.CS = CS @@ -2009,7 +2064,7 @@ function CP:BuildEquipmentPage() local crit = CS.SafeGetMeleeCrit() CS.TipKV("当前暴击率:", string.format("%.2f%%", crit), 0.7,0.7,0.75, 1,1,0.5) else - local total, base, agiC, gearC, talC = CS.FullMeleeCrit() + local total, base, agiC, gearC, talC, tempC = CS.FullMeleeCrit() CS.TipLine("来源分项:", 0.5,0.8,1) if base > 0 then CS.TipKV(" 基础暴击:", string.format("%.2f%%", base)) end if agiC > 0 then CS.TipKV(" 敏捷暴击:", string.format("%.2f%%", agiC)) end @@ -2021,6 +2076,9 @@ function CP:BuildEquipmentPage() d.name, d.rank, d.maxRank, d.bonus), 0.55,0.55,0.6) end end + if tempC and tempC > 0 then + CS.TipKV(" 临时附魔(磨刀石):", string.format("+%d%%", tempC), 0.7,0.7,0.75, 0.3,1,0.3) + end GameTooltip:AddLine(" ") CS.TipKV("合计暴击率:", string.format("%.2f%%", total), 0.7,0.7,0.75, 1,1,0.5) CS.TipLine("(Buff 暴击未计入)", 0.8,0.5,0.3) @@ -2135,7 +2193,7 @@ function CP:BuildEquipmentPage() if fromAPI then CS.TipKV("当前暴击率:", string.format("%.2f%%", CS.SafeGetRangedCrit()), 0.7,0.7,0.75, 1,1,0.5) else - local total, base, agiC, gearC, talC = CS.FullRangedCrit() + local total, base, agiC, gearC, talC, tempC = CS.FullRangedCrit() CS.TipLine("来源分项:", 0.5,0.8,1) if base > 0 then CS.TipKV(" 基础暴击:", string.format("%.2f%%", base)) end if agiC > 0 then CS.TipKV(" 敏捷暴击:", string.format("%.2f%%", agiC)) end @@ -2147,6 +2205,9 @@ function CP:BuildEquipmentPage() d.name, d.rank, d.maxRank, d.bonus), 0.55,0.55,0.6) end end + if tempC and tempC > 0 then + CS.TipKV(" 临时附魔(瞄准镜):", string.format("+%d%%", tempC), 0.7,0.7,0.75, 0.3,1,0.3) + end GameTooltip:AddLine(" ") CS.TipKV("合计暴击率:", string.format("%.2f%%", total), 0.7,0.7,0.75, 1,1,0.5) CS.TipLine("(Buff 暴击未计入)", 0.8,0.5,0.3) diff --git a/Chat.lua b/Chat.lua index 20317c2..3b88aac 100644 --- a/Chat.lua +++ b/Chat.lua @@ -18,6 +18,7 @@ local DEFAULTS = { topPadding = 30, bottomPadding = 8, bgAlpha = 0.45, + hoverTransparent = true, activeTab = 1, editBoxPosition = "bottom", editBoxX = 0, @@ -739,27 +740,87 @@ local function GetTranslateFilterKeyForEvent(event) return TRANSLATE_EVENT_FILTERS[event] end -local function ParseHardcoreDeathMessage(text) - if type(text) ~= "string" or text == "" then return nil end - if not string.find(text, "硬核") and not string.find(text, "死亡") then - local lower = string.lower(text) - if not string.find(lower, "hc news") and not string.find(lower, "has fallen") - and not string.find(lower, "died") and not string.find(lower, "slain") then - return nil +-- ============================================================ +-- HC 公会成员缓存:用于"仅通报工会成员"过滤 +-- ============================================================ +local HCGuildMemberCache = {} + +local function RefreshHCGuildCache() + HCGuildMemberCache = {} + if not (IsInGuild and IsInGuild()) then return end + if not GetNumGuildMembers then return end + local total = GetNumGuildMembers() + for i = 1, total do + local name = GetGuildRosterInfo(i) + if type(name) == "string" and name ~= "" then + HCGuildMemberCache[name] = true end end +end + +local function IsHCGuildMember(name) + return type(name) == "string" and HCGuildMemberCache[name] == true +end + +-- 从 HC 系统消息中提取主角色名(死亡/升级均支持) +local function ParseHCCharacterName(text) + if type(text) ~= "string" then return nil end + -- 中文死亡格式: "硬核角色 NAME(等级 N)" + local _, _, n1 = string.find(text, "硬核角色%s+(.-)(等级") + if n1 and n1 ~= "" then return n1 end + -- 中文升级格式: "NAME 在硬核模式中已达到" + local _, _, n2 = string.find(text, "^(.-)%s+在硬核模式") + if n2 and n2 ~= "" then return n2 end + -- 英文死亡格式: "Hardcore character NAME (Level N)" + local _, _, n3 = string.find(text, "[Hh]ardcore character%s+(.-)%s+%(Level") + if n3 and n3 ~= "" then return n3 end + -- 英文升级格式: "NAME has reached level" + local _, _, n4 = string.find(text, "^(.-)%s+has reached level") + if n4 and n4 ~= "" then return n4 end + return nil +end + +-- 检测HC等级里程碑消息(如"达到20级");返回等级数字,否则返回nil +local function ParseHardcoreLevelMessage(text) + if type(text) ~= "string" or text == "" then return nil end + local lower = string.lower(text) + -- 英文: "has reached level X" / "reached level X" + if string.find(lower, "reached level") then + local _, _, lvl = string.find(lower, "reached level%s+(%d+)") + return tonumber(lvl) or 1 + end + -- 中文: "达到 X 级" / "已达到X级" + if string.find(text, "达到") then + local _, _, lvl = string.find(text, "达到%s*(%d+)%s*级") + if lvl then return tonumber(lvl) end + end + return nil +end + +-- 检测HC死亡通报消息;排除等级里程碑;返回等级数字,否则返回nil +local function ParseHardcoreDeathMessage(text) + if type(text) ~= "string" or text == "" then return nil end + -- 先排除等级里程碑消息,避免误判(里程碑消息也含"死亡"字样) + if ParseHardcoreLevelMessage(text) then return nil end + -- 初步过滤:必须含有死亡/击杀相关关键词 + local lower = string.lower(text) + local hasHC = string.find(text, "硬核") or string.find(lower, "hardcore") or string.find(lower, "hc") + local hasDead = string.find(text, "死亡") or string.find(text, "击杀") + or string.find(lower, "has fallen") or string.find(lower, "slain") + or string.find(lower, "died") or string.find(lower, "hc news") + if not hasDead then return nil end local clean = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "") clean = string.gsub(clean, "|r", "") - local _, _, lvlStr = string.find(clean, "Level%s+(%d+)") + -- 英文格式: Level: 17 / Level 17 + local _, _, lvlStr = string.find(clean, "Level%s*:%s*(%d+)") if lvlStr then return tonumber(lvlStr) end - local _, _, lvlStr2 = string.find(clean, "(%d+)%s*级") + local _, _, lvlStr2 = string.find(clean, "Level%s+(%d+)") if lvlStr2 then return tonumber(lvlStr2) end - local _, _, lvlStr3 = string.find(clean, "Level:%s+(%d+)") + -- 中文格式: 等级 1 / 等级1 / (等级 1) + local _, _, lvlStr3 = string.find(clean, "等级%s*(%d+)") if lvlStr3 then return tonumber(lvlStr3) end - local lower = string.lower(clean) - if string.find(lower, "hc news") or (string.find(clean, "硬核") and (string.find(clean, "死亡") or string.find(lower, "has fallen"))) then - return 1 - end + -- 兜底:有死亡关键词即返回1 + if hasDead then return 1 end return nil end @@ -774,6 +835,68 @@ local function CleanTextForTranslation(text) return clean end +-- HC 死亡消息的句式精确解析:提取怪物名和地点名,翻译后原位替换,玩家名不动 +-- 中文格式: "...硬核角色 [玩家](等级 N)被 [怪物](等级 N)击杀。这发生在 [地点]。..." +-- 英文格式: "...character [PLAYER] (Level N) has been slain by [MONSTER] (Level N)...in [ZONE]." +local function TranslateHCDeathParts(text, onDone) + local api = _G.STranslateAPI + local canTranslate = api and api.IsReady and api.IsReady() and api.ForceToChinese + + -- 提取怪物名(中文句式) + local _, _, monster = string.find(text, ")被%s*(.-)(等级") + -- 提取地点名(中文句式) + local _, _, zone = string.find(text, "这发生在%s*(.-)。") + + -- 英文句式兜底 + if not monster then + local _, _, m = string.find(text, "slain by%s+(.-)%s+%(Level") + if m then monster = m end + end + if not zone then + local _, _, z = string.find(text, "This happened in%s+(.-)[%.%!]") + if z then zone = z end + end + + -- 收集需要翻译的词(含英文字母才有翻译意义) + local targets = {} + if monster and string.find(monster, "[A-Za-z]") then + table.insert(targets, monster) + end + if zone and string.find(zone, "[A-Za-z]") and zone ~= monster then + table.insert(targets, zone) + end + + if not canTranslate or table.getn(targets) == 0 then + onDone(text) + return + end + + -- 并行翻译,全部完成后替换回原文 + local out = text + local total = table.getn(targets) + local doneCount = 0 + for i = 1, total do + local orig = targets[i] + api.ForceToChinese(orig, function(translated, err, meta) + if translated and translated ~= "" and translated ~= orig then + local esc = string.gsub(orig, "([%(%)%.%%%+%-%*%?%[%^%$])", "%%%1") + local safeRepl = string.gsub(translated, "%%", "%%%%") + out = string.gsub(out, esc, safeRepl) + end + doneCount = doneCount + 1 + if doneCount == total then + onDone(out) + end + end, "Nanami-UI-HC") + end +end + +-- 等级里程碑消息:玩家名无需翻译(是玩家自选名),整条消息已由服务器本地化,直接转发 +local function TranslateHCLevelParts(text, onDone) + -- 仅当存在非玩家名的英文才翻译;里程碑消息唯一英文就是玩家名,故直接原文转发 + onDone(text) +end + local function ForceHide(object) if not object then return end object:Hide() @@ -789,6 +912,66 @@ local function ForceInvisible(object) if object.EnableMouse then object:EnableMouse(false) end end +local function StripChatFrameArtwork(chatFrame) + if not chatFrame then return end + + local frameID = chatFrame.GetID and chatFrame:GetID() + if frameID then + if SetChatWindowColor then + pcall(function() SetChatWindowColor(frameID, 0, 0, 0) end) + end + if SetChatWindowAlpha then + pcall(function() SetChatWindowAlpha(frameID, 0) end) + end + end + + if FCF_SetWindowAlpha then + pcall(function() FCF_SetWindowAlpha(chatFrame, 0) end) + end + + if chatFrame.SetBackdropColor then + chatFrame:SetBackdropColor(0, 0, 0, 0) + end + if chatFrame.SetBackdropBorderColor then + chatFrame:SetBackdropBorderColor(0, 0, 0, 0) + end + if chatFrame.SetBackdrop then + pcall(function() chatFrame:SetBackdrop(nil) end) + end + + local frameName = chatFrame.GetName and chatFrame:GetName() + if type(frameName) == "string" and frameName ~= "" then + local legacyTextures = { + frameName .. "Background", + frameName .. "BackgroundLeft", + frameName .. "BackgroundMiddle", + frameName .. "BackgroundRight", + frameName .. "BottomButton", + frameName .. "ButtonFrame", + frameName .. "ButtonFrameBackground", + } + for _, texName in ipairs(legacyTextures) do + local tex = _G[texName] + if tex then + if tex.SetTexture then tex:SetTexture(nil) end + if tex.SetVertexColor then tex:SetVertexColor(0, 0, 0, 0) end + if tex.SetAlpha then tex:SetAlpha(0) end + tex:Hide() + end + end + end + + local regions = { chatFrame:GetRegions() } + for _, region in ipairs(regions) do + if region and region.GetObjectType and region:GetObjectType() == "Texture" then + if region.SetTexture then region:SetTexture(nil) end + if region.SetVertexColor then region:SetVertexColor(0, 0, 0, 0) end + if region.SetAlpha then region:SetAlpha(0) end + region:Hide() + end + end +end + local function CreateFont(parent, size, justify) if SFrames and SFrames.CreateFontString then return SFrames:CreateFontString(parent, size, justify) @@ -1547,6 +1730,9 @@ local function EnsureDB() 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 db.hcDeathToGuild == nil then db.hcDeathToGuild = true end + if db.hcLevelToGuild == nil then db.hcLevelToGuild = true end + if db.hcGuildMemberOnly == nil then db.hcGuildMemberOnly = false end if type(db.layoutVersion) ~= "number" then db.layoutVersion = 1 end if db.layoutVersion < 2 then db.topPadding = DEFAULTS.topPadding @@ -1895,6 +2081,7 @@ function SFrames.Chat:GetConfig() topPadding = math.floor(Clamp(db.topPadding, 24, 64) + 0.5), bottomPadding = math.floor(Clamp(db.bottomPadding, 4, 18) + 0.5), bgAlpha = Clamp(db.bgAlpha, 0, 1), + hoverTransparent = (db.hoverTransparent ~= false), editBoxPosition = editBoxPosition, editBoxX = tonumber(db.editBoxX) or DEFAULTS.editBoxX, editBoxY = tonumber(db.editBoxY) or DEFAULTS.editBoxY, @@ -3321,6 +3508,19 @@ function SFrames.Chat:RefreshConfigFrame() end end + if self.cfgMonitorSection then + self.cfgMonitorSection:SetAlpha(1) + end + if self.cfgMonitorCb then + self.cfgMonitorCb:Enable() + end + if self.cfgMonitorDesc then + self.cfgMonitorDesc:SetTextColor(0.7, 0.7, 0.74) + end + if self.cfgMonitorReloadHint then + self.cfgMonitorReloadHint:SetTextColor(0.9, 0.75, 0.5) + end + if self.configControls then for i = 1, table.getn(self.configControls) do local ctrl = self.configControls[i] @@ -3485,12 +3685,12 @@ function SFrames.Chat:EnsureConfigFrame() transDesc:SetPoint("TOPLEFT", engineSection, "TOPLEFT", 38, -50) transDesc:SetWidth(520) transDesc:SetJustifyH("LEFT") - transDesc:SetText("关闭后将完全停止调用 STranslateAPI 翻译接口,所有标签的自动翻译均不生效。") + transDesc:SetText("关闭后将完全停止调用 STranslateAPI 翻译接口,所有标签的自动翻译均不生效。聊天监控可独立启用。") transDesc:SetTextColor(0.7, 0.7, 0.74) local monitorSection = CreateCfgSection(generalPage, "聊天消息监控", 0, -136, 584, 160, fontPath) - AddControl(CreateCfgCheck(monitorSection, "启用聊天消息监控与收集", 16, -30, + local monitorCb = CreateCfgCheck(monitorSection, "启用聊天消息监控与收集", 16, -30, function() return EnsureDB().chatMonitorEnabled ~= false end, function(checked) EnsureDB().chatMonitorEnabled = (checked == true) @@ -3498,15 +3698,19 @@ function SFrames.Chat:EnsureConfigFrame() function() SFrames.Chat:RefreshConfigFrame() end - )) + ) + AddControl(monitorCb) + self.cfgMonitorCb = monitorCb + self.cfgMonitorSection = monitorSection local monDesc = monitorSection:CreateFontString(nil, "OVERLAY") monDesc:SetFont(fontPath, 10, "OUTLINE") monDesc:SetPoint("TOPLEFT", monitorSection, "TOPLEFT", 38, -50) monDesc:SetWidth(520) monDesc:SetJustifyH("LEFT") - monDesc:SetText("启用后将拦截聊天消息,提供消息历史缓存、右键复制 [+] 标记、频道翻译触发等功能。\n关闭后消息将原样通过,不做任何处理(翻译、复制等功能不可用)。") + monDesc:SetText("启用后将拦截聊天消息,提供消息历史缓存、右键复制 [+] 标记、职业染色等功能。\n可独立于 AI 翻译开关使用。关闭后消息将原样通过,[+] 复制等功能不可用。") monDesc:SetTextColor(0.7, 0.7, 0.74) + self.cfgMonitorDesc = monDesc local reloadHint = monitorSection:CreateFontString(nil, "OVERLAY") reloadHint:SetFont(fontPath, 10, "OUTLINE") @@ -3515,11 +3719,12 @@ function SFrames.Chat:EnsureConfigFrame() reloadHint:SetJustifyH("LEFT") reloadHint:SetText("提示:更改监控开关后建议 /reload 以确保完全生效。") reloadHint:SetTextColor(0.9, 0.75, 0.5) + self.cfgMonitorReloadHint = reloadHint end local windowPage = CreatePage("window") do - local appearance = CreateCfgSection(windowPage, "窗口外观", 0, 0, 584, 274, fontPath) + local appearance = CreateCfgSection(windowPage, "窗口外观", 0, 0, 584, 304, fontPath) AddControl(CreateCfgSlider(appearance, "宽度", 16, -46, 260, 320, 900, 1, function() return EnsureDB().width end, function(v) EnsureDB().width = v end, @@ -3580,13 +3785,18 @@ function SFrames.Chat:EnsureConfigFrame() function(checked) EnsureDB().showPlayerLevel = (checked == true) end, function() SFrames.Chat:RefreshConfigFrame() end )) + AddControl(CreateCfgCheck(appearance, "悬停显示背景", 16, -248, + function() return EnsureDB().hoverTransparent ~= false end, + function(checked) EnsureDB().hoverTransparent = (checked == true) end, + function() SFrames.Chat:ApplyConfig(); SFrames.Chat:RefreshConfigFrame() end + )) self.cfgWindowSummaryText = appearance:CreateFontString(nil, "OVERLAY") self.cfgWindowSummaryText:SetFont(fontPath, 10, "OUTLINE") self.cfgWindowSummaryText:SetPoint("BOTTOMLEFT", appearance, "BOTTOMLEFT", 16, 10) self.cfgWindowSummaryText:SetTextColor(0.74, 0.74, 0.8) - local inputSection = CreateCfgSection(windowPage, "输入框", 0, -290, 584, 114, fontPath) + local inputSection = CreateCfgSection(windowPage, "输入框", 0, -320, 584, 114, fontPath) self.cfgInputModeText = inputSection:CreateFontString(nil, "OVERLAY") self.cfgInputModeText:SetFont(fontPath, 11, "OUTLINE") self.cfgInputModeText:SetPoint("TOPLEFT", inputSection, "TOPLEFT", 16, -30) @@ -3618,7 +3828,7 @@ function SFrames.Chat:EnsureConfigFrame() inputTip:SetText("建议优先使用顶部或底部模式;自由拖动适合特殊布局。") inputTip:SetTextColor(0.74, 0.74, 0.8) - local actionSection = CreateCfgSection(windowPage, "窗口操作", 0, -398, 584, 96, fontPath) + local actionSection = CreateCfgSection(windowPage, "窗口操作", 0, -428, 584, 96, fontPath) CreateCfgButton(actionSection, "重置位置", 16, -32, 108, 24, function() SFrames.Chat:ResetPosition() SFrames.Chat:RefreshConfigFrame() @@ -4050,7 +4260,7 @@ function SFrames.Chat:EnsureConfigFrame() local hcPage = CreatePage("hc") do - local hcControls = CreateCfgSection(hcPage, "硬核生存服务器专属", 0, 0, 584, 182, fontPath) + local hcControls = CreateCfgSection(hcPage, "硬核生存服务器专属", 0, 0, 584, 382, fontPath) local hcStatusText = hcControls:CreateFontString(nil, "OVERLAY") hcStatusText:SetFont(fontPath, 10, "OUTLINE") @@ -4095,7 +4305,7 @@ function SFrames.Chat:EnsureConfigFrame() deathTip:SetPoint("TOPLEFT", hcControls, "TOPLEFT", 16, -112) deathTip:SetWidth(540) deathTip:SetJustifyH("LEFT") - deathTip:SetText("关闭那些“某某在XX级死亡”的系统提示。") + deathTip:SetText("关闭那些[某某在XX级死亡]的系统提示。") deathTip:SetTextColor(0.8, 0.7, 0.7) AddControl(CreateCfgSlider(hcControls, "最低死亡通报等级", 340, -82, 210, 0, 60, 1, @@ -4104,6 +4314,48 @@ function SFrames.Chat:EnsureConfigFrame() function(v) return (v == 0) and "所有击杀" or (tostring(v) .. " 级及以上") end, function() SFrames.Chat:RefreshConfigFrame() end )) + + AddControl(CreateCfgCheck(hcControls, "翻译死亡通报并转发到公会频道", 16, -138, + function() return EnsureDB().hcDeathToGuild ~= false end, + function(checked) EnsureDB().hcDeathToGuild = (checked == true) end, + function() SFrames.Chat:RefreshConfigFrame() end + )) + + local guildTip = hcControls:CreateFontString(nil, "OVERLAY") + guildTip:SetFont(fontPath, 10, "OUTLINE") + guildTip:SetPoint("TOPLEFT", hcControls, "TOPLEFT", 16, -164) + guildTip:SetWidth(540) + guildTip:SetJustifyH("LEFT") + guildTip:SetText("开启 AI 翻译时,将死亡黄字系统消息翻译后自动发送到公会频道。需 AI 翻译已启用。") + guildTip:SetTextColor(0.8, 0.7, 0.7) + + AddControl(CreateCfgCheck(hcControls, "翻译等级里程碑并转发到公会频道", 16, -204, + function() return EnsureDB().hcLevelToGuild ~= false end, + function(checked) EnsureDB().hcLevelToGuild = (checked == true) end, + function() SFrames.Chat:RefreshConfigFrame() end + )) + + local levelGuildTip = hcControls:CreateFontString(nil, "OVERLAY") + levelGuildTip:SetFont(fontPath, 10, "OUTLINE") + levelGuildTip:SetPoint("TOPLEFT", hcControls, "TOPLEFT", 16, -228) + levelGuildTip:SetWidth(540) + levelGuildTip:SetJustifyH("LEFT") + levelGuildTip:SetText("开启 AI 翻译时,将[达到X级]里程碑系统消息翻译后转发到公会频道。与死亡通报独立控制。") + levelGuildTip:SetTextColor(0.8, 0.7, 0.7) + + AddControl(CreateCfgCheck(hcControls, "仅通报工会成员的死亡/升级消息", 16, -270, + function() return EnsureDB().hcGuildMemberOnly == true end, + function(checked) EnsureDB().hcGuildMemberOnly = (checked == true) end, + function() SFrames.Chat:RefreshConfigFrame() end + )) + + local guildMemberTip = hcControls:CreateFontString(nil, "OVERLAY") + guildMemberTip:SetFont(fontPath, 10, "OUTLINE") + guildMemberTip:SetPoint("TOPLEFT", hcControls, "TOPLEFT", 16, -296) + guildMemberTip:SetWidth(540) + guildMemberTip:SetJustifyH("LEFT") + guildMemberTip:SetText("勾选后,仅当死亡/升级角色为本工会成员时才转发到公会频道。默认关闭(通报所有人)。") + guildMemberTip:SetTextColor(0.8, 0.7, 0.7) end local close = CreateCfgButton(panel, "保存", 430, -588, 150, 28, function() @@ -4399,6 +4651,7 @@ function SFrames.Chat:CreateContainer() }) chatShadow:SetBackdropColor(0, 0, 0, 0.55) chatShadow:SetBackdropBorderColor(0, 0, 0, 0.4) + f.chatShadow = chatShadow local topGlow = f:CreateTexture(nil, "BACKGROUND") topGlow:SetTexture("Interface\\Buttons\\WHITE8X8") @@ -4674,6 +4927,9 @@ function SFrames.Chat:CreateContainer() scrollTrack:SetPoint("BOTTOM", scrollDownBtn, "TOP", 0, 2) scrollTrack:SetWidth(4) scrollTrack:SetVertexColor(0.18, 0.19, 0.22, 0.9) + f.scrollUpBtn = scrollUpBtn + f.scrollDownBtn = scrollDownBtn + f.scrollTrack = scrollTrack local resize = CreateFrame("Button", nil, f) resize:SetWidth(16) @@ -4709,6 +4965,67 @@ function SFrames.Chat:CreateContainer() f.resizeHandle = resize self.frame = f + -- ── Hover-transparent: fade background/chrome when mouse is not over chat ── + f.sfHoverAlpha = 0 + f.sfHoverTarget = 0 + local FADE_SPEED = 4.0 -- alpha per second + -- Apply initial transparent state immediately after first config apply + f.sfHoverInitPending = true + + local function IsMouseOverChat() + if MouseIsOver(f) then return true end + if SFrames.Chat.editBackdrop and SFrames.Chat.editBackdrop:IsShown() and MouseIsOver(SFrames.Chat.editBackdrop) then return true end + if SFrames.Chat.configFrame and SFrames.Chat.configFrame:IsShown() then return true end + local editBox = ChatFrameEditBox or ChatFrame1EditBox + if editBox and editBox:IsShown() then return true end + return false + end + + local hoverFrame = CreateFrame("Frame", nil, f) + hoverFrame:SetScript("OnUpdate", function() + if not (SFrames and SFrames.Chat and SFrames.Chat.frame) then return end + local cfg = SFrames.Chat:GetConfig() + -- On first tick, snap to correct state (no animation) + if f.sfHoverInitPending then + f.sfHoverInitPending = nil + if cfg.hoverTransparent then + local over = IsMouseOverChat() and 1 or 0 + f.sfHoverAlpha = over + f.sfHoverTarget = over + SFrames.Chat:ApplyHoverAlpha(over) + else + f.sfHoverAlpha = 1 + f.sfHoverTarget = 1 + end + return + end + if not cfg.hoverTransparent then + if f.sfHoverAlpha ~= 1 then + f.sfHoverAlpha = 1 + SFrames.Chat:ApplyHoverAlpha(1) + end + return + end + local target = IsMouseOverChat() and 1 or 0 + f.sfHoverTarget = target + local cur = f.sfHoverAlpha or 1 + if math.abs(cur - target) < 0.01 then + if cur ~= target then + f.sfHoverAlpha = target + SFrames.Chat:ApplyHoverAlpha(target) + end + return + end + local dt = arg1 or 0.016 + if target > cur then + cur = math.min(cur + FADE_SPEED * dt, target) + else + cur = math.max(cur - FADE_SPEED * dt, target) + end + f.sfHoverAlpha = cur + SFrames.Chat:ApplyHoverAlpha(cur) + end) + if not self.hiddenConfigButton then local hiddenConfigButton = CreateFrame("Button", "SFramesChatHiddenConfigButton", UIParent, "UIPanelButtonTemplate") hiddenConfigButton:SetWidth(74) @@ -4749,9 +5066,45 @@ function SFrames.Chat:CreateContainer() f:SetWidth(Clamp(db.width, 320, 900)) f:SetHeight(Clamp(db.height, 120, 460)) - -- Background alpha: always show at configured bgAlpha + -- Background alpha: respect hoverTransparent on init local bgA = Clamp(db.bgAlpha or DEFAULTS.bgAlpha, 0, 1) - f:SetBackdropColor(CFG_THEME.panelBg[1], CFG_THEME.panelBg[2], CFG_THEME.panelBg[3], bgA) + if db.hoverTransparent ~= false then + f:SetBackdropColor(CFG_THEME.panelBg[1], CFG_THEME.panelBg[2], CFG_THEME.panelBg[3], 0) + f:SetBackdropBorderColor(CFG_THEME.panelBorder[1], CFG_THEME.panelBorder[2], CFG_THEME.panelBorder[3], 0) + if f.chatShadow then + f.chatShadow:SetBackdropColor(0, 0, 0, 0) + f.chatShadow:SetBackdropBorderColor(0, 0, 0, 0) + end + if f.innerShade then + f.innerShade:SetVertexColor(0, 0, 0, 0) + f.innerShade:Hide() + end + if f.watermark then + f.watermark:SetVertexColor(1, 0.78, 0.9, 0) + f.watermark:Hide() + end + if f.title and f.title.SetAlpha then f.title:SetAlpha(0) + elseif f.title and f.title.SetTextColor then f.title:SetTextColor(1, 0.82, 0.93, 0) end + if f.title then f.title:Hide() end + if f.titleBtn then + f.titleBtn:EnableMouse(false) + f.titleBtn:Hide() + end + if f.leftCat then + f.leftCat:SetVertexColor(1, 0.82, 0.9, 0) + f.leftCat:Hide() + end + if f.tabBar then f.tabBar:SetAlpha(0) f.tabBar:Hide() end + if f.configButton then f.configButton:SetAlpha(0) f.configButton:EnableMouse(false) f.configButton:Hide() end + if f.whisperButton then f.whisperButton:SetAlpha(0) f.whisperButton:EnableMouse(false) f.whisperButton:Hide() end + if f.scrollUpBtn then f.scrollUpBtn:SetAlpha(0) f.scrollUpBtn:EnableMouse(false) f.scrollUpBtn:Hide() end + if f.scrollDownBtn then f.scrollDownBtn:SetAlpha(0) f.scrollDownBtn:EnableMouse(false) f.scrollDownBtn:Hide() end + if f.scrollTrack then f.scrollTrack:SetVertexColor(0.18, 0.19, 0.22, 0) f.scrollTrack:Hide() end + if f.resizeHandle then f.resizeHandle:SetAlpha(0) f.resizeHandle:EnableMouse(false) f.resizeHandle:Hide() end + if f.hint then f.hint:Hide() end + else + f:SetBackdropColor(CFG_THEME.panelBg[1], CFG_THEME.panelBg[2], CFG_THEME.panelBg[3], bgA) + end end function SFrames.Chat:HideDefaultChrome() @@ -5225,8 +5578,15 @@ function SFrames.Chat:ApplyChatFrameBaseStyle(chatFrame, isCombat) end if chatFrame.SetHyperlinksEnabled then chatFrame:SetHyperlinksEnabled(1) end if chatFrame.SetIndentedWordWrap then chatFrame:SetIndentedWordWrap(false) end - if chatFrame.SetShadowOffset then chatFrame:SetShadowOffset(1, -1) end - if chatFrame.SetShadowColor then chatFrame:SetShadowColor(0, 0, 0, 0.92) end + if chatFrame.SetShadowOffset then chatFrame:SetShadowOffset(0, 0) end + if chatFrame.SetShadowColor then chatFrame:SetShadowColor(0, 0, 0, 0) end + StripChatFrameArtwork(chatFrame) + if not chatFrame.sfArtworkHooked and chatFrame.HookScript then + chatFrame.sfArtworkHooked = true + chatFrame:HookScript("OnShow", function() + StripChatFrameArtwork(chatFrame) + end) + end self:EnforceChatWindowLock(chatFrame) if not chatFrame.sfDragLockHooked and chatFrame.HookScript then @@ -6324,6 +6684,122 @@ function SFrames.Chat:StyleEditBox() end end +-- Apply hover-transparent alpha to background, shadow, and chrome elements. +-- alpha=0 means fully transparent (mouse away), alpha=1 means fully visible (mouse over). +function SFrames.Chat:ApplyHoverAlpha(alpha) + if not self.frame then return end + local f = self.frame + local cfg = self:GetConfig() + local bgA = Clamp(cfg.bgAlpha or DEFAULTS.bgAlpha, 0, 1) + local chromeVisible = alpha > 0.01 + + -- Main backdrop + f:SetBackdropColor(CFG_THEME.panelBg[1], CFG_THEME.panelBg[2], CFG_THEME.panelBg[3], bgA * alpha) + local showBorder = (cfg.showBorder ~= false) + local borderR, borderG, borderB = self:GetBorderColorRGB() + if showBorder then + f:SetBackdropBorderColor(borderR, borderG, borderB, 0.95 * alpha) + else + f:SetBackdropBorderColor(borderR, borderG, borderB, 0) + end + + -- Shadow + if f.chatShadow then + f.chatShadow:SetBackdropColor(0, 0, 0, 0.55 * alpha) + f.chatShadow:SetBackdropBorderColor(0, 0, 0, 0.4 * alpha) + end + + -- Inner shade + if f.innerShade then + f.innerShade:SetVertexColor(0, 0, 0, 0.2 * alpha) + if chromeVisible then f.innerShade:Show() else f.innerShade:Hide() end + end + + -- Watermark + if f.watermark then + f.watermark:SetVertexColor(1, 0.78, 0.9, 0.08 * alpha) + if chromeVisible then f.watermark:Show() else f.watermark:Hide() end + end + + -- Title, tab bar, config button, whisper button, scroll buttons, resize handle + local chromeAlpha = alpha + if f.title and f.title.SetAlpha then + f.title:SetAlpha(chromeAlpha) + if chromeVisible then f.title:Show() else f.title:Hide() end + elseif f.title and f.title.SetTextColor then + f.title:SetTextColor(1, 0.82, 0.93, chromeAlpha) + if chromeVisible then f.title:Show() else f.title:Hide() end + end + if f.titleBtn then + f.titleBtn:EnableMouse(chromeVisible) + if chromeVisible then f.titleBtn:Show() else f.titleBtn:Hide() end + end + if f.leftCat then + f.leftCat:SetVertexColor(1, 0.82, 0.9, 0.8 * chromeAlpha) + if chromeVisible then f.leftCat:Show() else f.leftCat:Hide() end + end + if f.tabBar then + f.tabBar:SetAlpha(chromeAlpha) + if chromeVisible then f.tabBar:Show() else f.tabBar:Hide() end + end + if f.configButton then + f.configButton:SetAlpha(chromeAlpha) + f.configButton:EnableMouse(chromeVisible) + if chromeVisible then f.configButton:Show() else f.configButton:Hide() end + end + if f.whisperButton then + f.whisperButton:SetAlpha(chromeAlpha) + f.whisperButton:EnableMouse(chromeVisible) + if chromeVisible then f.whisperButton:Show() else f.whisperButton:Hide() end + end + if f.scrollUpBtn then + f.scrollUpBtn:SetAlpha(chromeAlpha) + f.scrollUpBtn:EnableMouse(chromeVisible) + if chromeVisible then f.scrollUpBtn:Show() else f.scrollUpBtn:Hide() end + end + if f.scrollDownBtn then + f.scrollDownBtn:SetAlpha(chromeAlpha) + f.scrollDownBtn:EnableMouse(chromeVisible) + if chromeVisible then f.scrollDownBtn:Show() else f.scrollDownBtn:Hide() end + end + if f.scrollTrack then + f.scrollTrack:SetVertexColor(0.18, 0.19, 0.22, 0.9 * chromeAlpha) + if chromeVisible then f.scrollTrack:Show() else f.scrollTrack:Hide() end + end + if f.resizeHandle then + f.resizeHandle:SetAlpha(chromeAlpha) + f.resizeHandle:EnableMouse(chromeVisible) + if chromeVisible then f.resizeHandle:Show() else f.resizeHandle:Hide() end + end + if f.hint then + if chromeVisible then f.hint:Show() else f.hint:Hide() end + end + + -- Edit backdrop (only backdrop colors, not child alpha — preserve editbox text visibility) + if self.editBackdrop then + local editBox = ChatFrameEditBox or ChatFrame1EditBox + local editBackdropVisible = chromeVisible and editBox and editBox:IsShown() + if self.editBackdrop.SetBackdropColor then + self.editBackdrop:SetBackdropColor(CFG_THEME.panelBg[1], CFG_THEME.panelBg[2], CFG_THEME.panelBg[3], 0.96 * chromeAlpha) + end + if self.editBackdrop.SetBackdropBorderColor then + self.editBackdrop:SetBackdropBorderColor(CFG_THEME.panelBorder[1], CFG_THEME.panelBorder[2], CFG_THEME.panelBorder[3], 0.98 * chromeAlpha) + end + if self.editBackdrop.catIcon then + self.editBackdrop.catIcon:SetVertexColor(1, 0.84, 0.94, 0.9 * chromeAlpha) + end + if self.editBackdrop.topLine then + self.editBackdrop.topLine:SetVertexColor(1, 0.76, 0.9, 0.85 * chromeAlpha) + end + self.editBackdrop:EnableMouse(editBackdropVisible) + if editBackdropVisible then + self.editBackdrop:Show() + else + self.editBackdrop:Hide() + end + end +end + function SFrames.Chat:ApplyFrameBorderStyle() if not self.frame then return end @@ -6413,7 +6889,12 @@ function SFrames.Chat:ApplyConfig() end local bgA = Clamp(cfg.bgAlpha or DEFAULTS.bgAlpha, 0, 1) - self.frame:SetBackdropColor(CFG_THEME.panelBg[1], CFG_THEME.panelBg[2], CFG_THEME.panelBg[3], bgA) + local hAlpha = (self.frame.sfHoverAlpha ~= nil) and self.frame.sfHoverAlpha or 1 + if cfg.hoverTransparent then + self.frame:SetBackdropColor(CFG_THEME.panelBg[1], CFG_THEME.panelBg[2], CFG_THEME.panelBg[3], bgA * hAlpha) + else + self.frame:SetBackdropColor(CFG_THEME.panelBg[1], CFG_THEME.panelBg[2], CFG_THEME.panelBg[3], bgA) + end self:AttachChatFrame() self:HideDefaultChrome() @@ -6427,6 +6908,11 @@ function SFrames.Chat:ApplyConfig() self:StartStabilizer() self:SetUnlocked(SFrames and SFrames.isUnlocked) self:RefreshConfigFrame() + + -- Re-apply hover alpha so ApplyConfig doesn't leave chrome visible + if cfg.hoverTransparent and self.frame.sfHoverAlpha ~= nil then + self:ApplyHoverAlpha(self.frame.sfHoverAlpha) + end end function SFrames.Chat:Initialize() @@ -6524,6 +7010,61 @@ function SFrames.Chat:Initialize() end) end + -- HC死亡系统消息:AI开启时翻译并转发到公会 + if not SFrames.Chat._hcDeathGuildHooked then + SFrames.Chat._hcDeathGuildHooked = true + local hcDeathEvFrame = CreateFrame("Frame", "SFramesChatHCDeathGuildEvents", UIParent) + hcDeathEvFrame:RegisterEvent("CHAT_MSG_SYSTEM") + hcDeathEvFrame:SetScript("OnEvent", function() + if not (SFrames and SFrames.Chat) then return end + local db = EnsureDB() + -- AI翻译必须开启 + if db.translateEnabled == false then return end + -- 必须在公会中才能发送 + if not (IsInGuild and IsInGuild()) then return end + local messageText = arg1 + if type(messageText) ~= "string" or messageText == "" then return end + + local isDeath = ParseHardcoreDeathMessage(messageText) ~= nil + local isLevel = (not isDeath) and (ParseHardcoreLevelMessage(messageText) ~= nil) + if not isDeath and not isLevel then return end + + -- 仅通报工会成员:提取角色名并检查缓存 + if db.hcGuildMemberOnly then + local charName = ParseHCCharacterName(messageText) + if not charName or not IsHCGuildMember(charName) then return end + end + + -- 死亡通报:检查 hcDeathToGuild 及等级过滤 + if isDeath then + if db.hcDeathToGuild == false then return end + if db.hcDeathDisable then return end + local deathLvl = ParseHardcoreDeathMessage(messageText) + if db.hcDeathLevelMin and deathLvl and deathLvl > 0 and deathLvl < db.hcDeathLevelMin then return end + end + + -- 等级里程碑:检查 hcLevelToGuild + if isLevel then + if db.hcLevelToGuild == false then return end + end + + local cleanText = CleanTextForTranslation(messageText) + if cleanText == "" then return end + local function doSend(finalText) + pcall(function() SendChatMessage("[HC] " .. finalText, "GUILD") end) + end + if isDeath then + TranslateHCDeathParts(cleanText, doSend) + else + TranslateHCLevelParts(cleanText, doSend) + end + end) + end + + SFrames:RegisterEvent("GUILD_ROSTER_UPDATE", function() + RefreshHCGuildCache() + end) + SFrames:RegisterEvent("PLAYER_ENTERING_WORLD", function() if SFrames and SFrames.Chat then SFrames.Chat:ApplyConfig() @@ -6532,7 +7073,7 @@ function SFrames.Chat:Initialize() end LoadPersistentClassCache() if IsInGuild and IsInGuild() and GuildRoster then - GuildRoster() + GuildRoster() -- 触发 GUILD_ROSTER_UPDATE → RefreshHCGuildCache end SFrames:RefreshClassColorCache() @@ -6714,6 +7255,17 @@ function SFrames.Chat:Initialize() do local db = EnsureDB() + + -- HC系统消息(死亡/里程碑黄字)特殊处理: + -- AI翻译未开启时直接透传,不加 [+] 注释; + -- AI开启时正常记录,翻译转发由独立的 CHAT_MSG_SYSTEM 事件处理器完成。 + if ParseHardcoreDeathMessage(text) or ParseHardcoreLevelMessage(text) then + if db.translateEnabled == false then + origAddMessage(self, text, r, g, b, alpha, holdTime) + return + end + end + local chanName = GetChannelNameFromChatLine(text) if chanName and IsIgnoredChannelByDefault(chanName) then @@ -7137,4 +7689,3 @@ end end SFrames:RegisterEvent("PLAYER_REGEN_DISABLED", ChatCombatReanchor) SFrames:RegisterEvent("PLAYER_REGEN_ENABLED", ChatCombatReanchor) - diff --git a/ClassSkillData.lua b/ClassSkillData.lua deleted file mode 100644 index 75b8a92..0000000 --- a/ClassSkillData.lua +++ /dev/null @@ -1,347 +0,0 @@ -SFrames.ClassSkillData = { - WARRIOR = { - [4] = {"冲锋", "撕裂"}, - [6] = {"雷霆一击"}, - [8] = {"英勇打击 2级", "断筋"}, - [10] = {"撕裂 2级", "血性狂暴"}, - [12] = {"压制", "盾击", "战斗怒吼 2级"}, - [14] = {"挫志怒吼", "复仇"}, - [16] = {"英勇打击 3级", "惩戒痛击", "盾牌格挡"}, - [18] = {"雷霆一击 2级", "缴械"}, - [20] = {"撕裂 3级", "反击风暴", "顺劈斩"}, - [22] = {"战斗怒吼 3级", "破甲攻击 2级", "破胆怒吼"}, - [24] = {"英勇打击 4级", "挫志怒吼 2级", "复仇 2级", "斩杀"}, - [26] = {"冲锋 2级", "惩戒痛击 2级", "挑战怒吼"}, - [28] = {"雷霆一击 3级", "压制 2级", "盾墙"}, - [30] = {"撕裂 4级", "顺劈斩 2级", "猛击", "狂暴姿态"}, - [32] = {"英勇打击 5级", "断筋 2级", "斩杀 2级", "战斗怒吼 4级", "盾击 2级", "狂暴之怒"}, - [34] = {"挫志怒吼 3级", "复仇 3级", "破甲攻击 3级"}, - [36] = {"惩戒痛击 3级", "旋风斩"}, - [38] = {"雷霆一击 4级", "猛击 2级"}, - [40] = {"英勇打击 6级", "撕裂 5级", "顺劈斩 3级", "斩杀 3级"}, - [42] = {"战斗怒吼 5级", "拦截 2级"}, - [44] = {"压制 3级", "挫志怒吼 4级", "复仇 4级"}, - [46] = {"冲锋 3级", "惩戒痛击 4级", "猛击 3级", "破甲攻击 4级"}, - [48] = {"英勇打击 7级", "雷霆一击 5级", "斩杀 4级"}, - [50] = {"撕裂 6级", "鲁莽", "顺劈斩 4级"}, - [52] = {"战斗怒吼 6级", "拦截 3级", "盾击 3级"}, - [54] = {"断筋 3级", "挫志怒吼 5级", "猛击 4级", "复仇 5级"}, - [56] = {"英勇打击 8级", "惩戒痛击 5级", "斩杀 5级"}, - [58] = {"雷霆一击 6级", "破甲攻击 5级"}, - [60] = {"撕裂 7级", "压制 4级", "顺劈斩 5级"}, - }, - PALADIN = { - [4] = {"力量祝福", "审判"}, - [6] = {"圣光术 2级", "圣佑术", "十字军圣印"}, - [8] = {"纯净术", "制裁之锤"}, - [10] = {"圣疗术", "正义圣印 2级", "虔诚光环 2级", "保护祝福"}, - [12] = {"力量祝福 2级", "十字军圣印 2级"}, - [14] = {"圣光术 3级"}, - [16] = {"正义之怒", "惩罚光环"}, - [18] = {"正义圣印 3级", "圣佑术 2级"}, - [20] = {"驱邪术", "圣光闪现", "虔诚光环 3级"}, - [22] = {"圣光术 4级", "专注光环", "公正圣印", "力量祝福 3级", "十字军圣印 3级"}, - [24] = {"超度亡灵", "救赎 2级", "智慧祝福 2级", "制裁之锤 2级", "保护祝福 2级"}, - [26] = {"圣光闪现 2级", "正义圣印 4级", "拯救祝福", "惩罚光环 2级"}, - [28] = {"驱邪术 2级"}, - [30] = {"圣疗术 2级", "圣光术 5级", "光明圣印", "虔诚光环 4级", "神圣干涉"}, - [32] = {"冰霜抗性光环", "力量祝福 4级", "十字军圣印 4级"}, - [34] = {"智慧祝福 3级", "圣光闪现 3级", "正义圣印 5级", "圣盾术"}, - [36] = {"驱邪术 3级", "救赎 3级", "火焰抗性光环", "惩罚光环 3级"}, - [38] = {"圣光术 6级", "超度亡灵 2级", "智慧圣印", "保护祝福 3级"}, - [40] = {"光明祝福", "光明圣印 2级", "虔诚光环 5级", "制裁之锤 3级"}, - [42] = {"圣光闪现 4级", "正义圣印 6级", "力量祝福 5级", "十字军圣印 5级"}, - [44] = {"驱邪术 4级", "智慧祝福 4级", "冰霜抗性光环 2级"}, - [46] = {"圣光术 7级", "惩罚光环 4级"}, - [48] = {"救赎 4级", "智慧圣印 2级", "火焰抗性光环 2级"}, - [50] = {"圣疗术 3级", "圣光闪现 5级", "光明祝福 2级", "光明圣印 3级", "正义圣印 7级", "虔诚光环 6级", "圣盾术 2级"}, - [52] = {"驱邪术 5级", "超度亡灵 3级", "力量祝福 6级", "十字军圣印 6级", "强效力量祝福"}, - [54] = {"圣光术 8级", "智慧祝福 5级", "强效智慧祝福", "制裁之锤 4级"}, - [56] = {"冰霜抗性光环 3级", "惩罚光环 5级"}, - [58] = {"圣光闪现 6级", "智慧圣印 3级", "正义圣印 8级"}, - [60] = {"驱邪术 6级", "救赎 5级", "光明祝福 3级", "光明圣印 4级", "强效光明祝福", "虔诚光环 7级", "火焰抗性光环 3级", "强效力量祝福 2级"}, - }, - HUNTER = { - [4] = {"灵猴守护", "毒蛇钉刺"}, - [6] = {"猎人印记", "奥术射击"}, - [8] = {"震荡射击", "猛禽一击 2级"}, - [10] = {"雄鹰守护", "毒蛇钉刺 2级", "持久耐力", "自然护甲", "追踪人型生物"}, - [12] = {"治疗宠物", "奥术射击 2级", "扰乱射击", "摔绊"}, - [14] = {"野兽之眼", "恐吓野兽", "鹰眼术"}, - [16] = {"猛禽一击 3级", "献祭陷阱", "猫鼬撕咬"}, - [18] = {"雄鹰守护 2级", "毒蛇钉刺 3级", "追踪亡灵", "多重射击"}, - [20] = {"治疗宠物 2级", "猎豹守护", "奥术射击 3级", "逃脱", "冰冻陷阱", "猛禽一击 4级"}, - [22] = {"猎人印记 2级", "毒蝎钉刺"}, - [24] = {"野兽知识", "追踪隐藏生物"}, - [26] = {"毒蛇钉刺 4级", "急速射击", "追踪元素生物", "献祭陷阱 2级"}, - [28] = {"治疗宠物 3级", "雄鹰守护 3级", "奥术射击 4级", "冰霜陷阱"}, - [30] = {"恐吓野兽 2级", "野兽守护", "多重射击 2级", "猫鼬撕咬 2级", "假死"}, - [32] = {"照明弹", "爆炸陷阱", "追踪恶魔", "猛禽一击 5级"}, - [34] = {"毒蛇钉刺 5级", "逃脱 2级"}, - [36] = {"治疗宠物 4级", "蝰蛇钉刺", "献祭陷阱 3级"}, - [38] = {"雄鹰守护 4级"}, - [40] = {"豹群守护", "猎人印记 3级", "乱射", "扰乱射击 4级", "冰冻陷阱 2级", "猛禽一击 6级", "追踪巨人"}, - [42] = {"毒蛇钉刺 6级", "多重射击 3级"}, - [44] = {"治疗宠物 5级", "奥术射击 6级", "爆炸陷阱 2级", "献祭陷阱 4级", "猫鼬撕咬 3级"}, - [46] = {"恐吓野兽 3级", "蝰蛇钉刺 2级"}, - [48] = {"雄鹰守护 5级", "猛禽一击 7级", "逃脱 3级"}, - [50] = {"毒蛇钉刺 7级", "乱射 2级", "追踪龙类"}, - [52] = {"治疗宠物 6级", "毒蝎钉刺 4级"}, - [54] = {"多重射击 4级", "爆炸陷阱 3级", "猫鼬撕咬 4级", "猛禽一击 8级"}, - [56] = {"蝰蛇钉刺 3级", "献祭陷阱 5级"}, - [58] = {"猎人印记 4级", "乱射 3级", "毒蛇钉刺 8级", "雄鹰守护 6级"}, - [60] = {"治疗宠物 7级", "奥术射击 8级", "扰乱射击 6级", "冰冻陷阱 3级", "摔绊 3级"}, - }, - ROGUE = { - [2] = {"潜行"}, - [4] = {"背刺", "搜索"}, - [6] = {"邪恶攻击 2级", "凿击"}, - [8] = {"刺骨 2级", "闪避"}, - [10] = {"切割", "疾跑", "闷棍"}, - [12] = {"背刺 2级", "脚踢"}, - [14] = {"绞喉", "破甲", "邪恶攻击 3级"}, - [16] = {"刺骨 3级", "佯攻"}, - [18] = {"凿击 2级", "伏击"}, - [20] = {"割裂", "背刺 3级", "潜行 2级", "致残毒药"}, - [22] = {"绞喉 2级", "邪恶攻击 4级", "扰乱", "消失"}, - [24] = {"刺骨 4级", "麻痹毒药", "侦测陷阱"}, - [26] = {"偷袭", "破甲 2级", "伏击 2级", "脚踢 2级"}, - [28] = {"割裂 2级", "背刺 4级", "佯攻 2级", "闷棍 2级"}, - [30] = {"绞喉 3级", "邪恶攻击 5级", "肾击", "致命毒药"}, - [32] = {"凿击 3级", "致伤毒药"}, - [34] = {"疾跑 2级"}, - [36] = {"割裂 3级", "破甲 3级"}, - [38] = {"绞喉 4级", "致命毒药 2级", "麻痹毒药 2级"}, - [40] = {"邪恶攻击 6级", "佯攻 3级", "潜行 3级", "安全降落", "致伤毒药 2级", "消失 2级"}, - [42] = {"切割 2级"}, - [44] = {"割裂 4级", "背刺 6级"}, - [46] = {"绞喉 5级", "破甲 4级", "致命毒药 3级"}, - [48] = {"刺骨 7级", "凿击 4级", "闷棍 3级", "致伤毒药 3级"}, - [50] = {"肾击 2级", "邪恶攻击 7级", "伏击 5级", "致残毒药 2级"}, - [52] = {"割裂 5级", "背刺 7级", "麻痹毒药 3级"}, - [54] = {"绞喉 6级", "邪恶攻击 8级", "致命毒药 4级"}, - [56] = {"刺骨 8级", "破甲 5级", "致伤毒药 4级"}, - [58] = {"脚踢 4级", "疾跑 3级"}, - [60] = {"割裂 6级", "凿击 5级", "佯攻 4级", "背刺 8级", "潜行 4级"}, - }, - PRIEST = { - [4] = {"暗言术:痛", "次级治疗术 2级"}, - [6] = {"真言术:盾", "惩击 2级"}, - [8] = {"恢复", "渐隐术"}, - [10] = {"暗言术:痛 2级", "心灵震爆", "复活术"}, - [12] = {"真言术:盾 2级", "心灵之火", "真言术:韧 2级", "祛病术"}, - [14] = {"恢复 2级", "心灵尖啸"}, - [16] = {"治疗术", "心灵震爆 2级"}, - [18] = {"真言术:盾 3级", "驱散魔法", "暗言术:痛 3级"}, - [20] = {"心灵之火 2级", "束缚亡灵", "快速治疗", "安抚心灵", "渐隐术 2级", "神圣之火"}, - [22] = {"惩击 4级", "心灵视界", "复活术 2级", "心灵震爆 3级"}, - [24] = {"真言术:盾 4级", "真言术:韧 3级", "法力燃烧", "神圣之火 2级"}, - [26] = {"恢复 4级", "暗言术:痛 4级"}, - [28] = {"治疗术 3级", "心灵震爆 4级", "心灵尖啸 2级"}, - [30] = {"真言术:盾 5级", "心灵之火 3级", "治疗祷言", "束缚亡灵 2级", "精神控制", "防护暗影", "渐隐术 3级"}, - [32] = {"法力燃烧 2级", "恢复 5级", "快速治疗 3级"}, - [34] = {"漂浮术", "暗言术:痛 5级", "心灵震爆 5级", "复活术 3级", "治疗术 4级"}, - [36] = {"真言术:盾 6级", "驱散魔法 2级", "真言术:韧 4级", "心灵之火 4级", "恢复 6级", "惩击 6级"}, - [38] = {"安抚心灵 2级"}, - [40] = {"法力燃烧 3级", "治疗祷言 2级", "防护暗影 2级", "心灵震爆 6级", "渐隐术 4级"}, - [42] = {"真言术:盾 7级", "神圣之火 5级", "心灵尖啸 3级"}, - [44] = {"恢复 7级", "精神控制 2级"}, - [46] = {"惩击 7级", "强效治疗术 2级", "心灵震爆 7级", "复活术 4级"}, - [48] = {"真言术:盾 8级", "真言术:韧 5级", "法力燃烧 4级", "神圣之火 6级", "恢复 8级", "暗言术:痛 7级"}, - [50] = {"心灵之火 5级", "治疗祷言 3级"}, - [52] = {"强效治疗术 3级", "心灵震爆 8级", "安抚心灵 3级"}, - [54] = {"真言术:盾 9级", "神圣之火 7级", "惩击 8级"}, - [56] = {"法力燃烧 5级", "恢复 9级", "防护暗影 3级", "心灵尖啸 4级", "暗言术:痛 8级"}, - [58] = {"复活术 5级", "强效治疗术 4级", "心灵震爆 9级"}, - [60] = {"真言术:盾 10级", "心灵之火 6级", "真言术:韧 6级", "束缚亡灵 3级", "治疗祷言 4级", "渐隐术 6级"}, - }, - SHAMAN = { - [4] = {"地震术"}, - [6] = {"治疗波 2级", "地缚图腾"}, - [8] = {"闪电箭 2级", "石爪图腾", "地震术 2级", "闪电之盾"}, - [10] = {"烈焰震击", "火舌武器", "大地之力图腾"}, - [12] = {"净化术", "火焰新星图腾", "先祖之魂", "治疗波 3级"}, - [14] = {"闪电箭 3级", "地震术 3级"}, - [16] = {"闪电之盾 2级", "消毒术"}, - [18] = {"烈焰震击 2级", "火舌武器 2级", "石爪图腾 2级", "治疗波 4级", "战栗图腾"}, - [20] = {"闪电箭 4级", "冰霜震击", "幽魂之狼", "次级治疗波"}, - [22] = {"火焰新星图腾 2级", "水下呼吸", "祛病术"}, - [24] = {"净化术 2级", "地震术 4级", "大地之力图腾 2级", "闪电之盾 3级", "先祖之魂 2级"}, - [26] = {"闪电箭 5级", "熔岩图腾", "火舌武器 3级", "视界术", "法力之泉图腾"}, - [28] = {"石爪图腾 3级", "烈焰震击 3级", "火舌图腾", "水上行走", "次级治疗波 2级"}, - [30] = {"星界传送", "根基图腾", "风怒武器", "治疗之泉图腾"}, - [32] = {"闪电箭 6级", "火焰新星图腾 3级", "闪电之盾 4级", "治疗波 6级", "闪电链", "风怒图腾"}, - [34] = {"冰霜震击 2级", "岗哨图腾"}, - [36] = {"地震术 5级", "熔岩图腾 2级", "火舌武器 4级", "法力之泉图腾 2级", "次级治疗波 3级", "风墙图腾"}, - [38] = {"石爪图腾 4级", "大地之力图腾 3级", "火舌图腾 2级"}, - [40] = {"闪电箭 8级", "闪电链 2级", "烈焰震击 4级", "治疗波 7级", "治疗链", "治疗之泉图腾 3级", "风怒武器 2级"}, - [42] = {"火焰新星图腾 4级"}, - [44] = {"闪电之盾 6级", "冰霜震击 3级", "熔岩图腾 3级", "风墙图腾 2级"}, - [46] = {"火舌武器 5级", "治疗链 2级"}, - [48] = {"地震术 6级", "石爪图腾 5级", "火舌图腾 3级", "治疗波 8级"}, - [50] = {"闪电箭 9级", "治疗之泉图腾 4级", "风怒武器 3级"}, - [52] = {"烈焰震击 5级", "大地之力图腾 4级", "次级治疗波 5级"}, - [54] = {"闪电箭 10级"}, - [56] = {"闪电链 4级", "熔岩图腾 4级", "火舌图腾 4级", "风墙图腾 3级", "治疗波 9级", "法力之泉图腾 4级"}, - [58] = {"冰霜震击 4级"}, - [60] = {"风怒武器 4级", "次级治疗波 6级", "治疗之泉图腾 5级"}, - }, - MAGE = { - [4] = {"造水术", "寒冰箭"}, - [6] = {"造食术", "火球术 2级", "火焰冲击"}, - [8] = {"变形术", "奥术飞弹"}, - [10] = {"霜甲术 2级", "冰霜新星"}, - [12] = {"缓落术", "造食术 2级", "火球术 3级"}, - [14] = {"魔爆术", "奥术智慧 2级", "火焰冲击 2级"}, - [16] = {"侦测魔法", "烈焰风暴"}, - [18] = {"解除次级诅咒", "魔法增效", "火球术 4级"}, - [20] = {"变形术 2级", "法力护盾", "闪现术", "霜甲术 3级", "暴风雪", "唤醒"}, - [22] = {"造食术 3级", "魔爆术 2级", "火焰冲击 3级", "灼烧"}, - [24] = {"火球术 5级", "烈焰风暴 2级", "法术反制"}, - [26] = {"寒冰箭 5级", "冰锥术"}, - [28] = {"奥术智慧 3级", "法力护盾 2级", "暴风雪 2级", "灼烧 2级", "冰霜新星 2级"}, - [30] = {"魔爆术 3级", "火球术 6级", "冰甲术"}, - [32] = {"造食术 4级", "烈焰风暴 3级", "寒冰箭 6级"}, - [34] = {"魔甲术", "冰锥术 2级", "灼烧 3级"}, - [36] = {"法力护盾 3级", "火球术 7级", "暴风雪 3级", "冰霜新星 3级"}, - [38] = {"魔爆术 4级", "寒冰箭 7级", "火焰冲击 5级"}, - [40] = {"造食术 5级", "奥术飞弹 5级", "火球术 8级", "冰甲术 2级", "灼烧 4级"}, - [42] = {"奥术智慧 4级"}, - [44] = {"法力护盾 4级", "暴风雪 4级", "寒冰箭 8级"}, - [46] = {"魔爆术 5级", "灼烧 5级"}, - [48] = {"火球术 9级", "奥术飞弹 6级", "烈焰风暴 5级"}, - [50] = {"造水术 6级", "寒冰箭 9级", "冰锥术 4级", "冰甲术 3级"}, - [52] = {"法力护盾 5级", "火球术 10级", "火焰冲击 7级", "冰霜新星 4级"}, - [54] = {"魔法增效 4级", "奥术飞弹 7级", "烈焰风暴 6级"}, - [56] = {"奥术智慧 5级", "寒冰箭 10级", "冰锥术 5级"}, - [58] = {"魔甲术 3级", "灼烧 7级"}, - [60] = {"变形术 4级", "法力护盾 6级", "火球术 11级", "暴风雪 6级", "冰甲术 4级"}, - }, - WARLOCK = { - [2] = {"痛苦诅咒", "恐惧术"}, - [4] = {"腐蚀术", "虚弱诅咒"}, - [6] = {"暗影箭 3级"}, - [8] = {"痛苦诅咒 2级"}, - [10] = {"吸取灵魂", "献祭 2级", "恶魔皮肤 2级", "制造初级治疗石"}, - [12] = {"生命分流", "生命通道", "魔息术"}, - [14] = {"腐蚀术 2级", "吸取生命", "鲁莽诅咒"}, - [16] = {"生命分流 2级"}, - [18] = {"痛苦诅咒 3级", "灼热之痛"}, - [20] = {"献祭 3级", "生命通道 2级", "暗影箭 4级", "魔甲术", "火焰之雨"}, - [22] = {"吸取生命 2级", "虚弱诅咒 3级", "基尔罗格之眼"}, - [24] = {"腐蚀术 3级", "吸取灵魂 2级", "吸取法力", "感知恶魔"}, - [26] = {"生命分流 3级", "语言诅咒"}, - [28] = {"鲁莽诅咒 2级", "痛苦诅咒 4级", "生命通道 3级", "放逐术"}, - [30] = {"吸取生命 3级", "献祭 4级", "奴役恶魔", "地狱烈焰", "魔甲术 2级"}, - [32] = {"虚弱诅咒 4级", "恐惧术 2级", "元素诅咒", "防护暗影结界"}, - [34] = {"生命分流 4级", "吸取法力 2级", "火焰之雨 2级", "灼热之痛 3级"}, - [36] = {"生命通道 4级"}, - [38] = {"吸取灵魂 3级", "痛苦诅咒 5级"}, - [40] = {"恐惧嚎叫", "献祭 5级", "奴役恶魔 2级"}, - [42] = {"虚弱诅咒 5级", "鲁莽诅咒 3级", "死亡缠绕", "防护暗影结界 2级", "地狱烈焰 2级", "灼热之痛 4级"}, - [44] = {"吸取生命 5级", "生命通道 5级", "暗影箭 7级"}, - [46] = {"生命分流 5级", "火焰之雨 3级"}, - [48] = {"痛苦诅咒 6级", "放逐术 2级", "灵魂之火"}, - [50] = {"虚弱诅咒 6级", "死亡缠绕 2级", "恐惧嚎叫 2级", "魔甲术 4级", "吸取灵魂 4级", "吸取法力 4级", "暗影箭 8级", "灼热之痛 5级"}, - [52] = {"防护暗影结界 3级", "生命通道 6级"}, - [54] = {"腐蚀术 6级", "吸取生命 6级", "地狱烈焰 3级", "灵魂之火 2级"}, - [56] = {"鲁莽诅咒 4级", "死亡缠绕 3级"}, - [58] = {"痛苦诅咒 7级", "奴役恶魔 3级", "火焰之雨 4级", "灼热之痛 6级"}, - [60] = {"厄运诅咒", "元素诅咒 3级", "魔甲术 5级", "暗影箭 9级"}, - }, - DRUID = { - [4] = {"月火术", "回春术"}, - [6] = {"荆棘术", "愤怒 2级"}, - [8] = {"纠缠根须", "治疗之触 2级"}, - [10] = {"月火术 2级", "回春术 2级", "挫志咆哮", "野性印记 2级"}, - [12] = {"愈合", "狂怒"}, - [14] = {"荆棘术 2级", "愤怒 3级", "重击"}, - [16] = {"月火术 3级", "回春术 3级", "挥击"}, - [18] = {"精灵之火", "休眠", "愈合 2级"}, - [20] = {"纠缠根须 2级", "星火术", "猎豹形态", "撕扯", "爪击", "治疗之触 4级", "潜行", "野性印记 3级", "复生"}, - [22] = {"愤怒 4级", "撕碎", "安抚动物"}, - [24] = {"荆棘术 3级", "挥击 2级", "扫击", "猛虎之怒", "解除诅咒"}, - [26] = {"星火术 2级", "月火术 5级", "爪击 2级", "治疗之触 5级", "驱毒术"}, - [28] = {"撕扯 2级", "挑战咆哮", "畏缩"}, - [30] = {"精灵之火 2级", "星火术 3级", "愤怒 5级", "旅行形态", "撕碎 2级", "重击 2级", "野性印记 4级", "宁静", "复生 2级"}, - [32] = {"挫志咆哮 3级", "挥击 3级", "毁灭", "治疗之触 6级", "凶猛撕咬"}, - [34] = {"荆棘术 4级", "月火术 6级", "回春术 6级", "扫击 2级", "爪击 3级"}, - [36] = {"愤怒 6级", "突袭", "狂暴回复"}, - [38] = {"纠缠根须 4级", "休眠 2级", "安抚动物 2级", "撕碎 3级"}, - [40] = {"星火术 4级", "飓风", "挥击 4级", "潜行 2级", "畏缩 2级", "巨熊形态", "凶猛撕咬 2级", "回春术 7级", "宁静 2级", "复生 3级", "激活"}, - [42] = {"挫志咆哮 4级", "毁灭 2级"}, - [44] = {"荆棘术 5级", "树皮术", "撕扯 4级", "扫击 3级", "治疗之触 8级"}, - [46] = {"愤怒 7级", "重击 3级", "突袭 2级"}, - [48] = {"纠缠根须 5级", "月火术 8级", "撕碎 4级"}, - [50] = {"星火术 5级", "宁静 3级", "复生 4级"}, - [52] = {"挫志咆哮 5级", "撕扯 5级", "畏缩 3级", "凶猛撕咬 4级", "回春术 9级"}, - [54] = {"荆棘术 6级", "愤怒 8级", "月火术 9级", "挥击 5级", "扫击 4级", "爪击 4级"}, - [56] = {"治疗之触 10级"}, - [58] = {"纠缠根须 6级", "星火术 6级", "月火术 10级", "爪击 5级", "毁灭 4级", "回春术 10级"}, - [60] = {"飓风 3级", "潜行 3级", "猛虎之怒 4级", "撕扯 6级", "宁静 4级", "复生 5级", "野性印记 7级", "愈合 9级"}, - }, -} - -SFrames.TalentTrainerSkills = { - WARRIOR = { - [48] = {{"致死打击 2级", "致死打击"}, {"嗜血 2级", "嗜血"}, {"盾牌猛击 2级", "盾牌猛击"}}, - [54] = {{"致死打击 3级", "致死打击"}, {"嗜血 3级", "嗜血"}, {"盾牌猛击 3级", "盾牌猛击"}}, - [60] = {{"致死打击 4级", "致死打击"}, {"嗜血 4级", "嗜血"}, {"盾牌猛击 4级", "盾牌猛击"}}, - }, - PALADIN = { - [48] = {{"神圣震击 2级", "神圣震击"}}, - [56] = {{"神圣震击 3级", "神圣震击"}}, - }, - HUNTER = { - [28] = {{"瞄准射击 2级", "瞄准射击"}}, - [36] = {{"瞄准射击 3级", "瞄准射击"}}, - [44] = {{"瞄准射击 4级", "瞄准射击"}}, - [52] = {{"瞄准射击 5级", "瞄准射击"}}, - [60] = {{"瞄准射击 6级", "瞄准射击"}}, - }, - ROGUE = { - [46] = {{"出血 2级", "出血"}}, - [58] = {{"出血 3级", "出血"}}, - }, - PRIEST = { - [28] = {{"精神鞭笞 2级", "精神鞭笞"}}, - [36] = {{"精神鞭笞 3级", "精神鞭笞"}}, - [44] = {{"精神鞭笞 4级", "精神鞭笞"}}, - [52] = {{"精神鞭笞 5级", "精神鞭笞"}}, - [60] = {{"精神鞭笞 6级", "精神鞭笞"}}, - }, - MAGE = { - [24] = {{"炎爆术 2级", "炎爆术"}}, - [30] = {{"炎爆术 3级", "炎爆术"}}, - [36] = {{"炎爆术 4级", "炎爆术"}}, - [42] = {{"炎爆术 5级", "炎爆术"}}, - [48] = {{"炎爆术 6级", "炎爆术"}, {"冲击波 2级", "冲击波"}, {"寒冰屏障 2级", "寒冰屏障"}}, - [54] = {{"炎爆术 7级", "炎爆术"}}, - [56] = {{"冲击波 3级", "冲击波"}, {"寒冰屏障 3级", "寒冰屏障"}}, - [60] = {{"炎爆术 8级", "炎爆术"}, {"冲击波 4级", "冲击波"}, {"寒冰屏障 4级", "寒冰屏障"}}, - }, - WARLOCK = { - [38] = {{"生命虹吸 2级", "生命虹吸"}}, - [48] = {{"生命虹吸 3级", "生命虹吸"}}, - [50] = {{"黑暗契约 2级", "黑暗契约"}}, - [58] = {{"生命虹吸 4级", "生命虹吸"}}, - [60] = {{"黑暗契约 3级", "黑暗契约"}}, - }, - DRUID = { - [30] = {{"虫群 2级", "虫群"}}, - [40] = {{"虫群 3级", "虫群"}}, - [50] = {{"虫群 4级", "虫群"}}, - [60] = {{"虫群 5级", "虫群"}}, - }, -} - -SFrames.ClassMountQuests = { - WARLOCK = { - [40] = "职业坐骑任务:召唤恶马", - [60] = "史诗坐骑任务:召唤恐惧战马", - }, - PALADIN = { - [40] = "职业坐骑任务:召唤战马", - [60] = "史诗坐骑任务:召唤战驹", - }, -} diff --git a/ConfigUI.lua b/ConfigUI.lua index 0d65e32..52727d4 100644 --- a/ConfigUI.lua +++ b/ConfigUI.lua @@ -35,6 +35,14 @@ local function Clamp(value, minValue, maxValue) return value end +local function EnsureNumberDefault(tbl, key, value) + if type(tbl[key]) ~= "number" then tbl[key] = value end +end + +local function EnsureStringDefault(tbl, key, value) + if type(tbl[key]) ~= "string" or tbl[key] == "" then tbl[key] = value end +end + local SOFT_THEME = SFrames.ActiveTheme local function EnsureSoftBackdrop(frame) @@ -331,17 +339,25 @@ local function StyleSlider(slider, low, high, text) end) UpdateFill() + local font = (SFrames.GetFont and SFrames:GetFont()) or "Fonts\\ARIALN.TTF" if low then + low:SetFont(font, 8, "OUTLINE") low:SetTextColor(SOFT_THEME.dimText[1], SOFT_THEME.dimText[2], SOFT_THEME.dimText[3]) low:ClearAllPoints() - low:SetPoint("TOPLEFT", slider, "BOTTOMLEFT", 0, 0) + low:SetWidth(40) + low:SetJustifyH("LEFT") + low:SetPoint("TOPLEFT", slider, "BOTTOMLEFT", 10, 0) end if high then + high:SetFont(font, 8, "OUTLINE") high:SetTextColor(SOFT_THEME.dimText[1], SOFT_THEME.dimText[2], SOFT_THEME.dimText[3]) high:ClearAllPoints() - high:SetPoint("TOPRIGHT", slider, "BOTTOMRIGHT", 0, 0) + high:SetWidth(40) + high:SetJustifyH("RIGHT") + high:SetPoint("TOPRIGHT", slider, "BOTTOMRIGHT", -10, 0) end if text then + text:SetFont(font, 9, "OUTLINE") text:SetTextColor(SOFT_THEME.text[1], SOFT_THEME.text[2], SOFT_THEME.text[3]) text:ClearAllPoints() text:SetPoint("BOTTOM", slider, "TOP", 0, 2) @@ -418,6 +434,25 @@ local function EnsureDB() 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 + local defaultPlayerPowerWidth = math.max(60, SFramesDB.playerFrameWidth - ((SFramesDB.playerShowPortrait ~= false) and SFramesDB.playerPortraitWidth or 0) - 2) + if type(SFramesDB.playerPowerWidth) ~= "number" + or math.abs(SFramesDB.playerPowerWidth - SFramesDB.playerFrameWidth) < 0.5 + or math.abs(SFramesDB.playerPowerWidth - 168) < 0.5 then + SFramesDB.playerPowerWidth = defaultPlayerPowerWidth + end + if type(SFramesDB.playerPowerOffsetX) ~= "number" then SFramesDB.playerPowerOffsetX = 0 end + if type(SFramesDB.playerPowerOffsetY) ~= "number" then SFramesDB.playerPowerOffsetY = 0 end + if SFramesDB.playerPowerOnTop == nil then SFramesDB.playerPowerOnTop = false end + if SFramesDB.playerShowBorder == nil then SFramesDB.playerShowBorder = false end + if type(SFramesDB.playerCornerRadius) ~= "number" then SFramesDB.playerCornerRadius = 0 end + if type(SFramesDB.playerPortraitBgAlpha) ~= "number" then SFramesDB.playerPortraitBgAlpha = SFramesDB.playerBgAlpha end + if SFramesDB.playerHealthTexture == nil then SFramesDB.playerHealthTexture = "" end + if SFramesDB.playerPowerTexture == nil then SFramesDB.playerPowerTexture = "" end + if SFramesDB.playerNameFontKey == nil then SFramesDB.playerNameFontKey = "" end + if SFramesDB.playerHealthFontKey == nil then SFramesDB.playerHealthFontKey = "" end + if SFramesDB.playerPowerFontKey == nil then SFramesDB.playerPowerFontKey = "" end + if type(SFramesDB.playerHealthFontSize) ~= "number" then SFramesDB.playerHealthFontSize = SFramesDB.playerValueFontSize end + if type(SFramesDB.playerPowerFontSize) ~= "number" then SFramesDB.playerPowerFontSize = SFramesDB.playerValueFontSize end if type(SFramesDB.targetFrameScale) ~= "number" then SFramesDB.targetFrameScale = 1 end if type(SFramesDB.targetFrameWidth) ~= "number" then SFramesDB.targetFrameWidth = 220 end @@ -431,9 +466,28 @@ local function EnsureDB() 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 + local defaultTargetPowerWidth = math.max(60, SFramesDB.targetFrameWidth - ((SFramesDB.targetShowPortrait ~= false) and SFramesDB.targetPortraitWidth or 0) - 2) + if type(SFramesDB.targetPowerWidth) ~= "number" + or math.abs(SFramesDB.targetPowerWidth - SFramesDB.targetFrameWidth) < 0.5 then + SFramesDB.targetPowerWidth = defaultTargetPowerWidth + end + if type(SFramesDB.targetPowerOffsetX) ~= "number" then SFramesDB.targetPowerOffsetX = 0 end + if type(SFramesDB.targetPowerOffsetY) ~= "number" then SFramesDB.targetPowerOffsetY = 0 end + if SFramesDB.targetPowerOnTop == nil then SFramesDB.targetPowerOnTop = false end + if SFramesDB.targetShowBorder == nil then SFramesDB.targetShowBorder = false end + if type(SFramesDB.targetCornerRadius) ~= "number" then SFramesDB.targetCornerRadius = 0 end + if type(SFramesDB.targetPortraitBgAlpha) ~= "number" then SFramesDB.targetPortraitBgAlpha = SFramesDB.targetBgAlpha end + if SFramesDB.targetHealthTexture == nil then SFramesDB.targetHealthTexture = "" end + if SFramesDB.targetPowerTexture == nil then SFramesDB.targetPowerTexture = "" end + if SFramesDB.targetNameFontKey == nil then SFramesDB.targetNameFontKey = "" end + if SFramesDB.targetHealthFontKey == nil then SFramesDB.targetHealthFontKey = "" end + if SFramesDB.targetPowerFontKey == nil then SFramesDB.targetPowerFontKey = "" end + if type(SFramesDB.targetHealthFontSize) ~= "number" then SFramesDB.targetHealthFontSize = SFramesDB.targetValueFontSize end + if type(SFramesDB.targetPowerFontSize) ~= "number" then SFramesDB.targetPowerFontSize = SFramesDB.targetValueFontSize 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 SFramesDB.targetDistanceFontKey == nil then SFramesDB.targetDistanceFontKey = "" end if type(SFramesDB.fontKey) ~= "string" then SFramesDB.fontKey = "default" end -- Focus frame defaults @@ -449,6 +503,28 @@ local function EnsureDB() 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 + local defaultFocusPowerWidth = math.max(60, SFramesDB.focusFrameWidth - ((SFramesDB.focusShowPortrait ~= false) and SFramesDB.focusPortraitWidth or 0) - 2) + if type(SFramesDB.focusPowerWidth) ~= "number" + or math.abs(SFramesDB.focusPowerWidth - SFramesDB.focusFrameWidth) < 0.5 then + SFramesDB.focusPowerWidth = defaultFocusPowerWidth + end + if type(SFramesDB.focusPowerOffsetX) ~= "number" then SFramesDB.focusPowerOffsetX = 0 end + if type(SFramesDB.focusPowerOffsetY) ~= "number" then SFramesDB.focusPowerOffsetY = 0 end + if SFramesDB.focusPowerOnTop == nil then SFramesDB.focusPowerOnTop = false end + if SFramesDB.focusShowBorder == nil then SFramesDB.focusShowBorder = false end + if type(SFramesDB.focusCornerRadius) ~= "number" then SFramesDB.focusCornerRadius = 0 end + if type(SFramesDB.focusPortraitBgAlpha) ~= "number" then SFramesDB.focusPortraitBgAlpha = SFramesDB.focusBgAlpha end + if SFramesDB.focusHealthTexture == nil then SFramesDB.focusHealthTexture = "" end + if SFramesDB.focusPowerTexture == nil then SFramesDB.focusPowerTexture = "" end + if SFramesDB.focusNameFontKey == nil then SFramesDB.focusNameFontKey = "" end + if SFramesDB.focusHealthFontKey == nil then SFramesDB.focusHealthFontKey = "" end + if SFramesDB.focusPowerFontKey == nil then SFramesDB.focusPowerFontKey = "" end + if SFramesDB.focusCastFontKey == nil then SFramesDB.focusCastFontKey = "" end + if SFramesDB.focusDistanceFontKey == nil then SFramesDB.focusDistanceFontKey = "" end + if type(SFramesDB.focusHealthFontSize) ~= "number" then SFramesDB.focusHealthFontSize = SFramesDB.focusValueFontSize end + if type(SFramesDB.focusPowerFontSize) ~= "number" then SFramesDB.focusPowerFontSize = SFramesDB.focusValueFontSize end + if type(SFramesDB.focusCastFontSize) ~= "number" then SFramesDB.focusCastFontSize = 10 end + if type(SFramesDB.focusDistanceFontSize) ~= "number" then SFramesDB.focusDistanceFontSize = 14 end if SFramesDB.showPetFrame == nil then SFramesDB.showPetFrame = true end if type(SFramesDB.petFrameScale) ~= "number" then SFramesDB.petFrameScale = 1 end @@ -466,10 +542,33 @@ local function EnsureDB() if type(SFramesDB.partyVerticalGap) ~= "number" then SFramesDB.partyVerticalGap = 30 end if type(SFramesDB.partyNameFontSize) ~= "number" then SFramesDB.partyNameFontSize = 10 end if type(SFramesDB.partyValueFontSize) ~= "number" then SFramesDB.partyValueFontSize = 10 end + local defaultPartyPowerWidth = math.max(40, SFramesDB.partyFrameWidth - SFramesDB.partyPortraitWidth - 5) + if type(SFramesDB.partyPowerWidth) ~= "number" + or math.abs(SFramesDB.partyPowerWidth - SFramesDB.partyFrameWidth) < 0.5 then + SFramesDB.partyPowerWidth = defaultPartyPowerWidth + end + if type(SFramesDB.partyPowerOffsetX) ~= "number" then SFramesDB.partyPowerOffsetX = 0 end + if type(SFramesDB.partyPowerOffsetY) ~= "number" then SFramesDB.partyPowerOffsetY = 0 end + if SFramesDB.partyPowerOnTop == nil then SFramesDB.partyPowerOnTop = false end + if SFramesDB.partyShowBorder == nil then SFramesDB.partyShowBorder = false end + if type(SFramesDB.partyCornerRadius) ~= "number" then SFramesDB.partyCornerRadius = 0 end + if type(SFramesDB.partyPortraitBgAlpha) ~= "number" then SFramesDB.partyPortraitBgAlpha = SFramesDB.partyBgAlpha end + if SFramesDB.partyHealthTexture == nil then SFramesDB.partyHealthTexture = "" end + if SFramesDB.partyPowerTexture == nil then SFramesDB.partyPowerTexture = "" end + if SFramesDB.partyNameFontKey == nil then SFramesDB.partyNameFontKey = "" end + if SFramesDB.partyHealthFontKey == nil then SFramesDB.partyHealthFontKey = "" end + if SFramesDB.partyPowerFontKey == nil then SFramesDB.partyPowerFontKey = "" end + if type(SFramesDB.partyHealthFontSize) ~= "number" then SFramesDB.partyHealthFontSize = SFramesDB.partyValueFontSize end + if type(SFramesDB.partyPowerFontSize) ~= "number" then SFramesDB.partyPowerFontSize = SFramesDB.partyValueFontSize end if SFramesDB.partyShowBuffs == nil then SFramesDB.partyShowBuffs = true end if SFramesDB.partyShowDebuffs == nil then SFramesDB.partyShowDebuffs = true end + if SFramesDB.partyPortrait3D == nil then SFramesDB.partyPortrait3D = true end if type(SFramesDB.partyBgAlpha) ~= "number" then SFramesDB.partyBgAlpha = 0.9 end + if SFramesDB.totHealthTexture == nil then SFramesDB.totHealthTexture = "" end + if SFramesDB.petHealthTexture == nil then SFramesDB.petHealthTexture = "" end + if SFramesDB.petPowerTexture == nil then SFramesDB.petPowerTexture = "" end + if SFramesDB.raidLayout ~= "horizontal" and SFramesDB.raidLayout ~= "vertical" then SFramesDB.raidLayout = "horizontal" end @@ -482,6 +581,23 @@ local function EnsureDB() if type(SFramesDB.raidGroupGap) ~= "number" then SFramesDB.raidGroupGap = 8 end if type(SFramesDB.raidNameFontSize) ~= "number" then SFramesDB.raidNameFontSize = 10 end if type(SFramesDB.raidValueFontSize) ~= "number" then SFramesDB.raidValueFontSize = 9 end + local defaultRaidPowerWidth = math.max(20, SFramesDB.raidFrameWidth - 2) + if type(SFramesDB.raidPowerWidth) ~= "number" + or math.abs(SFramesDB.raidPowerWidth - SFramesDB.raidFrameWidth) < 0.5 then + SFramesDB.raidPowerWidth = defaultRaidPowerWidth + end + if type(SFramesDB.raidPowerOffsetX) ~= "number" then SFramesDB.raidPowerOffsetX = 0 end + if type(SFramesDB.raidPowerOffsetY) ~= "number" then SFramesDB.raidPowerOffsetY = 0 end + if SFramesDB.raidPowerOnTop == nil then SFramesDB.raidPowerOnTop = false end + if SFramesDB.raidShowBorder == nil then SFramesDB.raidShowBorder = false end + if type(SFramesDB.raidCornerRadius) ~= "number" then SFramesDB.raidCornerRadius = 0 end + if SFramesDB.raidHealthTexture == nil then SFramesDB.raidHealthTexture = "" end + if SFramesDB.raidPowerTexture == nil then SFramesDB.raidPowerTexture = "" end + if SFramesDB.raidNameFontKey == nil then SFramesDB.raidNameFontKey = "" end + if SFramesDB.raidHealthFontKey == nil then SFramesDB.raidHealthFontKey = "" end + if SFramesDB.raidPowerFontKey == nil then SFramesDB.raidPowerFontKey = "" end + if type(SFramesDB.raidHealthFontSize) ~= "number" then SFramesDB.raidHealthFontSize = SFramesDB.raidValueFontSize end + if type(SFramesDB.raidPowerFontSize) ~= "number" then SFramesDB.raidPowerFontSize = SFramesDB.raidValueFontSize end if SFramesDB.raidShowPower == nil then SFramesDB.raidShowPower = true end if SFramesDB.enableRaidFrames == nil then SFramesDB.enableRaidFrames = true end if SFramesDB.raidHealthFormat == nil then SFramesDB.raidHealthFormat = "compact" end @@ -494,9 +610,6 @@ local function EnsureDB() if type(SFramesDB.afkDelay) ~= "number" then SFramesDB.afkDelay = 5 end if SFramesDB.afkOutsideRest == nil then SFramesDB.afkOutsideRest = false end - if SFramesDB.trainerReminder == nil then SFramesDB.trainerReminder = true end - if SFramesDB.trainerCache == nil then SFramesDB.trainerCache = {} end - if SFramesDB.smoothBars == nil then SFramesDB.smoothBars = true end if SFramesDB.mobRealHealth == nil then SFramesDB.mobRealHealth = true end if SFramesDB.showItemLevel == nil then SFramesDB.showItemLevel = true end @@ -533,7 +646,6 @@ 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 @@ -569,15 +681,31 @@ local function EnsureDB() local abDef = { enable = true, buttonSize = 36, buttonGap = 2, smallBarSize = 27, scale = 1.0, barCount = 3, showHotkey = true, showMacroName = false, - rangeColoring = true, behindGlow = true, showPetBar = true, showStanceBar = true, + rangeColoring = true, showPetBar = true, showStanceBar = true, showRightBars = true, buttonRounded = false, buttonInnerShadow = false, alpha = 1.0, bgAlpha = 0.9, + rightBar1PerRow = 1, + rightBar2PerRow = 1, + bottomBar1PerRow = 12, + bottomBar2PerRow = 12, + bottomBar3PerRow = 12, } for k, v in pairs(abDef) do if SFramesDB.ActionBars[k] == nil then SFramesDB.ActionBars[k] = v end end + if type(SFramesDB.ExtraBar) ~= "table" then SFramesDB.ExtraBar = {} end + local ebDef = { + enable = false, buttonCount = 12, perRow = 12, buttonSize = 36, + buttonGap = 2, align = "center", startSlot = 73, alpha = 1.0, + showHotkey = true, showCount = true, + buttonRounded = false, buttonInnerShadow = false, + } + for k, v in pairs(ebDef) do + if SFramesDB.ExtraBar[k] == nil then SFramesDB.ExtraBar[k] = v end + end + if SFramesDB.enableUnitFrames == nil then SFramesDB.enableUnitFrames = true end if SFramesDB.enablePlayerFrame == nil then SFramesDB.enablePlayerFrame = true end if SFramesDB.enableTargetFrame == nil then SFramesDB.enableTargetFrame = true end @@ -760,6 +888,113 @@ local function CreateButton(parent, text, x, y, width, height, onClick) StyleButton(btn) return btn end + +local function CreateOptionGrid(parent, title, x, y, width, options, getter, setter, previewKind, previewSize, onValueChanged) + local font = SFrames:GetFont() + local controls = {} + local cols = previewKind == "font" and 3 or 5 + local btnW = previewKind == "font" and 150 or 96 + local btnH = previewKind == "font" and 22 or 48 + local gapX = previewKind == "font" and 8 or 6 + local gapY = 6 + + CreateLabel(parent, title, x, y, font, 10, 0.85, 0.75, 0.80) + + local buttons = {} + for idx, entry in ipairs(options) do + local col = math.mod(idx - 1, cols) + local row = math.floor((idx - 1) / cols) + local bx = x + col * (btnW + gapX) + local by = y - 18 - row * (btnH + gapY) + local btn = CreateFrame("Button", NextWidgetName("OptionGrid"), parent) + btn:SetWidth(btnW) + btn:SetHeight(btnH) + btn:SetPoint("TOPLEFT", parent, "TOPLEFT", bx, by) + 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) + btn.optionKey = entry.key + btn.optionLabel = entry.label + + if previewKind == "bar" then + local preview = CreateFrame("StatusBar", NextWidgetName("OptionGridBar"), btn) + preview:SetPoint("TOPLEFT", btn, "TOPLEFT", 2, -2) + preview:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -2, 14) + preview:SetStatusBarTexture(entry.path) + 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(entry.label) + label:SetTextColor(0.8, 0.8, 0.8) + else + local label = btn:CreateFontString(nil, "OVERLAY") + local ok = pcall(label.SetFont, label, entry.path, previewSize or 11, "OUTLINE") + if not ok then + label:SetFont(font, previewSize or 11, "OUTLINE") + end + label:SetPoint("CENTER", btn, "CENTER", 0, 0) + label:SetText(entry.label) + label:SetTextColor(0.9, 0.9, 0.9) + end + + 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() + setter(this.optionKey) + for _, b in ipairs(buttons) do + local active = b.optionKey == this.optionKey + b:SetBackdropBorderColor(active and 1 or 0.3, active and 0.85 or 0.3, active and 0.6 or 0.35, 1) + if b.selectBorder then + if active then b.selectBorder:Show() else b.selectBorder:Hide() end + end + end + if onValueChanged then onValueChanged(this.optionKey) end + end) + btn:SetScript("OnEnter", function() + if this.optionKey ~= getter() then + this:SetBackdropBorderColor(0.7, 0.7, 0.8, 1) + end + GameTooltip:SetOwner(this, "ANCHOR_TOP") + GameTooltip:SetText(this.optionLabel) + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() + local active = this.optionKey == getter() + this:SetBackdropBorderColor(active and 1 or 0.3, active and 0.85 or 0.3, active and 0.6 or 0.35, 1) + GameTooltip:Hide() + end) + + btn.Refresh = function() + local active = btn.optionKey == getter() + btn:SetBackdropBorderColor(active and 1 or 0.3, active and 0.85 or 0.3, active and 0.6 or 0.35, 1) + if btn.selectBorder then + if active then btn.selectBorder:Show() else btn.selectBorder:Hide() end + end + end + btn:Refresh() + + table.insert(buttons, btn) + table.insert(controls, btn) + end + + local rows = math.floor((table.getn(options) + cols - 1) / cols) + return controls, (rows * btnH) + ((rows - 1) * gapY) + 18 +end local function CreateScrollArea(parent, x, y, width, height, childHeight) local holder = CreateFrame("Frame", NextWidgetName("ScrollHolder"), parent) holder:SetWidth(width) @@ -1154,18 +1389,11 @@ function SFrames.ConfigUI:BuildUIPage() CreateDesc(tweaksSection, "将整个游戏界面调暗为深色主题(默认关闭)", 292, -142, font, 218) 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, + "进战斗后台通知", 14, -172, function() return SFramesDB.Tweaks.combatNotify ~= false end, function(checked) SFramesDB.Tweaks.combatNotify = checked end )) - CreateDesc(tweaksSection, "进入战斗时闪烁任务栏图标(需安装 UnitXP SP3)", 292, -188, font, 218) + CreateDesc(tweaksSection, "进入战斗时闪烁任务栏图标(需安装 UnitXP SP3)", 36, -188, font, 218) table.insert(controls, CreateCheckBox(tweaksSection, "鼠标指向施法", 14, -218, @@ -1177,7 +1405,7 @@ function SFrames.ConfigUI:BuildUIPage() CreateLabel(tweaksSection, "提示:以上所有选项修改后需要 /reload 才能生效。", 14, -264, font, 10, 0.6, 0.6, 0.65) -- ── 聊天背景透明度 ────────────────────────────────────── - local chatBgSection = CreateSection(root, "聊天窗口", 8, -1018, 520, 90, font) + local chatBgSection = CreateSection(root, "聊天窗口", 8, -1018, 520, 120, font) table.insert(controls, CreateSlider(chatBgSection, "背景透明度", 14, -38, 220, 0, 1.0, 0.05, function() @@ -1195,20 +1423,399 @@ function SFrames.ConfigUI:BuildUIPage() end end )) - CreateDesc(chatBgSection, "调整聊天窗口背景的透明度(仅影响背景,不影响文字)。也可在 /nui chat 中设置", 14, -68, font, 480) + table.insert(controls, CreateCheckBox(chatBgSection, + "悬停显示背景", 260, -36, + function() + local chatDB = SFramesDB and SFramesDB.Chat + return not chatDB or chatDB.hoverTransparent ~= false + end, + function(checked) + if not SFramesDB.Chat then SFramesDB.Chat = {} end + SFramesDB.Chat.hoverTransparent = (checked == true) + if SFrames.Chat and SFrames.Chat.ApplyConfig then + SFrames.Chat:ApplyConfig() + end + end + )) + CreateDesc(chatBgSection, "调整聊天窗口背景的透明度(仅影响背景,不影响文字)。勾选[悬停显示背景]后,鼠标离开时背景自动透明。", 14, -68, font, 480) uiScroll:UpdateRange() self.uiControls = controls self.uiScroll = uiScroll end +local function AddFrameAdvancedSections(root, controls, spec) + local font = SFrames:GetFont() + local y = spec.startY + local apply = spec.apply + local prefix = spec.prefix + local showPowerHint = spec.showPowerHint + local powerSectionHeight = showPowerHint and 154 or 132 + + local powerSection = CreateSection(root, spec.powerTitle or "能量条", 8, y, 520, powerSectionHeight, font) + table.insert(controls, CreateSlider(powerSection, "宽度", 14, -42, 150, 40, 420, 1, + function() return SFramesDB[prefix .. "PowerWidth"] end, + function(value) SFramesDB[prefix .. "PowerWidth"] = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + apply + )) + table.insert(controls, CreateSlider(powerSection, "高度", 170, -42, 150, 4, 40, 1, + function() return SFramesDB[prefix .. "PowerHeight"] end, + function(value) SFramesDB[prefix .. "PowerHeight"] = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + apply + )) + table.insert(controls, CreateSlider(powerSection, "X 偏移", 326, -42, 130, -120, 120, 1, + function() return SFramesDB[prefix .. "PowerOffsetX"] end, + function(value) SFramesDB[prefix .. "PowerOffsetX"] = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + apply + )) + table.insert(controls, CreateSlider(powerSection, "Y 偏移", 14, -94, 150, -80, 80, 1, + function() return SFramesDB[prefix .. "PowerOffsetY"] end, + function(value) SFramesDB[prefix .. "PowerOffsetY"] = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + apply + )) + table.insert(controls, CreateCheckBox(powerSection, + "能量条在上层", 170, -92, + function() return SFramesDB[prefix .. "PowerOnTop"] == true end, + function(checked) SFramesDB[prefix .. "PowerOnTop"] = checked end, + apply + )) + if showPowerHint then + CreateDesc(powerSection, showPowerHint, 14, -118, font, 470) + end + + y = y - (powerSectionHeight + 12) + local textureSection = CreateSection(root, "条材质", 8, y, 520, 182, font) + local texControls, texHeight = CreateOptionGrid(textureSection, "血条材质", 14, -34, 490, SFrames.BarTextures or {}, + function() return SFramesDB[prefix .. "HealthTexture"] or "" end, + function(value) SFramesDB[prefix .. "HealthTexture"] = value end, + "bar", 0, apply) + for _, control in ipairs(texControls) do table.insert(controls, control) end + local texControls2 = CreateOptionGrid(textureSection, "能量条材质", 14, -(34 + texHeight + 12), 490, SFrames.BarTextures or {}, + function() return SFramesDB[prefix .. "PowerTexture"] or "" end, + function(value) SFramesDB[prefix .. "PowerTexture"] = value end, + "bar", 0, apply) + for _, control in ipairs(texControls2) do table.insert(controls, control) end + + y = y - 194 + local fontSection = CreateSection(root, "字体", 8, y, 520, spec.fontSectionHeight or 286, font) + local blockY = -34 + local function AddFontBlock(title, keyName, sizeKey) + local optControls, optHeight = CreateOptionGrid(fontSection, title, 14, blockY, 490, SFrames.FontChoices or {}, + function() return SFramesDB[keyName] or "" end, + function(value) SFramesDB[keyName] = value end, + "font", 11, apply) + for _, control in ipairs(optControls) do table.insert(controls, control) end + table.insert(controls, CreateSlider(fontSection, title .. "字号", 14, blockY - optHeight - 26, 180, 8, 24, 1, + function() return SFramesDB[sizeKey] end, + function(value) SFramesDB[sizeKey] = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + apply + )) + blockY = blockY - optHeight - 74 + end + + AddFontBlock("姓名字体", prefix .. "NameFontKey", prefix .. "NameFontSize") + AddFontBlock("生命值字体", prefix .. "HealthFontKey", prefix .. "HealthFontSize") + AddFontBlock("能量值字体", prefix .. "PowerFontKey", prefix .. "PowerFontSize") + + if spec.extraFontBlocks then + for _, extra in ipairs(spec.extraFontBlocks) do + AddFontBlock(extra.title, extra.keyName, extra.sizeKey) + end + end + + return y - (spec.fontSectionHeight or 286) - 12 +end + +local FRAME_DEFAULT_BUILDERS = { + player = function() + local portraitHeight = (SFrames.Config and SFrames.Config.height or 45) - 2 + return { + enablePlayerFrame = true, playerFrameScale = 1, playerFrameWidth = 220, + playerPortraitWidth = 50, playerPortraitHeight = portraitHeight, playerPortraitOffsetX = 0, playerPortraitOffsetY = 0, playerPortraitMode = "head", + playerHealthHeight = 38, playerPowerHeight = 9, playerPowerWidth = 166, playerPowerOffsetX = 0, playerPowerOffsetY = 0, playerPowerOnTop = false, + playerShowClass = false, playerShowClassIcon = true, playerShowPortrait = true, + playerFrameAlpha = 1, playerBgAlpha = 0.9, playerPortraitBgAlpha = 0.9, playerShowBorder = false, playerCornerRadius = 0, + playerNameFontSize = 10, playerValueFontSize = 10, playerHealthFontSize = 10, playerPowerFontSize = 10, + playerHealthTexture = "", playerPowerTexture = "", playerNameFontKey = "", playerHealthFontKey = "", playerPowerFontKey = "", + castbarStandalone = true, castbarRainbow = true, castbarWidth = 280, castbarHeight = 20, castbarAlpha = 1, powerRainbow = false, showPetFrame = true, petFrameScale = 1, + playerLeaderIconOffsetX = 0, playerLeaderIconOffsetY = 0, playerRaidIconOffsetX = 0, playerRaidIconOffsetY = 0, + petHealthTexture = "", petPowerTexture = "", + } + end, + target = function() + local portraitHeight = (SFrames.Config and SFrames.Config.height or 45) - 2 + return { + enableTargetFrame = true, targetFrameScale = 1, targetFrameWidth = 220, + targetPortraitWidth = 50, targetPortraitHeight = portraitHeight, targetPortraitOffsetX = 0, targetPortraitOffsetY = 0, targetPortraitMode = "head", + targetHealthHeight = 38, targetPowerHeight = 9, targetPowerWidth = 168, targetPowerOffsetX = 0, targetPowerOffsetY = 0, targetPowerOnTop = false, + targetShowClass = false, targetShowClassIcon = true, targetShowPortrait = true, + targetFrameAlpha = 1, targetBgAlpha = 0.9, targetPortraitBgAlpha = 0.9, targetShowBorder = false, targetCornerRadius = 0, + targetNameFontSize = 10, targetValueFontSize = 10, targetHealthFontSize = 10, targetPowerFontSize = 10, + targetHealthTexture = "", targetPowerTexture = "", targetNameFontKey = "", targetHealthFontKey = "", targetPowerFontKey = "", + targetDistanceEnabled = true, targetDistanceOnFrame = true, targetDistanceScale = 1, targetDistanceFontSize = 14, targetDistanceFontKey = "", + totHealthTexture = "", + } + end, + focus = function() + return { + focusEnabled = true, focusFrameScale = 0.9, focusFrameWidth = 200, + focusPortraitWidth = 45, focusPortraitHeight = 43, focusPortraitOffsetX = 0, focusPortraitOffsetY = 0, focusPortraitMode = "head", + focusHealthHeight = 32, focusPowerHeight = 10, focusPowerWidth = 153, focusPowerOffsetX = 0, focusPowerOffsetY = 0, focusPowerOnTop = false, + focusShowPortrait = true, focusShowCastBar = true, focusShowAuras = true, + focusBgAlpha = 0.9, focusPortraitBgAlpha = 0.9, focusShowBorder = false, focusCornerRadius = 0, + focusNameFontSize = 11, focusValueFontSize = 10, focusHealthFontSize = 10, focusPowerFontSize = 10, focusCastFontSize = 10, focusDistanceFontSize = 14, + focusHealthTexture = "", focusPowerTexture = "", focusNameFontKey = "", focusHealthFontKey = "", focusPowerFontKey = "", focusCastFontKey = "", focusDistanceFontKey = "", + } + end, + party = function() + return { + enablePartyFrame = true, partyLayout = "vertical", partyFrameScale = 1, partyFrameWidth = 150, partyFrameHeight = 35, + partyPortraitWidth = 33, partyPortraitHeight = 33, partyPortraitOffsetX = 0, partyPortraitOffsetY = 0, partyPortraitMode = "head", + partyHealthHeight = 22, partyPowerHeight = 10, partyPowerWidth = 112, partyPowerOffsetX = 0, partyPowerOffsetY = 0, partyPowerOnTop = false, + partyHorizontalGap = 8, partyVerticalGap = 30, partyBgAlpha = 0.9, partyPortraitBgAlpha = 0.9, partyShowBorder = false, partyCornerRadius = 0, + partyNameFontSize = 10, partyValueFontSize = 10, partyHealthFontSize = 10, partyPowerFontSize = 10, + partyHealthTexture = "", partyPowerTexture = "", partyNameFontKey = "", partyHealthFontKey = "", partyPowerFontKey = "", + partyShowBuffs = true, partyShowDebuffs = true, partyPortrait3D = true, + } + end, + raid = function() + return { + enableRaidFrames = true, raidLayout = "horizontal", raidFrameScale = 1, raidFrameWidth = 60, raidFrameHeight = 40, + raidHealthHeight = 31, raidPowerHeight = 6, raidPowerWidth = 58, raidPowerOffsetX = 0, raidPowerOffsetY = 0, raidPowerOnTop = false, + raidHorizontalGap = 2, raidVerticalGap = 2, raidGroupGap = 8, raidBgAlpha = 0.9, raidShowBorder = false, raidCornerRadius = 0, + raidNameFontSize = 10, raidValueFontSize = 9, raidHealthFontSize = 9, raidPowerFontSize = 9, + raidHealthTexture = "", raidPowerTexture = "", raidNameFontKey = "", raidHealthFontKey = "", raidPowerFontKey = "", + raidShowPower = true, raidHealthFormat = "compact", raidShowGroupLabel = true, + } + end, +} + +local activePreviewDropdown + +local function FindOptionEntry(options, key) + if type(options) ~= "table" then return nil end + for _, entry in ipairs(options) do + if entry.key == key then return entry end + end + return options[1] +end + +local function ClosePreviewDropdown() + if activePreviewDropdown and activePreviewDropdown.Hide then activePreviewDropdown:Hide() end + activePreviewDropdown = nil +end + +local function CreatePreviewDropdown(parent, label, x, y, width, options, getter, setter, previewKind, previewSize, onValueChanged) + local font = SFrames:GetFont() + CreateLabel(parent, label, x, y, font, 10, 0.85, 0.75, 0.80) + + local button = CreateFrame("Button", NextWidgetName("PreviewDropdown"), parent) + button:SetWidth(width) + button:SetHeight(24) + button:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y - 16) + button:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8", edgeFile = "Interface\\Buttons\\WHITE8X8", tile = false, edgeSize = 1, insets = { left = 1, right = 1, top = 1, bottom = 1 } }) + button:SetBackdropColor(0.08, 0.08, 0.10, 0.92) + button:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + + local preview = button:CreateFontString(nil, "OVERLAY") + preview:SetPoint("LEFT", button, "LEFT", 8, 0) + preview:SetPoint("RIGHT", button, "RIGHT", -24, 0) + preview:SetJustifyH("LEFT") + preview:SetTextColor(0.92, 0.92, 0.92) + + local arrow = button:CreateFontString(nil, "OVERLAY") + arrow:SetFont(font, 10, "OUTLINE") + arrow:SetPoint("RIGHT", button, "RIGHT", -8, 0) + arrow:SetText("▼") + arrow:SetTextColor(0.8, 0.75, 0.8) + + local menu = CreateFrame("Frame", NextWidgetName("PreviewDropdownMenu"), UIParent) + menu:SetWidth(width) + menu:SetHeight((table.getn(options or {}) * 22) + 8) + menu:SetFrameStrata("DIALOG") + menu:SetClampedToScreen(true) + EnsureSoftBackdrop(menu) + menu:SetBackdropColor(0.05, 0.05, 0.07, 0.96) + menu:SetBackdropBorderColor(0.45, 0.38, 0.46, 1) + menu:Hide() + button.menu = menu + + for index, entry in ipairs(options or {}) do + local item = CreateFrame("Button", NextWidgetName("PreviewDropdownItem"), menu) + item:SetWidth(width - 10) + item:SetHeight(20) + item:SetPoint("TOPLEFT", menu, "TOPLEFT", 5, -5 - ((index - 1) * 22)) + item.optionKey = entry.key + + local itemText = item:CreateFontString(nil, "OVERLAY") + itemText:SetPoint("LEFT", item, "LEFT", 6, 0) + itemText:SetPoint("RIGHT", item, "RIGHT", -6, 0) + itemText:SetJustifyH("LEFT") + itemText:SetText(entry.label) + if previewKind == "font" then + local ok = pcall(itemText.SetFont, itemText, entry.path, previewSize or 11, "OUTLINE") + if not ok then itemText:SetFont(font, previewSize or 11, "OUTLINE") end + else + itemText:SetFont(font, 10, "OUTLINE") + end + itemText:SetTextColor(0.9, 0.9, 0.9) + item.text = itemText + + item:SetScript("OnEnter", function() this.text:SetTextColor(1, 0.88, 0.55) end) + item:SetScript("OnLeave", function() this.text:SetTextColor(0.9, 0.9, 0.9) end) + item:SetScript("OnClick", function() + setter(this.optionKey) + ClosePreviewDropdown() + if button.Refresh then button:Refresh() end + if onValueChanged then onValueChanged(this.optionKey) end + end) + end + + button:SetScript("OnHide", function() if this.menu then this.menu:Hide() end end) + button:SetScript("OnClick", function() + if this.menu:IsShown() then + ClosePreviewDropdown() + return + end + ClosePreviewDropdown() + this.menu:ClearAllPoints() + this.menu:SetPoint("TOPLEFT", this, "BOTTOMLEFT", 0, -2) + this.menu:Show() + activePreviewDropdown = this.menu + end) + + button.Refresh = function() + local entry = FindOptionEntry(options, getter() or "") + if not entry then return end + preview:SetText(entry.label) + if previewKind == "font" then + local ok = pcall(preview.SetFont, preview, entry.path, previewSize or 11, "OUTLINE") + if not ok then preview:SetFont(font, previewSize or 11, "OUTLINE") end + else + preview:SetFont(font, 10, "OUTLINE") + end + end + + button:Refresh() + return button +end + +local function ResetFrameDefaults(kind) + local builder = FRAME_DEFAULT_BUILDERS[kind] + if not builder then return end + for key, value in pairs(builder()) do + SFramesDB[key] = value + end +end + +local function CreateResetButton(parent, x, y, frameKind, refreshFn, controls) + return CreateButton(parent, "恢复默认", x, y, 100, 22, function() + ClosePreviewDropdown() + ResetFrameDefaults(frameKind) + EnsureDB() + if controls then SFrames.ConfigUI:RefreshControls(controls) end + if refreshFn then refreshFn() end + end) +end + +local function AddFontControl(parent, controls, label, keyName, sizeKey, x, y, apply, sizeMin, sizeMax) + table.insert(controls, CreatePreviewDropdown(parent, label .. "字体", x, y, 220, SFrames.FontChoices or {}, + function() return SFramesDB[keyName] or "" end, + function(value) SFramesDB[keyName] = value end, + "font", 11, apply)) + table.insert(controls, CreateSlider(parent, label .. "字号", x + 244, y - 4, 180, sizeMin or 8, sizeMax or 24, 1, + function() return SFramesDB[sizeKey] end, + function(value) SFramesDB[sizeKey] = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + apply)) +end + +local function AddFontControl(parent, controls, label, keyName, sizeKey, x, y, apply, sizeMin, sizeMax) + CreateLabel(parent, label .. "字体", x, y, SFrames:GetFont(), 10, 0.85, 0.75, 0.80) + + local choices = SFrames.FontChoices or {} + local previewBtn = CreateFrame("Button", NextWidgetName("FontPickerSafe"), parent) + previewBtn:SetWidth(220) + previewBtn:SetHeight(24) + previewBtn:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y - 16) + previewBtn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + previewBtn:SetBackdropColor(0.08, 0.08, 0.10, 0.92) + previewBtn:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + + local previewText = previewBtn:CreateFontString(nil, "OVERLAY") + previewText:SetPoint("LEFT", previewBtn, "LEFT", 8, 0) + previewText:SetPoint("RIGHT", previewBtn, "RIGHT", -8, 0) + previewText:SetJustifyH("LEFT") + previewText:SetTextColor(0.92, 0.92, 0.92) + + local function FindFontIndex() + local current = SFramesDB[keyName] or "" + for idx, entry in ipairs(choices) do + if entry.key == current then + return idx + end + end + return 1 + end + + local function ApplyFontIndex(index) + local entry = choices[index] or choices[1] + if not entry then return end + SFramesDB[keyName] = entry.key + previewText:SetText(entry.label) + local ok = pcall(previewText.SetFont, previewText, entry.path, 11, "OUTLINE") + if not ok then + previewText:SetFont(SFrames:GetFont(), 11, "OUTLINE") + end + if apply then apply() end + end + + previewBtn.Refresh = function() + ApplyFontIndex(FindFontIndex()) + end + previewBtn:SetScript("OnClick", function() + local idx = FindFontIndex() + 1 + if idx > table.getn(choices) then idx = 1 end + ApplyFontIndex(idx) + end) + previewBtn:Refresh() + table.insert(controls, previewBtn) + + table.insert(controls, CreateButton(parent, "<", x + 224, y - 16, 24, 24, function() + local idx = FindFontIndex() - 1 + if idx < 1 then idx = table.getn(choices) end + ApplyFontIndex(idx) + end)) + + table.insert(controls, CreateButton(parent, ">", x + 252, y - 16, 24, 24, function() + local idx = FindFontIndex() + 1 + if idx > table.getn(choices) then idx = 1 end + ApplyFontIndex(idx) + end)) + + table.insert(controls, CreateSlider(parent, label .. "字号", x + 284, y - 4, 140, sizeMin or 8, sizeMax or 24, 1, + function() return SFramesDB[sizeKey] end, + function(value) SFramesDB[sizeKey] = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + apply)) +end function SFrames.ConfigUI:BuildPlayerPage() local font = SFrames:GetFont() local page = self.playerPage local controls = {} - local playerScroll = CreateScrollArea(page, 4, -4, 552, PANEL_HEIGHT - 110, 660) + local playerScroll = CreateScrollArea(page, 4, -4, 552, 420, 1960) local root = playerScroll.child local function RefreshPlayer() @@ -1226,6 +1833,7 @@ function SFrames.ConfigUI:BuildPlayerPage() function() return SFramesDB.enablePlayerFrame ~= false end, function(checked) SFramesDB.enablePlayerFrame = checked end )) + table.insert(controls, CreateResetButton(playerSection, 356, -24, "player", RefreshPlayer, controls)) table.insert(controls, CreateSlider(playerSection, "缩放", 14, -72, 150, 0.7, 1.8, 0.05, function() return SFramesDB.playerFrameScale end, @@ -1328,7 +1936,7 @@ function SFrames.ConfigUI:BuildPlayerPage() 14, -355, font, 10, 0.9, 0.9, 0.9) -- ── 施法条 ────────────────────────────────────────────────── - local cbSection = CreateSection(root, "施法条", 8, -418, 520, 110, font) + local cbSection = CreateSection(root, "施法条", 8, -418, 520, 250, font) table.insert(controls, CreateCheckBox(cbSection, "独立施法条(显示在屏幕下方)", 14, -34, @@ -1349,8 +1957,43 @@ function SFrames.ConfigUI:BuildPlayerPage() )) CreateDesc(cbSection, "施法时条颜色会随时间流转变化", 36, -90, font) + table.insert(controls, CreateSlider(cbSection, "独立施法条宽度", 14, -120, 200, 100, 500, 5, + function() return SFramesDB.castbarWidth or 280 end, + function(value) SFramesDB.castbarWidth = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + function() + if SFrames.Player and SFrames.Player.ApplyCastbarPosition then + SFrames.Player:ApplyCastbarPosition() + end + end + )) + + table.insert(controls, CreateSlider(cbSection, "独立施法条高度", 270, -120, 200, 10, 60, 1, + function() return SFramesDB.castbarHeight or 20 end, + function(value) SFramesDB.castbarHeight = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + function() + if SFrames.Player and SFrames.Player.ApplyCastbarPosition then + SFrames.Player:ApplyCastbarPosition() + end + end + )) + + table.insert(controls, CreateSlider(cbSection, "施法条透明度", 14, -160, 200, 0.1, 1.0, 0.05, + function() return SFramesDB.castbarAlpha or 1 end, + function(value) SFramesDB.castbarAlpha = value end, + function(v) return string.format("%.0f%%", v * 100) end, + function(value) + local cb = SFrames.Player and SFrames.Player.frame and SFrames.Player.frame.castbar + if cb and cb:IsShown() then + cb:SetAlpha(value) + if cb.cbbg then cb.cbbg:SetAlpha(value) end + end + end + )) + -- ── 宠物框体 ────────────────────────────────────────────────── - local petSection = CreateSection(root, "宠物框体", 8, -536, 520, 98, font) + local petSection = CreateSection(root, "宠物框体", 8, -676, 520, 98, font) table.insert(controls, CreateCheckBox(petSection, "显示宠物框体", 14, -34, @@ -1367,6 +2010,56 @@ function SFrames.ConfigUI:BuildPlayerPage() function(value) if SFrames.Pet and SFrames.Pet.frame then SFrames.Pet.frame:SetScale(value) end end )) + -- ── 图标位置 ────────────────────────────────────────────────── + local iconSection = CreateSection(root, "图标位置", 8, -754, 520, 142, font) + table.insert(controls, CreateSlider(iconSection, "队长图标 X", 14, -44, 150, -80, 80, 1, + function() return SFramesDB.playerLeaderIconOffsetX or 0 end, + function(value) SFramesDB.playerLeaderIconOffsetX = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + RefreshPlayer + )) + table.insert(controls, CreateSlider(iconSection, "队长图标 Y", 170, -44, 150, -80, 80, 1, + function() return SFramesDB.playerLeaderIconOffsetY or 0 end, + function(value) SFramesDB.playerLeaderIconOffsetY = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + RefreshPlayer + )) + table.insert(controls, CreateSlider(iconSection, "战斗标记 X", 14, -104, 150, -120, 120, 1, + function() return SFramesDB.playerRaidIconOffsetX or 0 end, + function(value) SFramesDB.playerRaidIconOffsetX = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + RefreshPlayer + )) + table.insert(controls, CreateSlider(iconSection, "战斗标记 Y", 170, -104, 150, -80, 80, 1, + function() return SFramesDB.playerRaidIconOffsetY or 0 end, + function(value) SFramesDB.playerRaidIconOffsetY = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + RefreshPlayer + )) + + AddFrameAdvancedSections(root, controls, { + prefix = "player", + startY = -944, + apply = RefreshPlayer, + }) + + local function RefreshPet() + if SFrames.Pet and SFrames.Pet.ApplyConfig then SFrames.Pet:ApplyConfig() end + if SFrames.Pet and SFrames.Pet.UpdateAll then SFrames.Pet:UpdateAll() end + end + + local petTexSection = CreateSection(root, "宠物框体 条材质", 8, -1800, 520, 182, font) + local petTexControls, petTexH = CreateOptionGrid(petTexSection, "血条材质", 14, -34, 490, SFrames.BarTextures or {}, + function() return SFramesDB.petHealthTexture or "" end, + function(value) SFramesDB.petHealthTexture = value end, + "bar", 0, RefreshPet) + for _, c in ipairs(petTexControls) do table.insert(controls, c) end + local petTexControls2 = CreateOptionGrid(petTexSection, "能量条材质", 14, -(34 + petTexH + 12), 490, SFrames.BarTextures or {}, + function() return SFramesDB.petPowerTexture or "" end, + function(value) SFramesDB.petPowerTexture = value end, + "bar", 0, RefreshPet) + for _, c in ipairs(petTexControls2) do table.insert(controls, c) end + playerScroll:UpdateRange() self.playerControls = controls self.playerScroll = playerScroll @@ -1385,13 +2078,17 @@ function SFrames.ConfigUI:BuildTargetPage() if SFrames.Target and SFrames.Target.UpdateAll then SFrames.Target:UpdateAll() end end - local targetSection = CreateSection(page, "目标框体", 8, -8, 520, 460, font) + local targetScroll = CreateScrollArea(page, 4, -4, 548, 420, 1780) + local targetRoot = targetScroll.child + + local targetSection = CreateSection(targetRoot, "目标框体", 8, -8, 520, 460, font) table.insert(controls, CreateCheckBox(targetSection, "启用 Nanami 目标框体(需 /reload)", 12, -28, function() return SFramesDB.enableTargetFrame ~= false end, function(checked) SFramesDB.enableTargetFrame = checked end )) + table.insert(controls, CreateResetButton(targetSection, 356, -24, "target", RefreshTarget, controls)) table.insert(controls, CreateSlider(targetSection, "缩放", 14, -72, 150, 0.7, 1.8, 0.05, function() return SFramesDB.targetFrameScale end, @@ -1501,10 +2198,22 @@ function SFrames.ConfigUI:BuildTargetPage() SFrames.Target.distanceFrame:Hide() end end + if SFrames.Target and SFrames.Target.frame and SFrames.Target.frame.distText then + if not checked then SFrames.Target.frame.distText:Hide() end + end end )) - table.insert(controls, CreateSlider(targetSection, "距离文本缩放", 14, -378, 150, 0.7, 1.8, 0.05, + table.insert(controls, CreateCheckBox(targetSection, + "距离显示在框体上", 326, -354, + function() return SFramesDB.targetDistanceOnFrame ~= false end, + function(checked) SFramesDB.targetDistanceOnFrame = checked end, + function() + if SFrames.Target then SFrames.Target:OnTargetChanged() end + end + )) + + table.insert(controls, CreateSlider(targetSection, "距离文本缩放(独立框体)", 14, -406, 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, @@ -1515,7 +2224,7 @@ function SFrames.ConfigUI:BuildTargetPage() end )) - table.insert(controls, CreateSlider(targetSection, "距离文字大小", 200, -378, 150, 8, 24, 1, + table.insert(controls, CreateSlider(targetSection, "距离文字大小", 200, -406, 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, @@ -1523,10 +2232,36 @@ function SFrames.ConfigUI:BuildTargetPage() if SFrames.Target and SFrames.Target.ApplyDistanceScale then SFrames.Target:ApplyDistanceScale(SFramesDB.targetDistanceScale or 1) end + if SFrames.Target and SFrames.Target.ApplyConfig then + SFrames.Target:ApplyConfig() + end end )) + AddFrameAdvancedSections(targetRoot, controls, { + prefix = "target", + startY = -516, + apply = RefreshTarget, + extraFontBlocks = { + { title = "距离文字字体", keyName = "targetDistanceFontKey", sizeKey = "targetDistanceFontSize" }, + }, + fontSectionHeight = 372, + }) + + local function RefreshToT() + if SFrames.ToT and SFrames.ToT.ApplyConfig then SFrames.ToT:ApplyConfig() end + end + + local totTexSection = CreateSection(targetRoot, "目标的目标 条材质", 8, -1560, 520, 100, font) + local totTexControls, totTexH = CreateOptionGrid(totTexSection, "血条材质", 14, -34, 490, SFrames.BarTextures or {}, + function() return SFramesDB.totHealthTexture or "" end, + function(value) SFramesDB.totHealthTexture = value end, + "bar", 0, RefreshToT) + for _, c in ipairs(totTexControls) do table.insert(controls, c) end + + targetScroll:UpdateRange() self.targetControls = controls + self.targetScroll = targetScroll end function SFrames.ConfigUI:BuildFocusPage() @@ -1534,7 +2269,9 @@ function SFrames.ConfigUI:BuildFocusPage() local page = self.focusPage local controls = {} - local focusSection = CreateSection(page, "焦点框体", 8, -8, 520, 380, font) + local focusScroll = CreateScrollArea(page, 4, -4, 548, 420, 1860) + local root = focusScroll.child + local focusSection = CreateSection(root, "焦点框体", 8, -8, 520, 390, font) local function FocusApply() if SFrames.Focus and SFrames.Focus.ApplySettings then @@ -1547,6 +2284,7 @@ function SFrames.ConfigUI:BuildFocusPage() function() return SFramesDB.focusEnabled ~= false end, function(checked) SFramesDB.focusEnabled = checked end )) + table.insert(controls, CreateResetButton(focusSection, 356, -24, "focus", FocusApply, controls)) table.insert(controls, CreateSlider(focusSection, "缩放", 14, -72, 150, 0.5, 1.8, 0.05, function() return SFramesDB.focusFrameScale end, @@ -1618,7 +2356,20 @@ function SFrames.ConfigUI:BuildFocusPage() 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) + AddFrameAdvancedSections(root, controls, { + prefix = "focus", + startY = -418, + apply = FocusApply, + extraFontBlocks = { + { title = "施法条字体", keyName = "focusCastFontKey", sizeKey = "focusCastFontSize" }, + { title = "距离文字字体", keyName = "focusDistanceFontKey", sizeKey = "focusDistanceFontSize" }, + }, + fontSectionHeight = 458, + }) + + focusScroll:UpdateRange() self.focusControls = controls + self.focusScroll = focusScroll end function SFrames.ConfigUI:BuildPartyPage() @@ -1633,7 +2384,7 @@ 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, 480) + local uiScroll = CreateScrollArea(page, 4, -4, 548, 420, 1720) local root = uiScroll.child local partySection = CreateSection(root, "小队", 8, -8, 520, 460, font) @@ -1643,6 +2394,7 @@ function SFrames.ConfigUI:BuildPartyPage() function() return SFramesDB.enablePartyFrame ~= false end, function(checked) SFramesDB.enablePartyFrame = checked end )) + table.insert(controls, CreateResetButton(partySection, 356, -24, "party", RefreshParty, controls)) CreateButton(partySection, "解锁小队框架", 14, -52, 130, 22, function() if SFrames and SFrames.UnlockFrames then SFrames:UnlockFrames() end @@ -1766,9 +2518,22 @@ function SFrames.ConfigUI:BuildPartyPage() function() RefreshParty() end )) + table.insert(controls, CreateCheckBox(partySection, + "启用3D头像", 326, -380, + function() return SFramesDB.partyPortrait3D ~= false end, + function(checked) SFramesDB.partyPortrait3D = checked end, + function() RefreshParty() end + )) + CreateButton(partySection, "小队测试模式", 326, -410, 130, 22, function() if SFrames.Party and SFrames.Party.TestMode then SFrames.Party:TestMode() end end) + AddFrameAdvancedSections(root, controls, { + prefix = "party", + startY = -486, + apply = RefreshParty, + }) + uiScroll:UpdateRange() self.partyControls = controls self.partyScroll = uiScroll @@ -2009,7 +2774,7 @@ function SFrames.ConfigUI:BuildRaidPage() end end - local uiScroll = CreateScrollArea(page, 4, -4, 548, 458, 600) + local uiScroll = CreateScrollArea(page, 4, -4, 548, 420, 1620) local root = uiScroll.child local raidSection = CreateSection(root, "团队框架设置", 8, -8, 520, 570, font) @@ -2019,6 +2784,7 @@ function SFrames.ConfigUI:BuildRaidPage() function() return SFramesDB.enableRaidFrames ~= false end, function(checked) SFramesDB.enableRaidFrames = checked end )) + table.insert(controls, CreateResetButton(raidSection, 356, -24, "raid", RefreshRaid, controls)) CreateButton(raidSection, "解锁团队框架", 14, -56, 130, 22, function() if SFrames and SFrames.UnlockFrames then SFrames:UnlockFrames() end @@ -2164,7 +2930,15 @@ function SFrames.ConfigUI:BuildRaidPage() end end end) - + + AddFrameAdvancedSections(root, controls, { + prefix = "raid", + startY = -592, + apply = RefreshRaid, + hasPortraitBg = false, + showPowerHint = "关闭团队能量条后,这些设置会保留。", + }) + uiScroll:UpdateRange() self.raidControls = controls self.raidScroll = uiScroll @@ -2317,10 +3091,10 @@ function SFrames.ConfigUI:BuildActionBarPage() end end - local uiScroll = CreateScrollArea(page, 4, -4, 548, 458, 1040) + local uiScroll = CreateScrollArea(page, 4, -4, 548, 458, 1400) local root = uiScroll.child - local abSection = CreateSection(root, "动作条", 8, -8, 520, 840, font) + local abSection = CreateSection(root, "动作条", 8, -8, 520, 1040, font) table.insert(controls, CreateCheckBox(abSection, "启用动作条接管(需 /reload 生效)", 12, -28, @@ -2383,7 +3157,10 @@ function SFrames.ConfigUI:BuildActionBarPage() function(value) if SFrames.ActionBars then if SFrames.ActionBars.anchor then SFrames.ActionBars.anchor:SetAlpha(value) end - if SFrames.ActionBars.rightHolder then SFrames.ActionBars.rightHolder:SetAlpha(value) end + if SFrames.ActionBars.row2Anchor then SFrames.ActionBars.row2Anchor:SetAlpha(value) end + if SFrames.ActionBars.row3Anchor then SFrames.ActionBars.row3Anchor:SetAlpha(value) end + if SFrames.ActionBars.rightBar1Holder then SFrames.ActionBars.rightBar1Holder:SetAlpha(value) end + if SFrames.ActionBars.rightBar2Holder then SFrames.ActionBars.rightBar2Holder:SetAlpha(value) end if SFrames.ActionBars.stanceHolder then SFrames.ActionBars.stanceHolder:SetAlpha(value) end if SFrames.ActionBars.petHolder then SFrames.ActionBars.petHolder:SetAlpha(value) end end @@ -2397,7 +3174,10 @@ function SFrames.ConfigUI:BuildActionBarPage() 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.row2Anchor then ApplyBgAlphaToFrame(SFrames.ActionBars.row2Anchor, value) end + if SFrames.ActionBars.row3Anchor then ApplyBgAlphaToFrame(SFrames.ActionBars.row3Anchor, value) end + if SFrames.ActionBars.rightBar1Holder then ApplyBgAlphaToFrame(SFrames.ActionBars.rightBar1Holder, value) end + if SFrames.ActionBars.rightBar2Holder then ApplyBgAlphaToFrame(SFrames.ActionBars.rightBar2Holder, 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 @@ -2425,85 +3205,114 @@ function SFrames.ConfigUI:BuildActionBarPage() )) table.insert(controls, CreateCheckBox(abSection, - "背后技能高亮(在目标背后时高亮背刺等技能)", 12, -356, - function() return SFramesDB.ActionBars.behindGlow ~= false end, - function(checked) SFramesDB.ActionBars.behindGlow = checked end - )) - - table.insert(controls, CreateCheckBox(abSection, - "显示宠物动作条", 12, -384, + "显示宠物动作条", 12, -356, function() return SFramesDB.ActionBars.showPetBar ~= false end, function(checked) SFramesDB.ActionBars.showPetBar = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "显示姿态栏", 200, -384, + "显示姿态栏", 200, -356, function() return SFramesDB.ActionBars.showStanceBar ~= false end, function(checked) SFramesDB.ActionBars.showStanceBar = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "显示右侧动作条(两列竖向栏)", 12, -412, + "显示右侧动作条", 12, -384, function() return SFramesDB.ActionBars.showRightBars ~= false end, function(checked) SFramesDB.ActionBars.showRightBars = checked end, function() RefreshAB() end )) + table.insert(controls, CreateSlider(abSection, "右侧栏1每行按钮数", 14, -416, 150, 1, 12, 1, + function() return SFramesDB.ActionBars.rightBar1PerRow or 1 end, + function(value) SFramesDB.ActionBars.rightBar1PerRow = value end, + function(v) local n = math.floor(v + 0.5); if n == 1 then return "1 (竖)" elseif n == 12 then return "12 (横)" else return tostring(n) end end, + function() RefreshAB() end + )) + + table.insert(controls, CreateSlider(abSection, "右侧栏2每行按钮数", 180, -416, 150, 1, 12, 1, + function() return SFramesDB.ActionBars.rightBar2PerRow or 1 end, + function(value) SFramesDB.ActionBars.rightBar2PerRow = value end, + function(v) local n = math.floor(v + 0.5); if n == 1 then return "1 (竖)" elseif n == 12 then return "12 (横)" else return tostring(n) end end, + function() RefreshAB() end + )) + + table.insert(controls, CreateSlider(abSection, "底部栏1每行", 12, -456, 110, 1, 12, 1, + function() return SFramesDB.ActionBars.bottomBar1PerRow or 12 end, + function(value) SFramesDB.ActionBars.bottomBar1PerRow = value end, + function(v) local n = math.floor(v + 0.5); if n == 12 then return "12 (满)" else return tostring(n) end end, + function() RefreshAB() end + )) + + table.insert(controls, CreateSlider(abSection, "底部栏2每行", 134, -456, 110, 1, 12, 1, + function() return SFramesDB.ActionBars.bottomBar2PerRow or 12 end, + function(value) SFramesDB.ActionBars.bottomBar2PerRow = value end, + function(v) local n = math.floor(v + 0.5); if n == 12 then return "12 (满)" else return tostring(n) end end, + function() RefreshAB() end + )) + + table.insert(controls, CreateSlider(abSection, "底部栏3每行", 256, -456, 110, 1, 12, 1, + function() return SFramesDB.ActionBars.bottomBar3PerRow or 12 end, + function(value) SFramesDB.ActionBars.bottomBar3PerRow = value end, + function(v) local n = math.floor(v + 0.5); if n == 12 then return "12 (满)" else return tostring(n) end end, + function() RefreshAB() end + )) + table.insert(controls, CreateCheckBox(abSection, - "始终显示动作条(空格子也显示背景框)", 12, -440, + "始终显示动作条(空格子也显示背景框)", 12, -514, function() return SFramesDB.ActionBars.alwaysShowGrid == true end, function(checked) SFramesDB.ActionBars.alwaysShowGrid = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "按钮圆角", 12, -468, + "按钮圆角", 12, -542, function() return SFramesDB.ActionBars.buttonRounded == true end, function(checked) SFramesDB.ActionBars.buttonRounded = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "按钮内阴影", 200, -468, + "按钮内阴影", 200, -542, function() return SFramesDB.ActionBars.buttonInnerShadow == true end, function(checked) SFramesDB.ActionBars.buttonInnerShadow = checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "显示动作条狮鹫(在底部动作条两侧显示装饰狮鹫)", 12, -496, + "显示动作条狮鹫(在底部动作条两侧显示装饰狮鹫)", 12, -570, function() return SFramesDB.ActionBars.hideGryphon == false end, function(checked) SFramesDB.ActionBars.hideGryphon = not checked end, function() RefreshAB() end )) table.insert(controls, CreateCheckBox(abSection, - "狮鹫置于动作条之上(否则在动作条之下)", 12, -524, + "狮鹫置于动作条之上(否则在动作条之下)", 12, -598, function() return SFramesDB.ActionBars.gryphonOnTop == true end, function(checked) SFramesDB.ActionBars.gryphonOnTop = checked end, function() RefreshAB() end )) - table.insert(controls, CreateSlider(abSection, "狮鹫宽度", 14, -578, 150, 24, 200, 2, + table.insert(controls, CreateSlider(abSection, "狮鹫宽度", 14, -652, 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, -578, 150, 24, 200, 2, + table.insert(controls, CreateSlider(abSection, "狮鹫高度", 180, -652, 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, -640, font, 480) + CreateDesc(abSection, "使用布局模式 (/nui layout) 调整狮鹫位置", 14, -714, font, 480) -- 狮鹫样式选择器(带图例预览) - CreateLabel(abSection, "狮鹫样式:", 14, -692, font, 11, 0.85, 0.75, 0.80) + CreateLabel(abSection, "狮鹫样式:", 14, -766, font, 11, 0.85, 0.75, 0.80) local GRYPHON_STYLES_UI = { { key = "dragonflight", label = "巨龙时代", @@ -2518,7 +3327,7 @@ function SFrames.ConfigUI:BuildActionBarPage() local styleBorders = {} local styleStartX = 14 - local styleY = -712 + local styleY = -786 for idx, style in ipairs(GRYPHON_STYLES_UI) do local xOff = styleStartX + (idx - 1) * 125 @@ -2600,12 +3409,167 @@ function SFrames.ConfigUI:BuildActionBarPage() end end - CreateLabel(abSection, "动作条位置:", 14, -806, font, 11, 0.85, 0.75, 0.80) - CreateDesc(abSection, "使用 /nui layout 或右键聊天框 Nanami 标题进入布局模式调整动作条位置", 14, -826, font, 480) + -- Layout presets + CreateLabel(abSection, "布局预设:", 14, -908, font, 11, 0.85, 0.75, 0.80) + CreateDesc(abSection, + "快速切换动作条排列方式。选择预设会重置动作条位置并应用对应的排列。", + 14, -926, font, 480) + + local presetDefs = (SFrames.ActionBars and SFrames.ActionBars.PRESETS) or { + { id = 1, name = "经典", desc = "底部堆叠 + 右侧竖栏" }, + { id = 2, name = "宽屏", desc = "左4x3 + 底部堆叠 + 右4x3" }, + { id = 3, name = "堆叠", desc = "全部堆叠于底部中央" }, + } + + local presetBtnW = 155 + local presetBtnGap = 8 + for pi = 1, 3 do + local pdef = presetDefs[pi] + local pLabel = pdef and pdef.name or ("方案" .. pi) + local pId = pi + local px = 14 + (pi - 1) * (presetBtnW + presetBtnGap) + CreateButton(abSection, pLabel, px, -946, presetBtnW, 24, function() + if SFrames.ActionBars and SFrames.ActionBars.ApplyPreset then + SFrames.ActionBars:ApplyPreset(pId) + RefreshAB() + end + end) + end + + for pi = 1, 3 do + local pdef = presetDefs[pi] + local pdesc = pdef and pdef.desc or "" + local px = 14 + (pi - 1) * (presetBtnW + presetBtnGap) + CreateDesc(abSection, pdesc, px, -976, font, presetBtnW) + end + + CreateLabel(abSection, "动作条位置:", 14, -1000, font, 11, 0.85, 0.75, 0.80) + CreateDesc(abSection, "使用 /nui layout 或右键聊天框 Nanami 标题进入布局模式调整动作条位置", 14, -1018, font, 480) CreateLabel(abSection, "提示:启用/禁用动作条接管需要 /reload 才能生效。", - 14, -846, font, 10, 1, 0.92, 0.38) + 14, -1038, font, 10, 1, 0.92, 0.38) + + -- ── 额外动作条 ────────────────────────────────────────────── + local function RefreshEB() + if SFrames.ExtraBar then + local edb = SFrames.ExtraBar:GetDB() + if edb.enable then + if not SFrames.ExtraBar.holder then + SFrames.ExtraBar:Enable() + else + SFrames.ExtraBar:ApplyConfig() + end + else + SFrames.ExtraBar:Disable() + end + end + end + + local ebSection = CreateSection(root, "额外动作条", 8, -1056, 520, 310, font) + + table.insert(controls, CreateCheckBox(ebSection, + "启用额外动作条(独立面板,使用空闲动作槽位)", 12, -30, + function() return SFramesDB.ExtraBar.enable == true end, + function(checked) + SFramesDB.ExtraBar.enable = checked + if checked then + if SFrames.ExtraBar then SFrames.ExtraBar:Enable() end + else + if SFrames.ExtraBar then SFrames.ExtraBar:Disable() end + end + end, + function() end + )) + + table.insert(controls, CreateSlider(ebSection, "按钮总数", 14, -62, 150, 1, 48, 1, + function() return SFramesDB.ExtraBar.buttonCount or 12 end, + function(value) SFramesDB.ExtraBar.buttonCount = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + function() RefreshEB() end + )) + + table.insert(controls, CreateSlider(ebSection, "每行按钮数", 180, -62, 150, 1, 12, 1, + function() return SFramesDB.ExtraBar.perRow or 12 end, + function(value) SFramesDB.ExtraBar.perRow = value end, + function(v) local n = math.floor(v + 0.5); if n == 12 then return "12 (满)" else return tostring(n) end end, + function() RefreshEB() end + )) + + table.insert(controls, CreateSlider(ebSection, "按钮尺寸", 14, -102, 150, 20, 60, 1, + function() return SFramesDB.ExtraBar.buttonSize or 36 end, + function(value) SFramesDB.ExtraBar.buttonSize = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + function() RefreshEB() end + )) + + table.insert(controls, CreateSlider(ebSection, "按钮间距", 180, -102, 150, 0, 10, 1, + function() return SFramesDB.ExtraBar.buttonGap or 2 end, + function(value) SFramesDB.ExtraBar.buttonGap = value end, + function(v) return tostring(math.floor(v + 0.5)) end, + function() RefreshEB() end + )) + + table.insert(controls, CreateSlider(ebSection, "对齐方式", 14, -142, 150, 1, 3, 1, + function() + local a = SFramesDB.ExtraBar.align or "center" + if a == "left" then return 1 elseif a == "right" then return 3 else return 2 end + end, + function(value) + local n = math.floor(value + 0.5) + if n == 1 then SFramesDB.ExtraBar.align = "left" + elseif n == 3 then SFramesDB.ExtraBar.align = "right" + else SFramesDB.ExtraBar.align = "center" end + end, + function(v) local n = math.floor(v + 0.5); if n == 1 then return "左对齐" elseif n == 3 then return "右对齐" else return "居中" end end, + function() RefreshEB() end + )) + + table.insert(controls, CreateSlider(ebSection, "透明度", 180, -142, 150, 0.1, 1.0, 0.05, + function() return SFramesDB.ExtraBar.alpha or 1.0 end, + function(value) SFramesDB.ExtraBar.alpha = value end, + function(v) return string.format("%.0f%%", v * 100) end, + function() RefreshEB() end + )) + + table.insert(controls, CreateSlider(ebSection, "起始槽位(页7=73)", 14, -182, 320, 73, 109, 1, + function() return SFramesDB.ExtraBar.startSlot or 73 end, + function(value) SFramesDB.ExtraBar.startSlot = value end, + function(v) local n = math.floor(v + 0.5); local pg = math.floor((n - 1) / 12) + 1; local idx = math.fmod(n - 1, 12) + 1; return n .. " (页" .. pg .. "-" .. idx .. ")" end, + function() RefreshEB() end + )) + + table.insert(controls, CreateCheckBox(ebSection, + "显示快捷键", 12, -220, + function() return SFramesDB.ExtraBar.showHotkey ~= false end, + function(checked) SFramesDB.ExtraBar.showHotkey = checked end, + function() RefreshEB() end + )) + + table.insert(controls, CreateCheckBox(ebSection, + "显示堆叠数", 200, -220, + function() return SFramesDB.ExtraBar.showCount ~= false end, + function(checked) SFramesDB.ExtraBar.showCount = checked end, + function() RefreshEB() end + )) + + table.insert(controls, CreateCheckBox(ebSection, + "按钮圆角", 12, -248, + function() return SFramesDB.ExtraBar.buttonRounded == true end, + function(checked) SFramesDB.ExtraBar.buttonRounded = checked end, + function() RefreshEB() end + )) + + table.insert(controls, CreateCheckBox(ebSection, + "按钮内阴影", 200, -248, + function() return SFramesDB.ExtraBar.buttonInnerShadow == true end, + function(checked) SFramesDB.ExtraBar.buttonInnerShadow = checked end, + function() RefreshEB() end + )) + + CreateDesc(ebSection, + "使用布局模式 (/nui layout) 调整额外动作条位置。默认槽位 73-84 (页7) 对所有职业安全。", + 14, -280, font, 480) uiScroll:UpdateRange() self.actionBarControls = controls @@ -3363,10 +4327,98 @@ function SFrames.ConfigUI:BuildBuffPage() self.buffScroll = uiScroll end +function SFrames.ConfigUI:ShowFramesSubPage(mode) + local subMode = mode + if subMode ~= "player" and subMode ~= "target" and subMode ~= "focus" and subMode ~= "party" and subMode ~= "raid" then + subMode = self.activeFrameSubPage or "player" + end + + self.activeFrameSubPage = subMode + self:EnsurePage(subMode) + + self.playerPage:Hide() + self.targetPage:Hide() + self.focusPage:Hide() + self.partyPage:Hide() + self.raidPage:Hide() + + local allSubTabs = { self.framesPlayerTab, self.framesTargetTab, self.framesFocusTab, self.framesPartyTab, self.framesRaidTab } + for _, tab in ipairs(allSubTabs) do + if tab then + tab.sfSoftActive = false + tab:Enable() + tab:RefreshVisual() + end + end + + local activeTab, controls, scroll, titleSuffix + if subMode == "target" then + self.targetPage:Show() + activeTab = self.framesTargetTab + controls = self.targetControls + scroll = self.targetScroll + titleSuffix = "目标框体" + elseif subMode == "focus" then + self.focusPage:Show() + activeTab = self.framesFocusTab + controls = self.focusControls + scroll = self.focusScroll + titleSuffix = "焦点框体" + elseif subMode == "party" then + self.partyPage:Show() + activeTab = self.framesPartyTab + controls = self.partyControls + scroll = self.partyScroll + titleSuffix = "小队框体" + elseif subMode == "raid" then + self.raidPage:Show() + activeTab = self.framesRaidTab + controls = self.raidControls + scroll = self.raidScroll + titleSuffix = "团队框体" + else + self.playerPage:Show() + activeTab = self.framesPlayerTab + controls = self.playerControls + scroll = self.playerScroll + titleSuffix = "玩家框体" + end + + if activeTab then + activeTab.sfSoftActive = true + activeTab:Disable() + activeTab:RefreshVisual() + end + + self.title:SetText("Nanami-UI 设置 - 框体设置 / " .. titleSuffix) + self:RefreshControls(controls) + if scroll and scroll.Reset then scroll:Reset() end +end + function SFrames.ConfigUI:ShowPage(mode) - self.activePage = mode + local requestedMode = mode or "ui" + local pageMode = requestedMode + if requestedMode == "player" or requestedMode == "target" or requestedMode == "focus" or requestedMode == "party" or requestedMode == "raid" then + pageMode = "frames" + end + + if requestedMode == "frames" then + requestedMode = self.activeFrameSubPage or (SFramesDB and SFramesDB.configLastFrameSubPage) or "player" + end + + if requestedMode == "player" or requestedMode == "target" or requestedMode == "focus" or requestedMode == "party" or requestedMode == "raid" then + if SFramesDB then + SFramesDB.configLastFrameSubPage = requestedMode + SFramesDB.configLastPage = requestedMode + end + elseif SFramesDB then + SFramesDB.configLastPage = pageMode + end + + self.activePage = pageMode self.uiPage:Hide() + self.framesPage:Hide() self.playerPage:Hide() self.targetPage:Hide() self.focusPage:Hide() @@ -3382,22 +4434,22 @@ function SFrames.ConfigUI:ShowPage(mode) self.themePage:Hide() self.profilePage:Hide() - 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 } + local allTabs = { self.uiTab, self.framesTab, 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() tab:RefreshVisual() end - self:EnsurePage(mode or "ui") + self:EnsurePage(pageMode) + mode = pageMode - if mode == "player" then - self.playerPage:Show() - self.playerTab.sfSoftActive = true - self.playerTab:Disable() - self.playerTab:RefreshVisual() - self.title:SetText("Nanami-UI 设置 - 玩家框体") - self:RefreshControls(self.playerControls) + if mode == "frames" then + self.framesPage:Show() + self.framesTab.sfSoftActive = true + self.framesTab:Disable() + self.framesTab:RefreshVisual() + self:ShowFramesSubPage(requestedMode) elseif mode == "target" then self.targetPage:Show() self.targetTab.sfSoftActive = true @@ -3547,17 +4599,18 @@ function SFrames.ConfigUI:EnsureFrame() panel:SetBackdropBorderColor(SOFT_THEME.panelBorder[1], SOFT_THEME.panelBorder[2], SOFT_THEME.panelBorder[3], SOFT_THEME.panelBorder[4]) end - local titleIco = SFrames:CreateIcon(panel, "logo", 18) - titleIco:SetDrawLayer("OVERLAY") - titleIco:SetPoint("TOP", panel, "TOP", -52, -14) - titleIco:SetVertexColor(SOFT_THEME.title[1], SOFT_THEME.title[2], SOFT_THEME.title[3]) - local title = panel:CreateFontString(nil, "OVERLAY") title:SetFont(font, 14, "OUTLINE") - title:SetPoint("LEFT", titleIco, "RIGHT", 5, 0) + title:SetPoint("TOP", panel, "TOP", 0, -14) + title:SetJustifyH("CENTER") title:SetTextColor(SOFT_THEME.title[1], SOFT_THEME.title[2], SOFT_THEME.title[3]) title:SetText("Nanami-UI 设置") + local titleIco = SFrames:CreateIcon(panel, "logo", 18) + titleIco:SetDrawLayer("OVERLAY") + titleIco:SetPoint("RIGHT", title, "LEFT", -5, 0) + titleIco:SetVertexColor(SOFT_THEME.title[1], SOFT_THEME.title[2], SOFT_THEME.title[3]) + local divider = panel:CreateTexture(nil, "ARTWORK") divider:SetWidth(1) divider:SetPoint("TOPLEFT", panel, "TOPLEFT", 120, -40) @@ -3600,14 +4653,24 @@ function SFrames.ConfigUI:EnsureFrame() StyleButton(tabUI) AddBtnIcon(tabUI, "settings", nil, "left") + local tabFrames = CreateFrame("Button", "SFramesConfigTabFrames", panel, "UIPanelButtonTemplate") + tabFrames:SetWidth(100) + tabFrames:SetHeight(28) + tabFrames:SetPoint("TOP", tabUI, "BOTTOM", 0, -4) + tabFrames:SetText("框体设置") + tabFrames:SetScript("OnClick", function() SFrames.ConfigUI:ShowPage("frames") end) + StyleButton(tabFrames) + AddBtnIcon(tabFrames, "character", nil, "left") + local tabPlayer = CreateFrame("Button", "SFramesConfigTabPlayer", panel, "UIPanelButtonTemplate") tabPlayer:SetWidth(100) tabPlayer:SetHeight(28) - tabPlayer:SetPoint("TOP", tabUI, "BOTTOM", 0, -4) + tabPlayer:SetPoint("TOP", tabFrames, "BOTTOM", 0, -4) tabPlayer:SetText("玩家框体") tabPlayer:SetScript("OnClick", function() SFrames.ConfigUI:ShowPage("player") end) StyleButton(tabPlayer) AddBtnIcon(tabPlayer, "character", nil, "left") + tabPlayer:Hide() local tabTarget = CreateFrame("Button", "SFramesConfigTabTarget", panel, "UIPanelButtonTemplate") tabTarget:SetWidth(100) @@ -3617,15 +4680,17 @@ function SFrames.ConfigUI:EnsureFrame() tabTarget:SetScript("OnClick", function() SFrames.ConfigUI:ShowPage("target") end) StyleButton(tabTarget) AddBtnIcon(tabTarget, "search", nil, "left") + tabTarget:Hide() local tabFocus = CreateFrame("Button", "SFramesConfigTabFocus", panel, "UIPanelButtonTemplate") tabFocus:SetWidth(100) tabFocus:SetHeight(28) - tabFocus:SetPoint("TOP", tabTarget, "BOTTOM", 0, -4) + tabFocus:SetPoint("TOP", tabFrames, "BOTTOM", 0, -4) tabFocus:SetText("焦点框体") tabFocus:SetScript("OnClick", function() SFrames.ConfigUI:ShowPage("focus") end) StyleButton(tabFocus) AddBtnIcon(tabFocus, "search", nil, "left") + tabFocus:Hide() local tabParty = CreateFrame("Button", "SFramesConfigTabParty", panel, "UIPanelButtonTemplate") tabParty:SetWidth(100) @@ -3635,6 +4700,7 @@ function SFrames.ConfigUI:EnsureFrame() tabParty:SetScript("OnClick", function() SFrames.ConfigUI:ShowPage("party") end) StyleButton(tabParty) AddBtnIcon(tabParty, "friends", nil, "left") + tabParty:Hide() local tabRaid = CreateFrame("Button", "SFramesConfigTabRaid", panel, "UIPanelButtonTemplate") tabRaid:SetWidth(100) @@ -3644,11 +4710,12 @@ function SFrames.ConfigUI:EnsureFrame() tabRaid:SetScript("OnClick", function() SFrames.ConfigUI:ShowPage("raid") end) StyleButton(tabRaid) AddBtnIcon(tabRaid, "party", nil, "left") + tabRaid:Hide() local tabBags = CreateFrame("Button", "SFramesConfigTabBags", panel, "UIPanelButtonTemplate") tabBags:SetWidth(100) tabBags:SetHeight(28) - tabBags:SetPoint("TOP", tabRaid, "BOTTOM", 0, -4) + tabBags:SetPoint("TOP", tabFrames, "BOTTOM", 0, -4) tabBags:SetText("背包设置") tabBags:SetScript("OnClick", function() SFrames.ConfigUI:ShowPage("bags") end) StyleButton(tabBags) @@ -3734,23 +4801,75 @@ function SFrames.ConfigUI:EnsureFrame() local uiPage = CreateFrame("Frame", "SFramesConfigUIPage", content) uiPage:SetAllPoints(content) - local playerPage = CreateFrame("Frame", "SFramesConfigPlayerPage", content) - playerPage:SetAllPoints(content) + local framesPage = CreateFrame("Frame", "SFramesConfigFramesPage", content) + framesPage:SetAllPoints(content) - local targetPage = CreateFrame("Frame", "SFramesConfigTargetPage", content) - targetPage:SetAllPoints(content) + local framesTabs = CreateFrame("Frame", "SFramesConfigFramesTabs", framesPage) + framesTabs:SetWidth(548) + framesTabs:SetHeight(30) + framesTabs:SetPoint("TOPLEFT", framesPage, "TOPLEFT", 4, -4) - local focusPage = CreateFrame("Frame", "SFramesConfigFocusPage", content) - focusPage:SetAllPoints(content) + local framesPlayerTab = CreateFrame("Button", "SFramesConfigFramesPlayerTab", framesTabs, "UIPanelButtonTemplate") + framesPlayerTab:SetWidth(96) + framesPlayerTab:SetHeight(24) + framesPlayerTab:SetPoint("TOPLEFT", framesTabs, "TOPLEFT", 0, 0) + framesPlayerTab:SetText("玩家") + framesPlayerTab:SetScript("OnClick", function() SFrames.ConfigUI:ShowFramesSubPage("player") end) + StyleButton(framesPlayerTab) + + local framesTargetTab = CreateFrame("Button", "SFramesConfigFramesTargetTab", framesTabs, "UIPanelButtonTemplate") + framesTargetTab:SetWidth(96) + framesTargetTab:SetHeight(24) + framesTargetTab:SetPoint("LEFT", framesPlayerTab, "RIGHT", 6, 0) + framesTargetTab:SetText("目标") + framesTargetTab:SetScript("OnClick", function() SFrames.ConfigUI:ShowFramesSubPage("target") end) + StyleButton(framesTargetTab) + + local framesFocusTab = CreateFrame("Button", "SFramesConfigFramesFocusTab", framesTabs, "UIPanelButtonTemplate") + framesFocusTab:SetWidth(96) + framesFocusTab:SetHeight(24) + framesFocusTab:SetPoint("LEFT", framesTargetTab, "RIGHT", 6, 0) + framesFocusTab:SetText("焦点") + framesFocusTab:SetScript("OnClick", function() SFrames.ConfigUI:ShowFramesSubPage("focus") end) + StyleButton(framesFocusTab) + + local framesPartyTab = CreateFrame("Button", "SFramesConfigFramesPartyTab", framesTabs, "UIPanelButtonTemplate") + framesPartyTab:SetWidth(96) + framesPartyTab:SetHeight(24) + framesPartyTab:SetPoint("LEFT", framesFocusTab, "RIGHT", 6, 0) + framesPartyTab:SetText("小队") + framesPartyTab:SetScript("OnClick", function() SFrames.ConfigUI:ShowFramesSubPage("party") end) + StyleButton(framesPartyTab) + + local framesRaidTab = CreateFrame("Button", "SFramesConfigFramesRaidTab", framesTabs, "UIPanelButtonTemplate") + framesRaidTab:SetWidth(96) + framesRaidTab:SetHeight(24) + framesRaidTab:SetPoint("LEFT", framesPartyTab, "RIGHT", 6, 0) + framesRaidTab:SetText("团队") + framesRaidTab:SetScript("OnClick", function() SFrames.ConfigUI:ShowFramesSubPage("raid") end) + StyleButton(framesRaidTab) + + local framesContent = CreateFrame("Frame", "SFramesConfigFramesContent", framesPage) + framesContent:SetPoint("TOPLEFT", framesTabs, "BOTTOMLEFT", 0, -6) + framesContent:SetPoint("BOTTOMRIGHT", framesPage, "BOTTOMRIGHT", 0, 0) + + local playerPage = CreateFrame("Frame", "SFramesConfigPlayerPage", framesContent) + playerPage:SetAllPoints(framesContent) + + local targetPage = CreateFrame("Frame", "SFramesConfigTargetPage", framesContent) + targetPage:SetAllPoints(framesContent) + + local focusPage = CreateFrame("Frame", "SFramesConfigFocusPage", framesContent) + focusPage:SetAllPoints(framesContent) local bagPage = CreateFrame("Frame", "SFramesConfigBagPage", content) bagPage:SetAllPoints(content) - local raidPage = CreateFrame("Frame", "SFramesConfigRaidPage", content) - raidPage:SetAllPoints(content) + local raidPage = CreateFrame("Frame", "SFramesConfigRaidPage", framesContent) + raidPage:SetAllPoints(framesContent) - local partyPage = CreateFrame("Frame", "SFramesConfigPartyPage", content) - partyPage:SetAllPoints(content) + local partyPage = CreateFrame("Frame", "SFramesConfigPartyPage", framesContent) + partyPage:SetAllPoints(framesContent) local charPage = CreateFrame("Frame", "SFramesConfigCharPage", content) charPage:SetAllPoints(content) @@ -3779,6 +4898,7 @@ function SFrames.ConfigUI:EnsureFrame() self.frame = panel self.title = title self.uiTab = tabUI + self.framesTab = tabFrames self.playerTab = tabPlayer self.targetTab = tabTarget self.focusTab = tabFocus @@ -3795,6 +4915,7 @@ function SFrames.ConfigUI:EnsureFrame() self.profileTab = tabProfile self.content = content self.uiPage = uiPage + self.framesPage = framesPage self.playerPage = playerPage self.targetPage = targetPage self.focusPage = focusPage @@ -3809,6 +4930,12 @@ function SFrames.ConfigUI:EnsureFrame() self.personalizePage = personalizePage self.themePage = themePage self.profilePage = profilePage + self.framesPlayerTab = framesPlayerTab + self.framesTargetTab = framesTargetTab + self.framesFocusTab = framesFocusTab + self.framesPartyTab = framesPartyTab + self.framesRaidTab = framesRaidTab + self.activeFrameSubPage = "player" local btnSaveReload = CreateFrame("Button", "SFramesConfigSaveReloadBtn", panel, "UIPanelButtonTemplate") btnSaveReload:SetWidth(100) @@ -3836,47 +4963,11 @@ function SFrames.ConfigUI:BuildPersonalizePage() local page = self.personalizePage local controls = {} - local personalizeScroll = CreateScrollArea(page, 4, -4, 548, 458, 960) + local personalizeScroll = CreateScrollArea(page, 4, -4, 548, 458, 950) local root = personalizeScroll.child - -- ── 升级训练师提醒 ────────────────────────────────────────── - local trainerSection = CreateSection(root, "升级技能提醒", 8, -8, 520, 100, font) - - table.insert(controls, CreateCheckBox(trainerSection, - "升级时提醒可学习的新技能", 14, -34, - function() return SFramesDB.trainerReminder ~= false end, - function(checked) SFramesDB.trainerReminder = checked end - )) - CreateDesc(trainerSection, "升级后如果有新的职业技能可学习,将在屏幕和聊天窗口显示技能列表提醒", 36, -50, font, 340) - - CreateButton(trainerSection, "模拟提醒", 370, -34, 120, 22, function() - if SFrames.Player and SFrames.Player.ShowTrainerReminder then - local testLevel = UnitLevel("player") - if mod(testLevel, 2) ~= 0 then testLevel = testLevel + 1 end - local _, classEn = UnitClass("player") - local cachedSkills = SFramesDB and SFramesDB.trainerCache and SFramesDB.trainerCache[classEn] - local staticSkills = SFrames.ClassSkillData and SFrames.ClassSkillData[classEn] - local function hasData(lv) - if cachedSkills then - if cachedSkills[lv] then return true end - if lv > 1 and cachedSkills[lv - 1] then return true end - end - if staticSkills and staticSkills[lv] then return true end - return false - end - if not hasData(testLevel) then - while testLevel <= 60 and not hasData(testLevel) do - testLevel = testLevel + 2 - end - if testLevel > 60 then testLevel = 60 end - end - SFrames.Player:ShowTrainerReminder(testLevel) - end - end) - CreateDesc(trainerSection, "点击按钮预览升级提醒效果", 392, -55, font, 100) - -- ── 拾取窗口 ──────────────────────────────────────────────── - local lootOptSection = CreateSection(root, "拾取窗口", 8, -118, 520, 56, font) + local lootOptSection = CreateSection(root, "拾取窗口", 8, -8, 520, 56, font) table.insert(controls, CreateCheckBox(lootOptSection, "拾取窗口跟随鼠标", 14, -30, @@ -3892,7 +4983,7 @@ function SFrames.ConfigUI:BuildPersonalizePage() CreateDesc(lootOptSection, "勾选后拾取窗口显示在鼠标附近,否则显示在固定位置", 36, -46, font) -- ── 鼠标提示框 ──────────────────────────────────────────────── - local tooltipSection = CreateSection(root, "鼠标提示框", 8, -184, 520, 180, font) + local tooltipSection = CreateSection(root, "鼠标提示框", 8, -74, 520, 180, font) CreateDesc(tooltipSection, "选择游戏提示框的显示位置方式(三选一)", 14, -28, font) local function RefreshTooltipMode() @@ -3944,7 +5035,7 @@ function SFrames.ConfigUI:BuildPersonalizePage() -- ── AFK 待机动画 ────────────────────────────────────────────── -- ── 血条材质选择 ────────────────────────────────────────── - local barTexSection = CreateSection(root, "血条材质", 8, -374, 520, 120, font) + local barTexSection = CreateSection(root, "血条材质", 8, -264, 520, 120, font) CreateDesc(barTexSection, "选择单位框架中血条和能量条使用的材质。修改后需要 /reload 生效。", 14, -30, font, 490) @@ -4042,7 +5133,118 @@ function SFrames.ConfigUI:BuildPersonalizePage() end end - local afkSection = CreateSection(root, "AFK 待机动画", 8, -504, 520, 146, font) + -- ── 框体样式预设 ────────────────────────────────────────── + local styleSection = CreateSection(root, "框体样式", 8, -394, 520, 90, font) + + CreateDesc(styleSection, "切换单位框体的整体外观风格。即时生效,无需 /reload。", 14, -30, font, 490) + + local STYLE_PRESETS = { + { key = "classic", label = "经典(默认)" }, + { key = "gradient", label = "简约渐变" }, + } + + local styleBtnW = 120 + local styleBtnH = 28 + local styleBtnGap = 8 + local styleBtnStartX = 14 + local styleBtnStartY = -52 + local styleBtns = {} + + for idx, preset in ipairs(STYLE_PRESETS) do + local x = styleBtnStartX + (idx - 1) * (styleBtnW + styleBtnGap) + local btn = CreateFrame("Button", NextWidgetName("StylePreset"), styleSection) + btn:SetWidth(styleBtnW) + btn:SetHeight(styleBtnH) + btn:SetPoint("TOPLEFT", styleSection, "TOPLEFT", x, styleBtnStartY) + + 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 label = btn:CreateFontString(nil, "OVERLAY") + label:SetFont(font, 10, "OUTLINE") + label:SetPoint("CENTER", btn, "CENTER", 0, 0) + label:SetText(preset.label) + label:SetTextColor(0.85, 0.85, 0.85) + + btn.styleKey = preset.key + btn.styleLabel = preset.label + + btn:SetScript("OnClick", function() + if not SFramesDB then SFramesDB = {} end + SFramesDB.frameStylePreset = this.styleKey + -- Gradient mode: default enable rainbow bar + if this.styleKey == "gradient" then + SFramesDB.powerRainbow = true + end + -- Update button highlights + for _, b in ipairs(styleBtns) do + if b.styleKey == this.styleKey then + b:SetBackdropBorderColor(1, 0.85, 0.6, 1) + else + b:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + end + end + -- Apply to all unit frames immediately + if SFrames.Player and SFrames.Player.ApplyConfig then + local ok, err = pcall(function() SFrames.Player:ApplyConfig() end) + if not ok then DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] Player style error: " .. tostring(err) .. "|r") end + end + if SFrames.Target and SFrames.Target.ApplyConfig then + local ok, err = pcall(function() SFrames.Target:ApplyConfig() end) + if not ok then DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] Target style error: " .. tostring(err) .. "|r") end + end + if SFrames.Party and SFrames.Party.ApplyConfig then + local ok, err = pcall(function() SFrames.Party:ApplyConfig() end) + if not ok then DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] Party style error: " .. tostring(err) .. "|r") end + end + if SFrames.Pet and SFrames.Pet.ApplyConfig then + local ok, err = pcall(function() SFrames.Pet:ApplyConfig() end) + if not ok then DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] Pet style error: " .. tostring(err) .. "|r") end + end + if SFrames.Raid and SFrames.Raid.ApplyConfig then + local ok, err = pcall(function() SFrames.Raid:ApplyConfig() end) + if not ok then DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] Raid style error: " .. tostring(err) .. "|r") end + end + if SFrames.ToT and SFrames.ToT.ApplyConfig then + local ok, err = pcall(function() SFrames.ToT:ApplyConfig() end) + if not ok then DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] ToT style error: " .. tostring(err) .. "|r") end + end + if SFrames.Focus and SFrames.Focus.ApplySettings then + local ok, err = pcall(function() SFrames.Focus:ApplySettings() end) + if not ok then DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami] Focus style error: " .. tostring(err) .. "|r") end + end + SFrames:Print("框体样式已切换为: " .. this.styleLabel) + end) + btn:SetScript("OnEnter", function() + this:SetBackdropColor(0.15, 0.12, 0.18, 0.95) + end) + btn:SetScript("OnLeave", function() + this:SetBackdropColor(0.08, 0.08, 0.10, 0.9) + local cur = (SFramesDB and SFramesDB.frameStylePreset) or "classic" + if this.styleKey == cur then + this:SetBackdropBorderColor(1, 0.85, 0.6, 1) + else + this:SetBackdropBorderColor(0.3, 0.3, 0.35, 1) + end + end) + + table.insert(styleBtns, btn) + end + + local curStyle = (SFramesDB and SFramesDB.frameStylePreset) or "classic" + for _, b in ipairs(styleBtns) do + if b.styleKey == curStyle then + b:SetBackdropBorderColor(1, 0.85, 0.6, 1) + end + end + + local afkSection = CreateSection(root, "AFK 待机动画", 8, -494, 520, 146, font) table.insert(controls, CreateCheckBox(afkSection, "启用 AFK 待机画面", 14, -34, @@ -4074,7 +5276,7 @@ function SFrames.ConfigUI:BuildPersonalizePage() end) -- ── 字体选择 ────────────────────────────────────────────────── - local fontSection = CreateSection(root, "全局字体", 8, -660, 520, 160, font) + local fontSection = CreateSection(root, "全局字体", 8, -650, 520, 160, font) CreateDesc(fontSection, "选择 UI 全局使用的字体(聊天、框体、系统文字等),需 /reload 完全生效", 14, -28, font, 480) local FONT_BTN_W = 150 @@ -4146,6 +5348,22 @@ function SFrames.ConfigUI:BuildPersonalizePage() CreateLabel(fontSection, "提示:切换字体后需要 /reload 才能完全生效。", 14, -136, font, 10, 0.6, 0.6, 0.65) + -- ── 彩虹能量条 ────────────────────────────────────────────────── + local rainbowSection = CreateSection(root, "彩虹能量条", 8, -820, 520, 70, font) + table.insert(controls, CreateCheckBox(rainbowSection, + "启用彩虹色能量条", 14, -30, + function() return SFramesDB.powerRainbow == true end, + function(checked) + SFramesDB.powerRainbow = checked + if SFrames.Player then SFrames.Player:UpdatePower() end + if SFrames.Target then SFrames.Target:UpdatePower() end + if SFrames.Focus then SFrames.Focus:UpdatePower() end + if SFrames.Party then SFrames.Party:UpdateAll() end + if SFrames.Raid then SFrames.Raid:UpdateAll() end + end + )) + CreateDesc(rainbowSection, "所有框体(玩家/目标/焦点/队伍/团队)的能量条均使用彩虹材质", 36, -46, font) + personalizeScroll:UpdateRange() self.personalizeControls = controls self.personalizeScroll = personalizeScroll @@ -4909,6 +6127,7 @@ local function BuildSummaryText() 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.hoverTransparent ~= false)) table.insert(lines, "边框: " .. BoolStr(chat.showBorder)) table.insert(lines, "职业色边框: " .. BoolStr(chat.borderClassColor)) table.insert(lines, "显示玩家等级: " .. BoolStr(chat.showPlayerLevel ~= false)) @@ -5012,7 +6231,6 @@ local function BuildSummaryText() 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 @@ -5232,18 +6450,18 @@ end -------------------------------------------------------------------------------- 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 = "ui", label = "界面功能开关", keys = {"enableMerchant","enableQuestUI","enableQuestLogSkin","enableTrainer","enableSpellBook","enableTradeSkill","enableSocial","enableInspect","enableFlightMap","enablePetStable","enableMail","enableUnitFrames","enablePlayerFrame","enableTargetFrame","enablePartyFrame","enableChat","showLevel","classColorHealth","smoothBars","fontKey","frameStylePreset"} }, + { key = "player", label = "玩家框体", keys = {"playerFrameScale","playerFrameWidth","playerPortraitWidth","playerHealthHeight","playerPowerHeight","playerPowerWidth","playerPowerOffsetX","playerPowerOffsetY","playerPowerOnTop","playerPortraitBgAlpha","playerShowClass","playerShowClassIcon","playerShowPortrait","playerFrameAlpha","playerBgAlpha","playerNameFontSize","playerValueFontSize","playerHealthFontSize","playerPowerFontSize","playerHealthTexture","playerPowerTexture","playerNameFontKey","playerHealthFontKey","playerPowerFontKey","castbarStandalone","castbarRainbow","powerRainbow","petHealthTexture","petPowerTexture"} }, + { key = "target", label = "目标框体", keys = {"targetFrameScale","targetFrameWidth","targetPortraitWidth","targetHealthHeight","targetPowerHeight","targetPowerWidth","targetPowerOffsetX","targetPowerOffsetY","targetPowerOnTop","targetPortraitBgAlpha","targetShowClass","targetShowClassIcon","targetShowPortrait","targetFrameAlpha","targetBgAlpha","targetNameFontSize","targetValueFontSize","targetHealthFontSize","targetPowerFontSize","targetHealthTexture","targetPowerTexture","targetNameFontKey","targetHealthFontKey","targetPowerFontKey","targetDistanceEnabled","targetDistanceOnFrame","targetDistanceScale","targetDistanceFontSize","targetDistanceFontKey","totHealthTexture"} }, + { key = "focus", label = "焦点框体", keys = {"focusEnabled","focusFrameScale","focusFrameWidth","focusPortraitWidth","focusHealthHeight","focusPowerHeight","focusPowerWidth","focusPowerOffsetX","focusPowerOffsetY","focusPowerOnTop","focusPortraitBgAlpha","focusShowPortrait","focusShowCastBar","focusShowAuras","focusNameFontSize","focusValueFontSize","focusHealthFontSize","focusPowerFontSize","focusCastFontSize","focusDistanceFontSize","focusHealthTexture","focusPowerTexture","focusNameFontKey","focusHealthFontKey","focusPowerFontKey","focusCastFontKey","focusDistanceFontKey","focusBgAlpha"} }, + { key = "party", label = "小队框架", keys = {"partyLayout","partyFrameScale","partyFrameWidth","partyFrameHeight","partyPortraitWidth","partyHealthHeight","partyPowerHeight","partyPowerWidth","partyPowerOffsetX","partyPowerOffsetY","partyPowerOnTop","partyPortraitBgAlpha","partyHorizontalGap","partyVerticalGap","partyNameFontSize","partyValueFontSize","partyHealthFontSize","partyPowerFontSize","partyHealthTexture","partyPowerTexture","partyNameFontKey","partyHealthFontKey","partyPowerFontKey","partyShowBuffs","partyShowDebuffs","partyBgAlpha","partyPortrait3D"} }, + { key = "raid", label = "团队框架", keys = {"raidLayout","raidFrameScale","raidFrameWidth","raidFrameHeight","raidHealthHeight","raidPowerWidth","raidPowerHeight","raidPowerOffsetX","raidPowerOffsetY","raidPowerOnTop","raidHorizontalGap","raidVerticalGap","raidGroupGap","raidNameFontSize","raidValueFontSize","raidHealthFontSize","raidPowerFontSize","raidHealthTexture","raidPowerTexture","raidNameFontKey","raidHealthFontKey","raidPowerFontKey","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"} }, + chat_keys = {"width","height","scale","fontSize","bgAlpha","hoverTransparent","sidePadding","topPadding","bottomPadding","editBoxPosition","editBoxX","editBoxY"} }, { key = "chat_tabs", label = "聊天-标签/过滤", chat_keys = {"tabs","activeTab","nextTabId"} }, { key = "chat_translate",label = "聊天-AI翻译", @@ -5734,6 +6952,7 @@ function SFrames.ConfigUI:EnsurePage(mode) if self._pageBuilt[mode] then return end self._pageBuilt[mode] = true if mode == "ui" then self:BuildUIPage() + elseif mode == "frames" then return elseif mode == "player" then self:BuildPlayerPage() elseif mode == "target" then self:BuildTargetPage() elseif mode == "focus" then self:BuildFocusPage() @@ -5755,11 +6974,21 @@ function SFrames.ConfigUI:Build(mode) EnsureDB() self:EnsureFrame() - local page = string.lower(mode or "ui") - 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 } + local requested = string.lower(mode or "") + if requested == "" or requested == "ui" then + requested = ((SFramesDB and SFramesDB.configLastPage) or "ui") + end + + local page = string.lower(requested) + local validPages = { ui = true, frames = 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 + local activeBucket = page + if page == "player" or page == "target" or page == "focus" or page == "party" or page == "raid" then + activeBucket = "frames" + end + + if self.frame:IsShown() and self.activePage == activeBucket and (activeBucket ~= "frames" or self.activeFrameSubPage == page or page == "frames") then self.frame:Hide() return end diff --git a/ConsumableDB.lua b/ConsumableDB.lua new file mode 100644 index 0000000..7c46eb5 --- /dev/null +++ b/ConsumableDB.lua @@ -0,0 +1,198 @@ +-------------------------------------------------------------------------------- +-- Nanami-UI: ConsumableDB.lua +-- 食物药剂百科数据库(由导出表生成,请优先更新 Excel 后再重新生成) +-- Source: c:\Users\rucky\Downloads\WoW_Consumables_Database.xlsx +-- Stats : 6 roles / 9 categories / 101 entries +-------------------------------------------------------------------------------- +SFrames = SFrames or {} + +SFrames.ConsumableDB = { + generatedAt = "2026-04-03 23:45:41", + summary = { + roleCount = 6, + categoryCount = 9, + itemCount = 101, + }, + roleOrder = { + "坦克 (物理坦克)", + "坦克 (法系坦克)", + "法系输出", + "物理近战", + "物理远程", + "治疗", + }, + categoryOrder = { + "合剂", + "药剂", + "攻强", + "诅咒之地buff", + "赞达拉", + "武器", + "食物", + "酒", + "药水", + }, + groups = { + + -- 1. 坦克 · 物理坦克 + { + key = "tank_physical", + role = "坦克 (物理坦克)", + detail = "坦克 · 物理坦克", + color = { 0.40, 0.70, 1.00 }, + items = { + { cat="合剂", name="泰坦合剂", effect="1200生命上限", duration="2小时", id=0 }, + { cat="药剂", name="坚韧药剂", effect="120生命上限", duration="1小时", id=0 }, + { cat="药剂", name="极效巨魔之血药水", effect="20生命/5秒回血", duration="1小时", id=0 }, + { cat="药剂", name="猫鼬药剂", effect="25敏捷 / 2%暴击", duration="1小时", id=13452 }, + { cat="药剂", name="巨人药剂", effect="25力量", duration="1小时", id=9206 }, + { cat="药剂", name="坚甲药剂", effect="450护甲", duration="1小时", id=0 }, + { cat="攻强", name="魂能之击", effect="30力量", duration="10分钟", id=0 }, + { cat="攻强", name="冬泉火酒", effect="35攻强", duration="20分钟", id=0 }, + { cat="攻强", name="魂能之力", effect="40攻强", duration="10分钟", id=0 }, + { cat="诅咒之地buff", name="肺片鸡尾酒", effect="50耐力", duration="1小时", id=0 }, + { cat="诅咒之地buff", name="土狼肉块", effect="40力量", duration="1小时", id=0 }, + { cat="赞达拉", name="赞扎之魂", effect="50耐力 / 50精神", duration="2小时", id=0 }, + { cat="武器", name="元素磨刀石", effect="2%暴击", duration="30分钟", id=0 }, + { cat="武器", name="致密磨刀石", effect="8伤害", duration="30分钟", id=0 }, + { cat="食物", name="迪尔格的超美味奇美拉肉片", effect="25耐力", duration="15分钟", id=0 }, + { cat="食物", name="嫩狼肉排", effect="12耐力 / 12精神", duration="15分钟", id=0 }, + { cat="食物", name="蜘蛛肉肠", effect="12耐力 / 12精神", duration="15分钟", id=0 }, + { cat="酒", name="戈多克绿酒", effect="10耐力", duration="15分钟", id=0 }, + { cat="酒", name="黑标美味朗姆酒", effect="15耐力", duration="15分钟", id=0 }, + { cat="药水", name="极效治疗药水", effect="1050-1750生命值", duration="瞬发", id=0 }, + }, + }, + + -- 2. 坦克 · 法系坦克 + { + key = "tank_caster", + role = "坦克 (法系坦克)", + detail = "坦克 · 法系坦克", + color = { 1.00, 0.80, 0.20 }, + items = { + { cat="合剂", name="泰坦合剂", effect="1200生命上限", duration="2小时", id=0 }, + { cat="药剂", name="坚韧药剂", effect="120生命上限", duration="1小时", id=0 }, + { cat="药剂", name="特效巨魔之血药水", effect="20生命/5秒回血", duration="1小时", id=0 }, + { cat="药剂", name="强效奥法药剂", effect="35法术伤害", duration="1小时", id=0 }, + { cat="药剂", name="先知药剂", effect="18智力 / 18精神", duration="1小时", id=0 }, + { cat="药剂", name="魔血药水", effect="12法力/5秒", duration="1小时", id=0 }, + { cat="药剂", name="坚甲药剂", effect="450护甲", duration="1小时", id=0 }, + { cat="诅咒之地buff", name="肺片鸡尾酒", effect="50耐力", duration="1小时", id=0 }, + { cat="诅咒之地buff", name="脑皮层混合饮料", effect="25智力", duration="1小时", id=0 }, + { cat="赞达拉", name="赞扎之魂", effect="50耐力 / 50精神", duration="2小时", id=0 }, + { cat="武器", name="巫师之油", effect="24法术伤害", duration="30分钟", id=0 }, + { cat="武器", name="卓越巫师之油", effect="36法伤 / 1%暴击", duration="30分钟", id=0 }, + { cat="食物", name="迪尔格的超美味奇美拉肉片", effect="25耐力", duration="15分钟", id=0 }, + { cat="食物", name="嫩狼肉排", effect="12耐力 / 12精神", duration="15分钟", id=0 }, + { cat="食物", name="龙息红椒", effect="攻击几率喷火", duration="10分钟", id=0 }, + { cat="酒", name="戈多克绿酒", effect="10耐力", duration="15分钟", id=0 }, + { cat="酒", name="黑标美味朗姆酒", effect="15耐力", duration="15分钟", id=0 }, + { cat="药水", name="极效治疗药水", effect="1050-1750生命值", duration="瞬发", id=0 }, + { cat="药水", name="极效法力药水", effect="1350-2250法力值", duration="瞬发", id=0 }, + { cat="药水", name="有限无敌药水", effect="物理攻击免疫", duration="6秒", id=0 }, + }, + }, + + -- 3. 输出 · 法系输出 + { + key = "caster_dps", + role = "法系输出", + detail = "输出 · 法系输出", + color = { 0.65, 0.45, 1.00 }, + items = { + { cat="合剂", name="超级能量合剂", effect="150法术伤害", duration="2小时", id=0 }, + { cat="药剂", name="强效奥法药剂", effect="35法术伤害", duration="1小时", id=0 }, + { cat="药剂", name="强效火力药剂", effect="40火焰法术伤害", duration="1小时", id=0 }, + { cat="药剂", name="暗影之力药剂", effect="40暗影法术伤害", duration="1小时", id=0 }, + { cat="药剂", name="冰霜之力药剂", effect="15冰霜法术伤害", duration="1小时", id=0 }, + { cat="药剂", name="先知药剂", effect="18智力 / 18精神", duration="1小时", id=0 }, + { cat="药剂", name="魔血药水", effect="12法力/5秒", duration="1小时", id=0 }, + { cat="诅咒之地buff", name="脑皮层混合饮料", effect="25智力", duration="1小时", id=0 }, + { cat="赞达拉", name="赞扎之魂", effect="50耐力 / 50精神", duration="2小时", id=0 }, + { cat="武器", name="卓越巫师之油", effect="36法伤 / 1%暴击", duration="30分钟", id=0 }, + { cat="武器", name="巫师之油", effect="24法术伤害", duration="30分钟", id=0 }, + { cat="食物", name="洛恩塔姆薯块", effect="10智力", duration="10分钟", id=0 }, + { cat="食物", name="黑口鱼起司", effect="10法力/5秒", duration="10分钟", id=0 }, + { cat="食物", name="夜鳞鱼汤", effect="8法力/5秒", duration="10分钟", id=13931 }, + { cat="酒", name="戈多克绿酒", effect="10耐力", duration="15分钟", id=0 }, + { cat="酒", name="黑标美味朗姆酒", effect="15耐力", duration="15分钟", id=0 }, + { cat="药水", name="极效法力药水", effect="1350-2250法力值", duration="瞬发", id=0 }, + }, + }, + + -- 4. 输出 · 物理近战 + { + key = "melee_dps", + role = "物理近战", + detail = "输出 · 物理近战", + color = { 1.00, 0.55, 0.25 }, + items = { + { cat="合剂", name="泰坦合剂", effect="1200生命上限", duration="2小时", id=0 }, + { cat="药剂", name="猫鼬药剂", effect="25敏捷 / 2%暴击", duration="1小时", id=13452 }, + { cat="药剂", name="巨人药剂", effect="25力量", duration="1小时", id=9206 }, + { cat="攻强", name="魂能之击", effect="30力量", duration="10分钟", id=0 }, + { cat="攻强", name="冬泉火酒", effect="35攻强", duration="20分钟", id=0 }, + { cat="攻强", name="魂能之力", effect="40攻强", duration="10分钟", id=0 }, + { cat="诅咒之地buff", name="蝎粉", effect="25敏捷", duration="1小时", id=0 }, + { cat="诅咒之地buff", name="土狼肉块", effect="40力量", duration="1小时", id=0 }, + { cat="赞达拉", name="赞扎之魂", effect="50耐力 / 50精神", duration="2小时", id=0 }, + { cat="武器", name="元素磨刀石", effect="2%暴击", duration="30分钟", id=0 }, + { cat="武器", name="致密磨刀石", effect="8伤害", duration="30分钟", id=0 }, + { cat="食物", name="烤鱿鱼", effect="10敏捷", duration="10分钟", id=13928 }, + { cat="食物", name="沙漠肉丸子", effect="20力量", duration="15分钟", id=0 }, + { cat="食物", name="迪尔格的超美味奇美拉肉片", effect="25耐力", duration="15分钟", id=0 }, + { cat="酒", name="戈多克绿酒", effect="10耐力", duration="15分钟", id=0 }, + { cat="酒", name="黑标美味朗姆酒", effect="15耐力", duration="15分钟", id=0 }, + { cat="药水", name="极效治疗药水", effect="1050-1750生命值", duration="瞬发", id=0 }, + { cat="药水", name="自由行动药水", effect="免疫昏迷及移动限制", duration="30秒", id=5634 }, + }, + }, + + -- 5. 输出 · 物理远程 + { + key = "ranged_dps", + role = "物理远程", + detail = "输出 · 物理远程", + color = { 0.55, 0.88, 0.42 }, + items = { + { cat="合剂", name="泰坦合剂", effect="1200生命上限", duration="2小时", id=0 }, + { cat="药剂", name="猫鼬药剂", effect="25敏捷 / 2%暴击", duration="1小时", id=13452 }, + { cat="药剂", name="魔血药水", effect="12法力/5秒", duration="1小时", id=0 }, + { cat="药剂", name="先知药剂", effect="18智力 / 18精神", duration="1小时", id=0 }, + { cat="诅咒之地buff", name="蝎粉", effect="25敏捷", duration="1小时", id=0 }, + { cat="诅咒之地buff", name="脑皮层混合饮料", effect="25智力", duration="1小时", id=0 }, + { cat="赞达拉", name="赞扎之魂", effect="50耐力 / 50精神", duration="2小时", id=0 }, + { cat="武器", name="卓越法力之油", effect="14法力/5秒", duration="30分钟", id=0 }, + { cat="食物", name="烤鱿鱼", effect="10敏捷", duration="10分钟", id=13928 }, + { cat="食物", name="洛恩塔姆薯块", effect="10智力", duration="10分钟", id=0 }, + { cat="酒", name="戈多克绿酒", effect="10耐力", duration="15分钟", id=0 }, + { cat="酒", name="黑标美味朗姆酒", effect="15耐力", duration="15分钟", id=0 }, + { cat="药水", name="极效法力药水", effect="1350-2250法力值", duration="瞬发", id=0 }, + }, + }, + + -- 6. 治疗 + { + key = "healer", + role = "治疗", + detail = "治疗", + color = { 0.42, 1.00, 0.72 }, + items = { + { cat="合剂", name="精炼智慧合剂", effect="2000法力上限", duration="2小时", id=13511 }, + { cat="药剂", name="先知药剂", effect="18智力 / 18精神", duration="1小时", id=0 }, + { cat="药剂", name="魔血药水", effect="12法力/5秒", duration="1小时", id=0 }, + { cat="诅咒之地buff", name="脑皮层混合饮料", effect="25智力", duration="1小时", id=0 }, + { cat="赞达拉", name="赞扎之魂", effect="50耐力 / 50精神", duration="2小时", id=0 }, + { cat="武器", name="卓越法力之油", effect="14法力/5秒 / 提升治疗效果", duration="30分钟", id=0 }, + { cat="食物", name="洛恩塔姆薯块", effect="10智力", duration="10分钟", id=0 }, + { cat="食物", name="夜鳞鱼汤", effect="8法力/5秒", duration="10分钟", id=13931 }, + { cat="酒", name="戈多克绿酒", effect="10耐力", duration="15分钟", id=0 }, + { cat="酒", name="黑标美味朗姆酒", effect="15耐力", duration="15分钟", id=0 }, + { cat="药水", name="极效法力药水", effect="1350-2250法力值", duration="瞬发", id=0 }, + { cat="药水", name="黑暗符文", effect="回复900-1500法力", duration="瞬发", id=0 }, + { cat="药水", name="恶魔符文", effect="回复900-1500法力", duration="瞬发", id=0 }, + }, + }, + }, +} diff --git a/ConsumableUI.lua b/ConsumableUI.lua new file mode 100644 index 0000000..7da78df --- /dev/null +++ b/ConsumableUI.lua @@ -0,0 +1,778 @@ +-------------------------------------------------------------------------------- +-- Nanami-UI: ConsumableUI.lua +-- 食物药剂百科窗口 +-------------------------------------------------------------------------------- +SFrames = SFrames or {} +SFrames.ConsumableUI = SFrames.ConsumableUI or {} + +local CUI = SFrames.ConsumableUI + +local FRAME_W = 600 +local FRAME_H = 500 +local HEADER_H = 34 +local FILTER_H = 28 +local ROLE_H = 28 +local COL_H = 22 +local ROW_H = 20 +local MAX_ROWS = 18 +local SIDE_PAD = 10 + +local COL_CAT_W = 86 +local COL_NAME_W = 168 +local COL_EFF_W = 232 +local COL_DUR_W = 76 + +local S = { + frame = nil, + searchBox = nil, + searchText = "", + activeRole = "全部", + rows = {}, + tabBtns = {}, + displayList = {}, + filteredCount = 0, + scrollOffset = 0, + scrollMax = 0, + summaryFS = nil, + emptyFS = nil, + scrollBar = nil, +} + +local function GetTheme() + local base = { + panelBg = { 0.07, 0.07, 0.10, 0.96 }, + panelBorder = { 0.35, 0.30, 0.40, 1.00 }, + headerBg = { 0.10, 0.10, 0.14, 1.00 }, + text = { 0.92, 0.88, 0.95 }, + dimText = { 0.55, 0.50, 0.58 }, + gold = { 1.00, 0.82, 0.40 }, + slotBg = { 0.10, 0.10, 0.14, 0.90 }, + slotBorder = { 0.30, 0.28, 0.35, 0.80 }, + slotSelected = { 0.80, 0.60, 1.00 }, + divider = { 0.28, 0.26, 0.32, 0.80 }, + rowAlt = { 0.11, 0.10, 0.15, 0.60 }, + green = { 0.40, 0.90, 0.50 }, + } + + if SFrames and SFrames.ActiveTheme then + for key, value in pairs(SFrames.ActiveTheme) do + base[key] = value + end + end + + return base +end + +local function GetFont() + return SFrames and SFrames.GetFont and SFrames:GetFont() + or "Fonts\\ARIALN.TTF" +end + +local function ApplyBackdrop(frame, bg, border) + local theme = GetTheme() + local backdropBg = bg or theme.panelBg + local backdropBorder = border or theme.panelBorder + + frame: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 }, + }) + frame:SetBackdropColor(backdropBg[1], backdropBg[2], backdropBg[3], backdropBg[4] or 0.96) + frame:SetBackdropBorderColor( + backdropBorder[1], + backdropBorder[2], + backdropBorder[3], + backdropBorder[4] or 1.00 + ) +end + +local function SetDivider(parent, y, rightPadding) + local theme = GetTheme() + local divider = parent:CreateTexture(nil, "ARTWORK") + divider:SetTexture("Interface\\Buttons\\WHITE8X8") + divider:SetHeight(1) + divider:SetPoint("TOPLEFT", parent, "TOPLEFT", SIDE_PAD, y) + divider:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -(SIDE_PAD + (rightPadding or 0)), y) + divider:SetVertexColor(theme.divider[1], theme.divider[2], theme.divider[3], theme.divider[4] or 0.8) +end + +local function Utf8DisplayWidth(value) + local width = 0 + local index = 1 + local len = string.len(value or "") + while index <= len do + local byte = string.byte(value, index) + if byte < 128 then + width = width + 7 + index = index + 1 + elseif byte < 224 then + width = width + 7 + index = index + 2 + elseif byte < 240 then + width = width + 11 + index = index + 3 + else + width = width + 11 + index = index + 4 + end + end + return width +end + +local function GetDatabase() + local db = SFrames and SFrames.ConsumableDB + if not db then + return nil + end + + if db.groups then + return db + end + + return { + groups = db, + roleOrder = nil, + categoryOrder = nil, + summary = nil, + generatedAt = nil, + } +end + +local function GetGroups() + local db = GetDatabase() + return db and db.groups or nil +end + +local function CountAllItems(groups) + local count = 0 + for _, group in ipairs(groups or {}) do + count = count + table.getn(group.items or {}) + end + return count +end + +local function CountAllCategories(groups) + local seen = {} + local count = 0 + for _, group in ipairs(groups or {}) do + for _, item in ipairs(group.items or {}) do + if item.cat and item.cat ~= "" and not seen[item.cat] then + seen[item.cat] = true + count = count + 1 + end + end + end + return count +end + +local function BuildRoleList() + local db = GetDatabase() + local roles = { "全部" } + local seen = { ["全部"] = true } + + if not db or not db.groups then + return roles + end + + if db.roleOrder then + for _, role in ipairs(db.roleOrder) do + if role and role ~= "" and not seen[role] then + seen[role] = true + table.insert(roles, role) + end + end + end + + for _, group in ipairs(db.groups) do + if group.role and group.role ~= "" and not seen[group.role] then + seen[group.role] = true + table.insert(roles, group.role) + end + end + + return roles +end + +local function RoleMatches(group) + if S.activeRole == "全部" then + return true + end + return group.role == S.activeRole +end + +local function ItemMatches(item) + if S.searchText == "" then + return true + end + + local query = string.lower(S.searchText) + return string.find(string.lower(item.name or ""), query, 1, true) + or string.find(string.lower(item.effect or ""), query, 1, true) + or string.find(string.lower(item.cat or ""), query, 1, true) +end + +function CUI:RefreshSummary() + if not S.summaryFS then + return + end + + local db = GetDatabase() + local groups = db and db.groups or {} + local summary = db and db.summary or nil + local roleCount = (summary and summary.roleCount) or table.getn(groups) + local categoryCount = (summary and summary.categoryCount) or CountAllCategories(groups) + local totalCount = (summary and summary.itemCount) or CountAllItems(groups) + + local text = string.format("%d 定位 / %d 类别 / %d 条", roleCount, categoryCount, totalCount) + if S.activeRole ~= "全部" or S.searchText ~= "" then + text = string.format("当前筛出 %d 条,数据库共 %d 条", S.filteredCount or 0, totalCount) + end + + if db and db.generatedAt then + text = text .. " · 更新于 " .. db.generatedAt + end + + S.summaryFS:SetText(text) +end + +function CUI:Filter() + S.displayList = {} + S.filteredCount = 0 + + local groups = GetGroups() + if not groups then + S.scrollOffset = 0 + S.scrollMax = 0 + self:RefreshSummary() + return + end + + for _, group in ipairs(groups) do + if RoleMatches(group) then + local matched = {} + for _, item in ipairs(group.items or {}) do + if ItemMatches(item) then + table.insert(matched, item) + end + end + + if table.getn(matched) > 0 then + table.insert(S.displayList, { + isHeader = true, + group = group, + count = table.getn(matched), + }) + for _, item in ipairs(matched) do + table.insert(S.displayList, { + isHeader = false, + item = item, + }) + end + S.filteredCount = S.filteredCount + table.getn(matched) + end + end + end + + S.scrollOffset = 0 + S.scrollMax = math.max(0, table.getn(S.displayList) - MAX_ROWS) + self:RefreshSummary() +end + +local function UpdateScrollbar() + local scrollBar = S.scrollBar + if not scrollBar then + return + end + + local total = table.getn(S.displayList) + if total <= MAX_ROWS then + scrollBar:Hide() + return + end + + scrollBar:Show() + + local trackHeight = scrollBar:GetHeight() + local thumbHeight = math.max(20, math.floor(trackHeight * MAX_ROWS / total + 0.5)) + local percent = 0 + if S.scrollMax > 0 then + percent = S.scrollOffset / S.scrollMax + end + + local thumbY = math.floor((trackHeight - thumbHeight) * percent + 0.5) + if scrollBar.thumb then + scrollBar.thumb:SetHeight(thumbHeight) + scrollBar.thumb:SetPoint("TOP", scrollBar, "TOP", 0, -thumbY) + end +end + +function CUI:Render() + local theme = GetTheme() + local total = table.getn(S.displayList) + local visibleItemIndex = 0 + + for i = 1, MAX_ROWS do + local row = S.rows[i] + if not row then + break + end + + local displayIndex = S.scrollOffset + i + local entry = S.displayList[displayIndex] + if entry then + row:Show() + + if entry.isHeader then + local headerText = entry.group.detail or entry.group.role or "未命名分组" + if entry.count and entry.count > 0 then + headerText = string.format("%s (%d)", headerText, entry.count) + end + + row.catFS:SetText(headerText) + row.catFS:SetTextColor( + entry.group.color and entry.group.color[1] or theme.gold[1], + entry.group.color and entry.group.color[2] or theme.gold[2], + entry.group.color and entry.group.color[3] or theme.gold[3] + ) + row.catFS:SetWidth(COL_CAT_W + COL_NAME_W + COL_EFF_W + COL_DUR_W - 10) + row.catFS:SetPoint("TOPLEFT", row, "TOPLEFT", 6, -3) + + row.nameFS:SetText("") + row.effFS:SetText("") + row.durFS:SetText("") + + row:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, + tileSize = 0, + edgeSize = 0, + }) + row:SetBackdropColor(theme.headerBg[1], theme.headerBg[2], theme.headerBg[3], 0.85) + row.entry = nil + else + local item = entry.item + visibleItemIndex = visibleItemIndex + 1 + + row:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, + tileSize = 0, + edgeSize = 0, + }) + + if math.mod(visibleItemIndex, 2) == 0 then + row:SetBackdropColor(theme.rowAlt[1], theme.rowAlt[2], theme.rowAlt[3], theme.rowAlt[4] or 0.6) + else + row:SetBackdropColor(theme.slotBg[1], theme.slotBg[2], theme.slotBg[3], 0.0) + end + + row.catFS:SetWidth(COL_CAT_W - 8) + row.catFS:SetText(item.cat or "") + row.catFS:SetTextColor(theme.dimText[1], theme.dimText[2], theme.dimText[3]) + row.catFS:SetPoint("TOPLEFT", row, "TOPLEFT", 6, -3) + + if item.id and item.id > 0 then + row.nameFS:SetTextColor(theme.gold[1], theme.gold[2], theme.gold[3]) + else + row.nameFS:SetTextColor(theme.text[1], theme.text[2], theme.text[3]) + end + row.nameFS:SetText(item.name or "") + + row.effFS:SetText(item.effect or "") + row.effFS:SetTextColor(theme.text[1], theme.text[2], theme.text[3]) + + row.durFS:SetText(item.duration or "") + row.durFS:SetTextColor(theme.dimText[1], theme.dimText[2], theme.dimText[3]) + + row.entry = item + end + else + row:Hide() + row.entry = nil + end + end + + if S.emptyFS then + if total == 0 then + S.emptyFS:SetText("没有匹配到条目,试试更短的关键词或切回“全部”。") + S.emptyFS:Show() + else + S.emptyFS:Hide() + end + end + + UpdateScrollbar() +end + +local function RefreshTabs() + local theme = GetTheme() + for _, button in ipairs(S.tabBtns or {}) do + local active = (button.roleName == S.activeRole) + if active then + button:SetBackdropBorderColor( + theme.slotSelected[1], + theme.slotSelected[2], + theme.slotSelected[3], + 1.00 + ) + button.fs:SetTextColor( + theme.slotSelected[1], + theme.slotSelected[2], + theme.slotSelected[3] + ) + else + button:SetBackdropBorderColor( + theme.slotBorder[1], + theme.slotBorder[2], + theme.slotBorder[3], + theme.slotBorder[4] or 0.8 + ) + button.fs:SetTextColor(theme.dimText[1], theme.dimText[2], theme.dimText[3]) + end + end +end + +local function BuildColumnLabel(parent, text, x, width) + local font = GetFont() + local theme = GetTheme() + local fs = parent:CreateFontString(nil, "OVERLAY") + fs:SetFont(font, 10, "OUTLINE") + fs:SetTextColor(theme.dimText[1], theme.dimText[2], theme.dimText[3]) + fs:SetPoint("TOPLEFT", parent, "TOPLEFT", x, -4) + fs:SetWidth(width) + fs:SetJustifyH("LEFT") + fs:SetText(text) + return fs +end + +function CUI:Build() + local theme = GetTheme() + local font = GetFont() + + local frame = CreateFrame("Frame", "NanamiConsumableFrame", UIParent) + S.frame = frame + frame:SetWidth(FRAME_W) + frame:SetHeight(FRAME_H) + frame:SetPoint("CENTER", UIParent, "CENTER", 60, 20) + frame:SetFrameStrata("HIGH") + frame:SetToplevel(true) + frame:EnableMouse(true) + frame:SetMovable(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function() this:StartMoving() end) + frame:SetScript("OnDragStop", function() this:StopMovingOrSizing() end) + frame:EnableMouseWheel(true) + frame:SetScript("OnMouseWheel", function() + if arg1 > 0 then + S.scrollOffset = math.max(0, S.scrollOffset - 1) + else + S.scrollOffset = math.min(S.scrollMax, S.scrollOffset + 1) + end + CUI:Render() + end) + + ApplyBackdrop(frame) + tinsert(UISpecialFrames, "NanamiConsumableFrame") + + local header = CreateFrame("Frame", nil, frame) + header:SetPoint("TOPLEFT", 0, 0) + header:SetPoint("TOPRIGHT", 0, 0) + header:SetHeight(HEADER_H) + header:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) + header:SetBackdropColor(theme.headerBg[1], theme.headerBg[2], theme.headerBg[3], theme.headerBg[4] or 1.0) + + local titleFS = header:CreateFontString(nil, "OVERLAY") + titleFS:SetFont(font, 13, "OUTLINE") + titleFS:SetPoint("LEFT", header, "LEFT", SIDE_PAD + 4, 0) + titleFS:SetText("食物药剂百科") + titleFS:SetTextColor(theme.gold[1], theme.gold[2], theme.gold[3]) + + local closeBtn = CreateFrame("Button", nil, header) + closeBtn:SetWidth(20) + closeBtn:SetHeight(20) + closeBtn:SetPoint("TOPRIGHT", header, "TOPRIGHT", -8, -7) + + local closeTex = closeBtn:CreateTexture(nil, "ARTWORK") + closeTex:SetTexture("Interface\\AddOns\\Nanami-UI\\img\\icon") + closeTex:SetTexCoord(0.25, 0.375, 0, 0.125) + closeTex:SetAllPoints() + closeTex:SetVertexColor(theme.dimText[1], theme.dimText[2], theme.dimText[3]) + + closeBtn:SetScript("OnClick", function() frame:Hide() end) + closeBtn:SetScript("OnEnter", function() closeTex:SetVertexColor(1, 0.6, 0.7) end) + closeBtn:SetScript("OnLeave", function() + closeTex:SetVertexColor(theme.dimText[1], theme.dimText[2], theme.dimText[3]) + end) + + SetDivider(frame, -HEADER_H, 16) + + local filterY = -(HEADER_H + 6) + + local searchFrame = CreateFrame("Frame", nil, frame) + searchFrame:SetPoint("TOPLEFT", frame, "TOPLEFT", SIDE_PAD, filterY) + searchFrame:SetWidth(212) + searchFrame:SetHeight(FILTER_H - 4) + searchFrame:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, + tileSize = 0, + edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + searchFrame:SetBackdropColor(0.05, 0.05, 0.08, 0.90) + searchFrame:SetBackdropBorderColor(theme.panelBorder[1], theme.panelBorder[2], theme.panelBorder[3], 0.80) + + local searchBox = CreateFrame("EditBox", "NanamiConsumableSearch", searchFrame) + searchBox:SetPoint("TOPLEFT", searchFrame, "TOPLEFT", 4, -3) + searchBox:SetPoint("BOTTOMRIGHT", searchFrame, "BOTTOMRIGHT", -4, 3) + searchBox:SetFont(font, 11) + searchBox:SetAutoFocus(false) + searchBox:SetMaxLetters(40) + searchBox:EnableMouse(true) + S.searchBox = searchBox + + local hintFS = searchFrame:CreateFontString(nil, "OVERLAY") + hintFS:SetFont(font, 10, "OUTLINE") + hintFS:SetPoint("LEFT", searchFrame, "LEFT", 6, 0) + hintFS:SetText("搜索名称 / 效果 / 类别") + hintFS:SetTextColor(theme.dimText[1], theme.dimText[2], theme.dimText[3]) + + searchBox:SetScript("OnTextChanged", function() + local text = this:GetText() or "" + S.searchText = text + if text == "" then + hintFS:Show() + else + hintFS:Hide() + end + CUI:Filter() + CUI:Render() + end) + searchBox:SetScript("OnEditFocusGained", function() hintFS:Hide() end) + searchBox:SetScript("OnEditFocusLost", function() + if (this:GetText() or "") == "" then + hintFS:Show() + end + end) + searchBox:SetScript("OnEscapePressed", function() this:ClearFocus() end) + + local summaryFS = frame:CreateFontString(nil, "OVERLAY") + summaryFS:SetFont(font, 10, "OUTLINE") + summaryFS:SetPoint("LEFT", searchFrame, "RIGHT", 12, 0) + summaryFS:SetPoint("RIGHT", frame, "RIGHT", -36, filterY - 12) + summaryFS:SetJustifyH("RIGHT") + summaryFS:SetTextColor(theme.dimText[1], theme.dimText[2], theme.dimText[3]) + summaryFS:SetText("") + S.summaryFS = summaryFS + + local tabX = SIDE_PAD + local tabY = filterY - FILTER_H + S.tabBtns = {} + for _, roleName in ipairs(BuildRoleList()) do + local tabW = Utf8DisplayWidth(roleName) + 14 + if tabX + tabW > FRAME_W - SIDE_PAD then + tabX = SIDE_PAD + tabY = tabY - (ROLE_H - 4) - 2 + end + + local button = CreateFrame("Button", nil, frame) + button:SetWidth(tabW) + button:SetHeight(ROLE_H - 4) + button:SetPoint("TOPLEFT", frame, "TOPLEFT", tabX, tabY) + button:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, + tileSize = 0, + edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + button:SetBackdropColor(theme.slotBg[1], theme.slotBg[2], theme.slotBg[3], 0.80) + button:SetBackdropBorderColor(theme.slotBorder[1], theme.slotBorder[2], theme.slotBorder[3], 0.80) + + local buttonFS = button:CreateFontString(nil, "OVERLAY") + buttonFS:SetFont(font, 10, "OUTLINE") + buttonFS:SetPoint("CENTER", button, "CENTER", 0, 0) + buttonFS:SetText(roleName) + buttonFS:SetTextColor(theme.dimText[1], theme.dimText[2], theme.dimText[3]) + + button.fs = buttonFS + button.roleName = roleName + + button:SetScript("OnClick", function() + S.activeRole = this.roleName + RefreshTabs() + CUI:Filter() + CUI:Render() + end) + button:SetScript("OnEnter", function() + if this.roleName ~= S.activeRole then + this:SetBackdropBorderColor(theme.text[1], theme.text[2], theme.text[3], 0.60) + end + end) + button:SetScript("OnLeave", function() + if this.roleName ~= S.activeRole then + this:SetBackdropBorderColor(theme.slotBorder[1], theme.slotBorder[2], theme.slotBorder[3], 0.80) + end + end) + + table.insert(S.tabBtns, button) + tabX = tabX + tabW + 3 + end + + RefreshTabs() + + local listStartY = tabY - ROLE_H + 2 + SetDivider(frame, listStartY, 16) + + local colY = listStartY - 2 + local colHeader = CreateFrame("Frame", nil, frame) + colHeader:SetPoint("TOPLEFT", frame, "TOPLEFT", SIDE_PAD, colY) + colHeader:SetWidth(FRAME_W - SIDE_PAD * 2 - 14) + colHeader:SetHeight(COL_H) + colHeader:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) + colHeader:SetBackdropColor(theme.headerBg[1], theme.headerBg[2], theme.headerBg[3], 0.70) + + BuildColumnLabel(colHeader, "类别", 4, COL_CAT_W) + BuildColumnLabel(colHeader, "名称", COL_CAT_W + 4, COL_NAME_W) + BuildColumnLabel(colHeader, "效果", COL_CAT_W + COL_NAME_W + 4, COL_EFF_W) + BuildColumnLabel(colHeader, "时长", COL_CAT_W + COL_NAME_W + COL_EFF_W + 4, COL_DUR_W) + + local rowAreaY = colY - COL_H - 1 + local rowAreaH = FRAME_H - (-rowAreaY) - 8 + + local scrollWidth = 10 + local scrollBar = CreateFrame("Frame", nil, frame) + scrollBar:SetWidth(scrollWidth) + scrollBar:SetHeight(rowAreaH) + scrollBar:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -4, rowAreaY) + scrollBar:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) + scrollBar:SetBackdropColor(theme.slotBg[1], theme.slotBg[2], theme.slotBg[3], 0.40) + + local scrollThumb = scrollBar:CreateTexture(nil, "OVERLAY") + scrollThumb:SetTexture("Interface\\Buttons\\WHITE8X8") + scrollThumb:SetWidth(scrollWidth - 2) + scrollThumb:SetHeight(40) + scrollThumb:SetPoint("TOP", scrollBar, "TOP", 0, 0) + scrollThumb:SetVertexColor(theme.slotSelected[1], theme.slotSelected[2], theme.slotSelected[3], 0.60) + + scrollBar.thumb = scrollThumb + S.scrollBar = scrollBar + + local rowWidth = FRAME_W - SIDE_PAD * 2 - scrollWidth - 6 + for i = 1, MAX_ROWS do + local rowY = rowAreaY - (i - 1) * ROW_H + local row = CreateFrame("Button", nil, frame) + row:SetWidth(rowWidth) + row:SetHeight(ROW_H) + row:SetPoint("TOPLEFT", frame, "TOPLEFT", SIDE_PAD, rowY) + + local catFS = row:CreateFontString(nil, "OVERLAY") + catFS:SetFont(font, 9, "OUTLINE") + catFS:SetPoint("TOPLEFT", row, "TOPLEFT", 6, -3) + catFS:SetWidth(COL_CAT_W - 8) + catFS:SetJustifyH("LEFT") + row.catFS = catFS + + local nameFS = row:CreateFontString(nil, "OVERLAY") + nameFS:SetFont(font, 9, "OUTLINE") + nameFS:SetPoint("TOPLEFT", row, "TOPLEFT", COL_CAT_W + 4, -3) + nameFS:SetWidth(COL_NAME_W - 4) + nameFS:SetJustifyH("LEFT") + row.nameFS = nameFS + + local effFS = row:CreateFontString(nil, "OVERLAY") + effFS:SetFont(font, 9, "OUTLINE") + effFS:SetPoint("TOPLEFT", row, "TOPLEFT", COL_CAT_W + COL_NAME_W + 4, -3) + effFS:SetWidth(COL_EFF_W - 4) + effFS:SetJustifyH("LEFT") + row.effFS = effFS + + local durFS = row:CreateFontString(nil, "OVERLAY") + durFS:SetFont(font, 9, "OUTLINE") + durFS:SetPoint("TOPLEFT", row, "TOPLEFT", COL_CAT_W + COL_NAME_W + COL_EFF_W + 4, -3) + durFS:SetWidth(COL_DUR_W - 2) + durFS:SetJustifyH("LEFT") + row.durFS = durFS + + local highlight = row:CreateTexture(nil, "HIGHLIGHT") + highlight:SetTexture("Interface\\Buttons\\WHITE8X8") + highlight:SetAllPoints() + highlight:SetVertexColor(1, 1, 1, 0.07) + highlight:SetBlendMode("ADD") + + row:SetScript("OnEnter", function() + local entry = this.entry + if not entry then + return + end + + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + if entry.id and entry.id > 0 then + GameTooltip:SetHyperlink("item:" .. entry.id .. ":0:0:0") + GameTooltip:AddLine("Shift+点击可发送物品链接", 0.55, 0.55, 0.60) + else + GameTooltip:SetText(entry.name or "", 1, 0.82, 0.40) + if entry.effect and entry.effect ~= "" then + GameTooltip:AddLine(entry.effect, 0.80, 0.80, 0.80) + end + if entry.duration and entry.duration ~= "" then + GameTooltip:AddLine("持续时间: " .. entry.duration, 0.55, 0.55, 0.60) + end + end + GameTooltip:Show() + end) + row:SetScript("OnLeave", function() GameTooltip:Hide() end) + row:SetScript("OnClick", function() + local entry = this.entry + if not entry then + return + end + + if IsShiftKeyDown() and entry.id and entry.id > 0 then + local _, link = GetItemInfo(entry.id) + if link and ChatFrameEditBox then + ChatFrameEditBox:Show() + ChatFrameEditBox:Insert(link) + end + end + end) + + row:Hide() + S.rows[i] = row + end + + local emptyFS = frame:CreateFontString(nil, "OVERLAY") + emptyFS:SetFont(font, 11, "OUTLINE") + emptyFS:SetPoint("CENTER", frame, "CENTER", 0, -28) + emptyFS:SetTextColor(theme.dimText[1], theme.dimText[2], theme.dimText[3]) + emptyFS:SetText("") + emptyFS:Hide() + S.emptyFS = emptyFS +end + +function CUI:Toggle() + if S.frame and S.frame:IsShown() then + S.frame:Hide() + return + end + + if not S.frame then + self:Build() + end + + self:Filter() + self:Render() + S.frame:Show() +end + +function CUI:Hide() + if S.frame then + S.frame:Hide() + end +end diff --git a/Core.lua b/Core.lua index ba38fb7..4ed59cc 100644 --- a/Core.lua +++ b/Core.lua @@ -32,6 +32,40 @@ do end end + -- 保护 ComboFrame,防止 fadeInfo 为 nil 导致的报错 + -- (ComboFrame.lua:46: attempt to index local 'fadeInfo' (a nil value)) + if ComboFrame then + if not ComboFrame.fadeInfo then + ComboFrame.fadeInfo = {} + end + local origComboScript = ComboFrame.GetScript and ComboFrame:GetScript("OnUpdate") + if origComboScript then + ComboFrame:SetScript("OnUpdate", function(elapsed) + if ComboFrame.fadeInfo then + origComboScript(elapsed) + end + end) + end + end + if ComboFrame_Update then + local _orig_ComboUpdate = ComboFrame_Update + ComboFrame_Update = function() + if ComboFrame and not ComboFrame.fadeInfo then + ComboFrame.fadeInfo = {} + end + return _orig_ComboUpdate() + end + end + if ComboFrame_OnUpdate then + local _orig_ComboOnUpdate = ComboFrame_OnUpdate + ComboFrame_OnUpdate = function(elapsed) + if ComboFrame and not ComboFrame.fadeInfo then + ComboFrame.fadeInfo = {} + end + return _orig_ComboOnUpdate(elapsed) + end + end + local origOnUpdate = UIParent and UIParent.GetScript and UIParent:GetScript("OnUpdate") if origOnUpdate then UIParent:SetScript("OnUpdate", function() @@ -43,6 +77,11 @@ end BINDING_HEADER_NANAMI_UI = "Nanami-UI" BINDING_NAME_NANAMI_TOGGLE_NAV = "切换导航地图" +BINDING_HEADER_NANAMI_EXTRABAR = "Nanami-UI 额外动作条" +for _i = 1, 48 do + _G["BINDING_NAME_NANAMI_EXTRABAR" .. _i] = "额外动作条 按钮" .. _i +end + SFrames.eventFrame = CreateFrame("Frame", "SFramesEventFrame", UIParent) SFrames.events = {} @@ -140,6 +179,52 @@ function SFrames:Print(msg) DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r " .. tostring(msg)) end +local function IsBattlefieldMinimapVisible() + if BattlefieldMinimap and BattlefieldMinimap.IsVisible and BattlefieldMinimap:IsVisible() then + return true + end + if BattlefieldMinimapFrame and BattlefieldMinimapFrame ~= BattlefieldMinimap + and BattlefieldMinimapFrame.IsVisible and BattlefieldMinimapFrame:IsVisible() then + return true + end + return false +end + +function SFrames:CaptureBattlefieldMinimapState() + return IsBattlefieldMinimapVisible() +end + +function SFrames:RestoreBattlefieldMinimapState(wasVisible) + if wasVisible then + return + end + + local frames = { BattlefieldMinimap, BattlefieldMinimapFrame } + local hidden = {} + for i = 1, table.getn(frames) do + local frame = frames[i] + if frame and not hidden[frame] and frame.Hide and frame.IsVisible and frame:IsVisible() then + hidden[frame] = true + pcall(frame.Hide, frame) + end + end +end + +function SFrames:CallWithPreservedBattlefieldMinimap(func, a1, a2, a3, a4, a5, a6, a7, a8) + if type(func) ~= "function" then + return + end + + local state = self:CaptureBattlefieldMinimapState() + local results = { pcall(func, a1, a2, a3, a4, a5, a6, a7, a8) } + self:RestoreBattlefieldMinimapState(state) + + if not results[1] then + return nil, results[2] + end + return unpack(results, 2, table.getn(results)) +end + -- Addon Loaded Initializer SFrames:RegisterEvent("PLAYER_LOGIN", function() SFrames:Initialize() @@ -299,6 +384,10 @@ function SFrames:DoFullInitialize() SFrames.Tooltip:SetAlpha(0) SFrames.Tooltip:Hide() + if SFrames.AuraTracker and SFrames.AuraTracker.Initialize then + SFrames.AuraTracker:Initialize() + end + -- Phase 1: Critical modules (unit frames, action bars) — must load immediately if SFramesDB.enableUnitFrames ~= false then if SFramesDB.enablePlayerFrame ~= false then @@ -319,6 +408,10 @@ function SFrames:DoFullInitialize() SFrames.ActionBars:Initialize() end + if SFrames.ExtraBar and SFrames.ExtraBar.Initialize then + SFrames.ExtraBar:Initialize() + end + self:InitSlashCommands() -- Phase 2: Deferred modules — spread across multiple frames to avoid memory spike @@ -375,6 +468,13 @@ function SFrames:GetAuraTimeLeft(unit, index, isBuff) end end + if SFrames.AuraTracker and SFrames.AuraTracker.GetAuraTimeLeft then + local trackerTime = SFrames.AuraTracker:GetAuraTimeLeft(unit, isBuff and "buff" or "debuff", index) + if trackerTime and trackerTime > 0 then + return trackerTime + end + end + -- Nanami-Plates SpellDB: combat log + spell DB tracking (most accurate for debuffs) if not isBuff and NanamiPlates_SpellDB and NanamiPlates_SpellDB.UnitDebuff then local effect, rank, tex, stacks, dtype, duration, timeleft, isOwn = NanamiPlates_SpellDB:UnitDebuff(unit, index) @@ -420,6 +520,64 @@ function SFrames:FormatTime(seconds) end end +local POWER_RAINBOW_TEX = "Interface\\AddOns\\Nanami-UI\\img\\progress" + +function SFrames:UpdateRainbowBar(bar, power, maxPower, unit) + -- 彩虹条仅适用于法力(powerType 0),怒气/能量等跳过 + if unit and UnitPowerType(unit) ~= 0 then + if bar._rainbowActive then + if bar.rainbowTex then bar.rainbowTex:Hide() end + bar._rainbowActive = nil + end + return + end + if not (SFramesDB and SFramesDB.powerRainbow) then + if bar._rainbowActive then + if bar.rainbowTex then bar.rainbowTex:Hide() end + bar._rainbowActive = nil + end + return + end + if not bar.rainbowTex then + bar.rainbowTex = bar:CreateTexture(nil, "OVERLAY") + bar.rainbowTex:SetTexture(POWER_RAINBOW_TEX) + bar.rainbowTex:Hide() + end + if maxPower and maxPower > 0 then + local pct = power / maxPower + if pct >= 1.0 then + -- 满条:直接铺满,不依赖 GetWidth()(两锚点定尺寸的框体 GetWidth 可能返回 0) + bar.rainbowTex:ClearAllPoints() + bar.rainbowTex:SetAllPoints(bar) + bar.rainbowTex:SetTexCoord(0, 1, 0, 1) + bar.rainbowTex:Show() + bar._rainbowActive = true + else + local barW = bar:GetWidth() + -- 双锚点定尺寸的框体(如宠物能量条)GetWidth() 可能返回 0 + -- 回退到 GetRight()-GetLeft() 获取实际渲染宽度 + if not barW or barW <= 0 then + local left = bar:GetLeft() + local right = bar:GetRight() + if left and right then + barW = right - left + end + end + if barW and barW > 0 then + bar.rainbowTex:ClearAllPoints() + bar.rainbowTex:SetPoint("TOPLEFT", bar, "TOPLEFT", 0, 0) + bar.rainbowTex:SetPoint("BOTTOMRIGHT", bar, "BOTTOMLEFT", barW * pct, 0) + bar.rainbowTex:SetTexCoord(0, pct, 0, 1) + bar.rainbowTex:Show() + bar._rainbowActive = true + end + end + else + bar.rainbowTex:Hide() + bar._rainbowActive = nil + end +end + function SFrames:InitSlashCommands() DEFAULT_CHAT_FRAME:AddMessage("SF: InitSlashCommands called.") SLASH_SFRAMES1 = "/nanami" @@ -808,12 +966,16 @@ function SFrames:HideBlizzardFrames() end if ComboFrame then ComboFrame:UnregisterAllEvents() + ComboFrame:SetScript("OnUpdate", nil) ComboFrame:Hide() ComboFrame.Show = function() end - ComboFrame.fadeInfo = ComboFrame.fadeInfo or {} + ComboFrame.fadeInfo = {} if ComboFrame_Update then ComboFrame_Update = function() end end + if ComboFrame_OnUpdate then + ComboFrame_OnUpdate = function() end + end end end diff --git a/ExtraBar.lua b/ExtraBar.lua new file mode 100644 index 0000000..e0e3cbd --- /dev/null +++ b/ExtraBar.lua @@ -0,0 +1,792 @@ +-------------------------------------------------------------------------------- +-- Nanami-UI: ExtraBar +-- +-- A fully configurable extra action bar using action slots from page 7+ +-- (slots 73-120). Buttons are created from scratch since no spare Blizzard +-- ActionButton frames exist. +-- +-- Supports: grid layout, per-row count, alignment, drag-and-drop, cooldown, +-- range coloring, tooltip, and Mover integration for position saving. +-------------------------------------------------------------------------------- + +SFrames.ExtraBar = {} + +local EB = SFrames.ExtraBar + +local DEFAULTS = { + enable = false, + buttonCount = 12, + perRow = 12, + buttonSize = 36, + buttonGap = 2, + align = "center", + startSlot = 73, + alpha = 1.0, + showHotkey = true, + showCount = true, + buttonRounded = false, + buttonInnerShadow = false, +} + +local MAX_BUTTONS = 48 + +function EB:GetDB() + if not SFramesDB then SFramesDB = {} end + if type(SFramesDB.ExtraBar) ~= "table" then SFramesDB.ExtraBar = {} end + local db = SFramesDB.ExtraBar + for k, v in pairs(DEFAULTS) do + if db[k] == nil then db[k] = v end + end + return db +end + +-------------------------------------------------------------------------------- +-- Layout helper (local LayoutGrid equivalent) +-------------------------------------------------------------------------------- +local function LayoutGrid(buttons, parent, size, gap, perRow, count) + if count == 0 then return end + local numCols = math.min(perRow, count) + local numRows = math.ceil(count / perRow) + parent:SetWidth(numCols * size + math.max(numCols - 1, 0) * gap) + parent:SetHeight(numRows * size + math.max(numRows - 1, 0) * gap) + for i = 1, count do + local b = buttons[i] + if b then + b:SetWidth(size) + b:SetHeight(size) + b:ClearAllPoints() + local col = math.fmod(i - 1, perRow) + local row = math.floor((i - 1) / perRow) + b:SetPoint("TOPLEFT", parent, "TOPLEFT", col * (size + gap), -row * (size + gap)) + b:Show() + end + end + for i = count + 1, MAX_BUTTONS do + if buttons[i] then buttons[i]:Hide() end + end +end + +-------------------------------------------------------------------------------- +-- Button visual helpers (mirrors ActionBars.lua approach) +-------------------------------------------------------------------------------- +local function CreateBackdropFor(btn) + if btn.sfBackdrop then return end + local level = btn:GetFrameLevel() + local bd = CreateFrame("Frame", nil, btn) + bd:SetFrameLevel(level > 0 and (level - 1) or 0) + bd:SetAllPoints(btn) + SFrames:CreateBackdrop(bd) + btn.sfBackdrop = bd +end + +local function CreateInnerShadow(btn) + if btn.sfInnerShadow then return btn.sfInnerShadow end + local shadow = {} + local thickness = 4 + + local top = btn:CreateTexture(nil, "OVERLAY") + top:SetTexture("Interface\\Buttons\\WHITE8X8") + top:SetHeight(thickness) + top:SetGradientAlpha("VERTICAL", 0, 0, 0, 0, 0, 0, 0, 0.5) + shadow.top = top + + local bot = btn:CreateTexture(nil, "OVERLAY") + bot:SetTexture("Interface\\Buttons\\WHITE8X8") + bot:SetHeight(thickness) + bot:SetGradientAlpha("VERTICAL", 0, 0, 0, 0.5, 0, 0, 0, 0) + shadow.bottom = bot + + local left = btn:CreateTexture(nil, "OVERLAY") + left:SetTexture("Interface\\Buttons\\WHITE8X8") + left:SetWidth(thickness) + left:SetGradientAlpha("HORIZONTAL", 0, 0, 0, 0.5, 0, 0, 0, 0) + shadow.left = left + + local right = btn:CreateTexture(nil, "OVERLAY") + right:SetTexture("Interface\\Buttons\\WHITE8X8") + right:SetWidth(thickness) + right:SetGradientAlpha("HORIZONTAL", 0, 0, 0, 0, 0, 0, 0, 0.5) + shadow.right = right + + btn.sfInnerShadow = shadow + return shadow +end + +local function ApplyButtonVisuals(btn, rounded, innerShadow) + local bd = btn.sfBackdrop + if not bd then return end + + local inset = rounded and 3 or 2 + btn.sfIconInset = inset + + if rounded then + bd: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 } + }) + else + bd:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 } + }) + end + local A = SFrames.ActiveTheme + if A and A.panelBg then + bd:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], A.panelBg[4] or 0.9) + bd:SetBackdropBorderColor(A.panelBorder[1], A.panelBorder[2], A.panelBorder[3], A.panelBorder[4] or 1) + else + bd:SetBackdropColor(0.1, 0.1, 0.1, 0.9) + bd:SetBackdropBorderColor(0, 0, 0, 1) + end + + local icon = btn.sfIcon + if icon then + icon:ClearAllPoints() + icon:SetPoint("TOPLEFT", btn, "TOPLEFT", inset, -inset) + icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -inset, inset) + end + if btn.sfCdOverlay then + btn.sfCdOverlay:ClearAllPoints() + btn.sfCdOverlay:SetPoint("TOPLEFT", btn, "TOPLEFT", inset, -inset) + btn.sfCdOverlay:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -inset, inset) + end + if btn.sfRangeOverlay then + btn.sfRangeOverlay:ClearAllPoints() + btn.sfRangeOverlay:SetPoint("TOPLEFT", btn, "TOPLEFT", inset, -inset) + btn.sfRangeOverlay:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -inset, inset) + end + + if innerShadow then + if not btn.sfInnerShadow then CreateInnerShadow(btn) end + local s = btn.sfInnerShadow + s.top:ClearAllPoints() + s.top:SetPoint("TOPLEFT", btn, "TOPLEFT", inset, -inset) + s.top:SetPoint("TOPRIGHT", btn, "TOPRIGHT", -inset, -inset) + s.bottom:ClearAllPoints() + s.bottom:SetPoint("BOTTOMLEFT", btn, "BOTTOMLEFT", inset, inset) + s.bottom:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -inset, inset) + s.left:ClearAllPoints() + s.left:SetPoint("TOPLEFT", btn, "TOPLEFT", inset, -inset) + s.left:SetPoint("BOTTOMLEFT", btn, "BOTTOMLEFT", inset, inset) + s.right:ClearAllPoints() + s.right:SetPoint("TOPRIGHT", btn, "TOPRIGHT", -inset, -inset) + s.right:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -inset, inset) + s.top:Show(); s.bottom:Show(); s.left:Show(); s.right:Show() + else + if btn.sfInnerShadow then + local s = btn.sfInnerShadow + s.top:Hide(); s.bottom:Hide(); s.left:Hide(); s.right:Hide() + end + end +end + +-------------------------------------------------------------------------------- +-- Single-button refresh helpers +-------------------------------------------------------------------------------- +local function RefreshButtonIcon(btn) + local slot = btn.sfActionSlot + if not slot then return end + if HasAction(slot) then + local tex = GetActionTexture(slot) + btn.sfIcon:SetTexture(tex) + btn.sfIcon:Show() + btn.sfIcon:SetVertexColor(1, 1, 1, 1) + btn:SetAlpha(1) + else + btn.sfIcon:SetTexture(nil) + btn.sfIcon:Hide() + end +end + +local function RefreshButtonCooldown(btn) + local slot = btn.sfActionSlot + if not slot or not HasAction(slot) then + if btn.sfCdOverlay then btn.sfCdOverlay:Hide() end + if btn.sfCdText then btn.sfCdText:SetText("") end + btn.sfCdStart = 0 + btn.sfCdDuration = 0 + return + end + local start, duration, enable = GetActionCooldown(slot) + if not start or not duration or duration == 0 or enable == 0 then + if btn.sfCdOverlay then btn.sfCdOverlay:Hide() end + if btn.sfCdText then btn.sfCdText:SetText("") end + btn.sfCdStart = 0 + btn.sfCdDuration = 0 + return + end + btn.sfCdStart = start + btn.sfCdDuration = duration + local now = GetTime() + local remaining = (start + duration) - now + if remaining > 0 then + if btn.sfCdOverlay then btn.sfCdOverlay:Show() end + if btn.sfCdText then + if remaining >= 60 then + btn.sfCdText:SetText(math.floor(remaining / 60) .. "m") + else + btn.sfCdText:SetText(math.floor(remaining + 0.5) .. "") + end + end + else + if btn.sfCdOverlay then btn.sfCdOverlay:Hide() end + if btn.sfCdText then btn.sfCdText:SetText("") end + btn.sfCdStart = 0 + btn.sfCdDuration = 0 + end +end + +local function RefreshButtonUsable(btn) + local slot = btn.sfActionSlot + if not slot or not HasAction(slot) then return end + local isUsable, notEnoughMana = IsUsableAction(slot) + if isUsable then + btn.sfIcon:SetVertexColor(1, 1, 1, 1) + elseif notEnoughMana then + btn.sfIcon:SetVertexColor(0.2, 0.2, 0.8, 1) + else + btn.sfIcon:SetVertexColor(0.4, 0.4, 0.4, 1) + end +end + +local function RefreshButtonCount(btn) + local slot = btn.sfActionSlot + if not slot or not HasAction(slot) then + btn.sfCount:SetText("") + return + end + local count = GetActionCount(slot) + if count and count > 0 then + btn.sfCount:SetText(tostring(count)) + else + btn.sfCount:SetText("") + end +end + +local function RefreshButtonRange(btn) + local slot = btn.sfActionSlot + if not slot or not HasAction(slot) then + if btn.sfRangeOverlay then btn.sfRangeOverlay:Hide() end + return + end + local inRange = IsActionInRange(slot) + if not btn.sfRangeOverlay then + local inset = btn.sfIconInset or 2 + local ov = btn:CreateTexture(nil, "OVERLAY") + ov:SetTexture("Interface\\Buttons\\WHITE8X8") + ov:SetPoint("TOPLEFT", btn, "TOPLEFT", inset, -inset) + ov:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -inset, inset) + ov:SetVertexColor(1.0, 0.1, 0.1, 0.35) + ov:Hide() + btn.sfRangeOverlay = ov + end + if inRange == 0 then + btn.sfRangeOverlay:Show() + else + btn.sfRangeOverlay:Hide() + end +end + +local function RefreshButtonState(btn) + local slot = btn.sfActionSlot + if not slot or not HasAction(slot) then return end + if IsCurrentAction(slot) then + if btn.sfBackdrop then + btn.sfBackdrop:SetBackdropBorderColor(1, 1, 1, 0.8) + end + else + local A = SFrames.ActiveTheme + if btn.sfBackdrop then + if A and A.panelBorder then + btn.sfBackdrop:SetBackdropBorderColor(A.panelBorder[1], A.panelBorder[2], A.panelBorder[3], A.panelBorder[4] or 1) + else + btn.sfBackdrop:SetBackdropBorderColor(0, 0, 0, 1) + end + end + end +end + +local function RefreshButtonAll(btn) + RefreshButtonIcon(btn) + RefreshButtonCooldown(btn) + RefreshButtonUsable(btn) + RefreshButtonCount(btn) + RefreshButtonState(btn) +end + +-------------------------------------------------------------------------------- +-- Custom action button factory +-------------------------------------------------------------------------------- +local btnIndex = 0 + +local function CreateExtraButton(parent) + btnIndex = btnIndex + 1 + local name = "SFramesExtraBarButton" .. btnIndex + local btn = CreateFrame("Button", name, parent) + btn:SetWidth(36) + btn:SetHeight(36) + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + btn:RegisterForDrag("LeftButton") + + CreateBackdropFor(btn) + + local icon = btn:CreateTexture(name .. "Icon", "ARTWORK") + icon:SetPoint("TOPLEFT", btn, "TOPLEFT", 2, -2) + icon:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -2, 2) + icon:SetTexCoord(0.07, 0.93, 0.07, 0.93) + btn.sfIcon = icon + + local cdOverlay = btn:CreateTexture(nil, "OVERLAY") + cdOverlay:SetTexture("Interface\\Buttons\\WHITE8X8") + cdOverlay:SetAllPoints(icon) + cdOverlay:SetVertexColor(0, 0, 0, 0.6) + cdOverlay:Hide() + btn.sfCdOverlay = cdOverlay + + local cdText = btn:CreateFontString(nil, "OVERLAY") + cdText:SetFont(SFrames:GetFont(), 12, "OUTLINE") + cdText:SetPoint("CENTER", btn, "CENTER", 0, 0) + cdText:SetTextColor(1, 1, 0.2) + cdText:SetText("") + btn.sfCdText = cdText + + btn.sfCdStart = 0 + btn.sfCdDuration = 0 + + local font = SFrames:GetFont() + + local hotkey = btn:CreateFontString(name .. "HotKey", "OVERLAY") + hotkey:SetFont(font, 9, "OUTLINE") + hotkey:SetPoint("TOPRIGHT", btn, "TOPRIGHT", -2, -2) + hotkey:SetJustifyH("RIGHT") + btn.sfHotKey = hotkey + + local count = btn:CreateFontString(name .. "Count", "OVERLAY") + count:SetFont(font, 9, "OUTLINE") + count:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -2, 2) + count:SetJustifyH("RIGHT") + btn.sfCount = count + + -- Highlight texture + local hl = btn:CreateTexture(nil, "HIGHLIGHT") + hl:SetTexture("Interface\\Buttons\\ButtonHilight-Square") + hl:SetBlendMode("ADD") + hl:SetPoint("TOPLEFT", btn, "TOPLEFT", 2, -2) + hl:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -2, 2) + hl:SetAlpha(0.3) + + -- Pushed texture overlay + local pushed = btn:CreateTexture(nil, "OVERLAY") + pushed:SetTexture("Interface\\Buttons\\WHITE8X8") + pushed:SetPoint("TOPLEFT", btn, "TOPLEFT", 2, -2) + pushed:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -2, 2) + pushed:SetVertexColor(1, 1, 1, 0.15) + pushed:Hide() + btn.sfPushed = pushed + + btn.sfActionSlot = nil + + btn:SetScript("OnClick", function() + local slot = this.sfActionSlot + if not slot then return end + if arg1 == "LeftButton" then + if IsShiftKeyDown() and ChatFrameEditBox and ChatFrameEditBox:IsVisible() then + -- Shift-click: link to chat (fallback: pickup) + pcall(PickupAction, slot) + else + UseAction(slot, 0, 1) + end + elseif arg1 == "RightButton" then + UseAction(slot, 0, 1) + end + RefreshButtonAll(this) + end) + + btn:SetScript("OnDragStart", function() + local slot = this.sfActionSlot + if slot then + pcall(PickupAction, slot) + RefreshButtonAll(this) + end + end) + + btn:SetScript("OnReceiveDrag", function() + local slot = this.sfActionSlot + if slot then + pcall(PlaceAction, slot) + RefreshButtonAll(this) + end + end) + + btn:SetScript("OnEnter", function() + local slot = this.sfActionSlot + if slot and HasAction(slot) then + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetAction(slot) + GameTooltip:Show() + end + end) + + btn:SetScript("OnLeave", function() + GameTooltip:Hide() + end) + + btn:SetScript("OnMouseDown", function() + if this.sfPushed then this.sfPushed:Show() end + end) + + btn:SetScript("OnMouseUp", function() + if this.sfPushed then this.sfPushed:Hide() end + end) + + btn:Hide() + return btn +end + +-------------------------------------------------------------------------------- +-- Create all buttons once +-------------------------------------------------------------------------------- +function EB:CreateButtons() + local db = self:GetDB() + + self.holder = CreateFrame("Frame", "SFramesExtraBarHolder", UIParent) + self.holder:SetWidth(200) + self.holder:SetHeight(40) + + local pos = SFramesDB and SFramesDB.Positions and SFramesDB.Positions["ExtraBar"] + if pos and pos.point and pos.relativePoint then + self.holder:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + else + self.holder:SetPoint("CENTER", UIParent, "CENTER", 0, -200) + end + + self.buttons = {} + for i = 1, MAX_BUTTONS do + local btn = CreateExtraButton(self.holder) + table.insert(self.buttons, btn) + end + + if SFrames.ActionBars and SFrames.ActionBars.RegisterBindButton then + for i = 1, MAX_BUTTONS do + local bname = self.buttons[i]:GetName() + SFrames.ActionBars:RegisterBindButton(bname, "NANAMI_EXTRABAR" .. i) + end + end +end + +-------------------------------------------------------------------------------- +-- Apply configuration +-------------------------------------------------------------------------------- +function EB:ApplyConfig() + if not self.holder then return end + local db = self:GetDB() + + if not db.enable then + self.holder:Hide() + return + end + + local count = math.min(db.buttonCount or 12, MAX_BUTTONS) + local perRow = db.perRow or 12 + local size = db.buttonSize or 36 + local gap = db.buttonGap or 2 + local startSlot = db.startSlot or 73 + + -- Assign action slots + for i = 1, MAX_BUTTONS do + local btn = self.buttons[i] + btn.sfActionSlot = startSlot + i - 1 + end + + -- Layout + LayoutGrid(self.buttons, self.holder, size, gap, perRow, count) + + -- Alignment via anchor + local positions = SFramesDB and SFramesDB.Positions + local pos = positions and positions["ExtraBar"] + if not (pos and pos.point and pos.relativePoint) then + self.holder:ClearAllPoints() + local align = db.align or "center" + if align == "left" then + self.holder:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 20, 200) + elseif align == "right" then + self.holder:SetPoint("BOTTOMRIGHT", UIParent, "BOTTOMRIGHT", -20, 200) + else + self.holder:SetPoint("CENTER", UIParent, "CENTER", 0, -200) + end + end + + -- Alpha + local alpha = db.alpha or 1 + if alpha < 0.1 then alpha = 0.1 end + if alpha > 1 then alpha = 1 end + self.holder:SetAlpha(alpha) + + -- Scale (inherit from main action bars if available) + local abDb = SFrames.ActionBars and SFrames.ActionBars.GetDB and SFrames.ActionBars:GetDB() + local scale = abDb and abDb.scale or 1.0 + self.holder:SetScale(scale) + + -- Button visuals + local isRounded = db.buttonRounded + local isShadow = db.buttonInnerShadow + local showHK = db.showHotkey + local showCt = db.showCount + local font = SFrames:GetFont() + local fontSize = math.max(6, math.floor(size * 0.25 + 0.5)) + + for i = 1, count do + local btn = self.buttons[i] + ApplyButtonVisuals(btn, isRounded, isShadow) + if btn.sfHotKey then + btn.sfHotKey:SetFont(font, fontSize, "OUTLINE") + if showHK then btn.sfHotKey:Show() else btn.sfHotKey:Hide() end + end + if btn.sfCount then + btn.sfCount:SetFont(font, fontSize, "OUTLINE") + if showCt then btn.sfCount:Show() else btn.sfCount:Hide() end + end + if btn.sfCdText then + local cdFontSize = math.max(8, math.floor(size * 0.35 + 0.5)) + btn.sfCdText:SetFont(font, cdFontSize, "OUTLINE") + end + RefreshButtonAll(btn) + end + + self.holder:Show() + + -- Update mover if in layout mode + if SFrames.Movers and SFrames.Movers:IsLayoutMode() then + SFrames.Movers:SyncMoverToFrame("ExtraBar") + end +end + +-------------------------------------------------------------------------------- +-- Refresh all visible buttons +-------------------------------------------------------------------------------- +function EB:RefreshAll() + if not self.buttons then return end + local db = self:GetDB() + if not db.enable then return end + local count = math.min(db.buttonCount or 12, MAX_BUTTONS) + for i = 1, count do + RefreshButtonAll(self.buttons[i]) + end +end + +function EB:RefreshCooldowns() + if not self.buttons then return end + local db = self:GetDB() + if not db.enable then return end + local count = math.min(db.buttonCount or 12, MAX_BUTTONS) + for i = 1, count do + RefreshButtonCooldown(self.buttons[i]) + end +end + +function EB:RefreshUsable() + if not self.buttons then return end + local db = self:GetDB() + if not db.enable then return end + local count = math.min(db.buttonCount or 12, MAX_BUTTONS) + for i = 1, count do + RefreshButtonUsable(self.buttons[i]) + end +end + +function EB:RefreshStates() + if not self.buttons then return end + local db = self:GetDB() + if not db.enable then return end + local count = math.min(db.buttonCount or 12, MAX_BUTTONS) + for i = 1, count do + RefreshButtonState(self.buttons[i]) + end +end + +function EB:RefreshHotkeys() + if not self.buttons then return end + local db = self:GetDB() + if not db.enable then return end + local count = math.min(db.buttonCount or 12, MAX_BUTTONS) + for i = 1, count do + local btn = self.buttons[i] + local cmd = "NANAMI_EXTRABAR" .. i + local hotkey = btn.sfHotKey + if hotkey then + local key1 = GetBindingKey(cmd) + if key1 then + local text = key1 + if GetBindingText then + text = GetBindingText(key1, "KEY_", 1) or key1 + end + hotkey:SetText(text) + else + hotkey:SetText("") + end + end + end +end + +-------------------------------------------------------------------------------- +-- OnUpdate poller for cooldown / range / usability +-------------------------------------------------------------------------------- +function EB:SetupPoller() + local poller = CreateFrame("Frame", "SFramesExtraBarPoller", UIParent) + poller.timer = 0 + self.poller = poller + + poller:SetScript("OnUpdate", function() + this.timer = this.timer + arg1 + if this.timer < 0.2 then return end + this.timer = 0 + + local db = EB:GetDB() + if not db.enable then return end + local count = math.min(db.buttonCount or 12, MAX_BUTTONS) + for i = 1, count do + local btn = EB.buttons[i] + if btn and btn:IsShown() then + RefreshButtonCooldown(btn) + RefreshButtonUsable(btn) + RefreshButtonRange(btn) + RefreshButtonState(btn) + end + end + end) +end + +-------------------------------------------------------------------------------- +-- Initialize +-------------------------------------------------------------------------------- +function EB:Initialize() + local db = self:GetDB() + if not db.enable then return end + + self:CreateButtons() + self:ApplyConfig() + self:SetupPoller() + + -- Events + SFrames:RegisterEvent("ACTIONBAR_SLOT_CHANGED", function() + if not EB.buttons then return end + local db = EB:GetDB() + if not db.enable then return end + local slot = arg1 + local startSlot = db.startSlot or 73 + local count = math.min(db.buttonCount or 12, MAX_BUTTONS) + if slot then + local idx = slot - startSlot + 1 + if idx >= 1 and idx <= count then + RefreshButtonAll(EB.buttons[idx]) + end + else + EB:RefreshAll() + end + end) + + SFrames:RegisterEvent("ACTIONBAR_UPDATE_COOLDOWN", function() + EB:RefreshCooldowns() + end) + + SFrames:RegisterEvent("ACTIONBAR_UPDATE_USABLE", function() + EB:RefreshUsable() + end) + + SFrames:RegisterEvent("ACTIONBAR_UPDATE_STATE", function() + EB:RefreshStates() + end) + + SFrames:RegisterEvent("PLAYER_ENTERING_WORLD", function() + EB:RefreshAll() + EB:RefreshHotkeys() + end) + + SFrames:RegisterEvent("UPDATE_BINDINGS", function() + EB:RefreshHotkeys() + end) + + -- Register mover + if SFrames.Movers and SFrames.Movers.RegisterMover then + SFrames.Movers:RegisterMover("ExtraBar", self.holder, "额外动作条", + "CENTER", "UIParent", "CENTER", 0, -200) + end +end + +-------------------------------------------------------------------------------- +-- Late enable: called from ConfigUI when user toggles enable on +-------------------------------------------------------------------------------- +function EB:Enable() + local db = self:GetDB() + db.enable = true + if not self.holder then + self:CreateButtons() + self:SetupPoller() + + SFrames:RegisterEvent("ACTIONBAR_SLOT_CHANGED", function() + if not EB.buttons then return end + local db2 = EB:GetDB() + if not db2.enable then return end + local slot = arg1 + local startSlot = db2.startSlot or 73 + local count = math.min(db2.buttonCount or 12, MAX_BUTTONS) + if slot then + local idx = slot - startSlot + 1 + if idx >= 1 and idx <= count then + RefreshButtonAll(EB.buttons[idx]) + end + else + EB:RefreshAll() + end + end) + SFrames:RegisterEvent("ACTIONBAR_UPDATE_COOLDOWN", function() + EB:RefreshCooldowns() + end) + SFrames:RegisterEvent("ACTIONBAR_UPDATE_USABLE", function() + EB:RefreshUsable() + end) + SFrames:RegisterEvent("ACTIONBAR_UPDATE_STATE", function() + EB:RefreshStates() + end) + SFrames:RegisterEvent("UPDATE_BINDINGS", function() + EB:RefreshHotkeys() + end) + + if SFrames.Movers and SFrames.Movers.RegisterMover then + SFrames.Movers:RegisterMover("ExtraBar", self.holder, "额外动作条", + "CENTER", "UIParent", "CENTER", 0, -200) + end + end + self:ApplyConfig() + self:RefreshHotkeys() +end + +function EB:Disable() + local db = self:GetDB() + db.enable = false + if self.holder then + self.holder:Hide() + end + if self.holder and SFrames.Movers and SFrames.Movers.IsLayoutMode + and SFrames.Movers:IsLayoutMode() then + pcall(SFrames.Movers.SyncMoverToFrame, SFrames.Movers, "ExtraBar") + end +end + +function EB:RunButton(index) + if not self.buttons then return end + local db = self:GetDB() + if not db.enable then return end + if index < 1 or index > math.min(db.buttonCount or 12, MAX_BUTTONS) then return end + local btn = self.buttons[index] + if not btn or not btn:IsVisible() then return end + local slot = btn.sfActionSlot + if slot and HasAction(slot) then + UseAction(slot) + RefreshButtonAll(btn) + end +end diff --git a/Factory.lua b/Factory.lua index 82d6155..d830719 100644 --- a/Factory.lua +++ b/Factory.lua @@ -1,19 +1,48 @@ -- Helper function to generate ElvUI-style backdrop and shadow border -function SFrames:CreateBackdrop(frame) - frame:SetBackdrop({ - bgFile = "Interface\\Buttons\\WHITE8X8", - edgeFile = "Interface\\Buttons\\WHITE8X8", - tile = false, tileSize = 0, edgeSize = 1, - insets = { left = 1, right = 1, top = 1, bottom = 1 } - }) - local A = SFrames.ActiveTheme - if A and A.panelBg then - frame:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], A.panelBg[4] or 0.9) - frame:SetBackdropBorderColor(A.panelBorder[1], A.panelBorder[2], A.panelBorder[3], A.panelBorder[4] or 1) +function SFrames:ApplyBackdropStyle(frame, opts) + opts = opts or {} + local radius = tonumber(opts.cornerRadius) or 0 + local showBorder = opts.showBorder ~= false + local useRounded = radius and radius > 0 + + if useRounded then + local edgeSize = math.max(8, math.min(18, math.floor(radius + 0.5))) + frame:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = showBorder and "Interface\\Tooltips\\UI-Tooltip-Border" or nil, + tile = true, tileSize = 16, edgeSize = edgeSize, + insets = { left = 3, right = 3, top = 3, bottom = 3 } + }) else - frame:SetBackdropColor(0.1, 0.1, 0.1, 0.9) - frame:SetBackdropBorderColor(0, 0, 0, 1) + frame:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = showBorder and "Interface\\Buttons\\WHITE8X8" or nil, + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 } + }) end + + local A = SFrames.ActiveTheme + local bgAlpha = opts.bgAlpha + if A and A.panelBg then + frame:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], bgAlpha or A.panelBg[4] or 0.9) + if showBorder then + frame:SetBackdropBorderColor(A.panelBorder[1], A.panelBorder[2], A.panelBorder[3], A.panelBorder[4] or 1) + else + frame:SetBackdropBorderColor(0, 0, 0, 0) + end + else + frame:SetBackdropColor(0.1, 0.1, 0.1, bgAlpha or 0.9) + if showBorder then + frame:SetBackdropBorderColor(0, 0, 0, 1) + else + frame:SetBackdropBorderColor(0, 0, 0, 0) + end + end +end + +function SFrames:CreateBackdrop(frame) + self:ApplyBackdropStyle(frame) end function SFrames:CreateRoundBackdrop(frame) @@ -38,6 +67,78 @@ function SFrames:CreateUnitBackdrop(frame) frame:SetBackdropBorderColor(0, 0, 0, 1) end +function SFrames:ApplyConfiguredUnitBackdrop(frame, prefix, isPortrait) + if not frame then return end + local db = SFramesDB or {} + local bgAlphaKey = prefix .. (isPortrait and "PortraitBgAlpha" or "BgAlpha") + self:ApplyBackdropStyle(frame, { + showBorder = false, + cornerRadius = 0, + bgAlpha = tonumber(db[bgAlphaKey]) or (isPortrait and tonumber(db[prefix .. "BgAlpha"]) or nil), + }) +end + +-------------------------------------------------------------------------------- +-- Frame Style Preset helpers +-------------------------------------------------------------------------------- + +function SFrames:GetFrameStylePreset() + return (SFramesDB and SFramesDB.frameStylePreset) or "classic" +end + +function SFrames:IsGradientStyle() + return self:GetFrameStylePreset() == "gradient" +end + +-- Apply a gradient darkening overlay on a StatusBar. +-- Uses a separate Texture on OVERLAY layer with purple-black vertex color +-- and alpha gradient from 0 (left) to ~0.6 (right). +-- This approach does NOT touch the StatusBar texture itself, +-- so it survives SetStatusBarColor calls. +function SFrames:ApplyGradientStyle(bar) + if not bar then return end + if not bar._gradOverlay then + local ov = bar:CreateTexture(nil, "OVERLAY") + ov:SetTexture("Interface\\Buttons\\WHITE8X8") + bar._gradOverlay = ov + end + local ov = bar._gradOverlay + ov:ClearAllPoints() + ov:SetAllPoints(bar:GetStatusBarTexture()) + -- Dark purple-ish tint color + ov:SetVertexColor(0.04, 0.0, 0.08, 1) + -- Alpha gradient: left fully transparent → right 65% opaque + if ov.SetGradientAlpha then + ov:SetGradientAlpha("HORIZONTAL", + 1, 1, 1, 0, + 1, 1, 1, 0.65) + end + ov:Show() +end + +function SFrames:RemoveGradientStyle(bar) + if not bar then return end + if bar._gradOverlay then + bar._gradOverlay:Hide() + end +end + +-- No-op wrappers kept for compatibility with unit files +function SFrames:ApplyBarGradient(bar) + -- Gradient is now persistent overlay; no per-color-update needed +end +function SFrames:ClearBarGradient(bar) + self:RemoveGradientStyle(bar) +end + +-- Strip backdrop from a frame (used in gradient style) +function SFrames:ClearBackdrop(frame) + if not frame then return end + if frame.SetBackdrop then + frame:SetBackdrop(nil) + end +end + -- Generator for StatusBars function SFrames:CreateStatusBar(parent, name) local bar = CreateFrame("StatusBar", name, parent) @@ -50,6 +151,11 @@ function SFrames:CreateStatusBar(parent, name) return bar end +function SFrames:ApplyStatusBarTexture(bar, settingKey, fallbackKey) + if not bar or not bar.SetStatusBarTexture then return end + bar:SetStatusBarTexture(self:ResolveBarTexture(settingKey, fallbackKey)) +end + -- Generator for FontStrings function SFrames:CreateFontString(parent, size, justifyH) local fs = parent:CreateFontString(nil, "OVERLAY") @@ -59,6 +165,29 @@ function SFrames:CreateFontString(parent, size, justifyH) return fs end +function SFrames:ApplyFontString(fs, size, fontSettingKey, fallbackFontKey, outlineSettingKey, fallbackOutlineKey) + if not fs or not fs.SetFont then return end + local fontPath = self:ResolveFont(fontSettingKey, fallbackFontKey) + local outline = self:ResolveFontOutline(outlineSettingKey, fallbackOutlineKey) + fs:SetFont(fontPath, size or 12, outline) +end + +function SFrames:FormatCompactNumber(value) + local num = tonumber(value) or 0 + local sign = num < 0 and "-" or "" + num = math.abs(num) + if num >= 1000000 then + return string.format("%s%.1fM", sign, num / 1000000) + elseif num >= 10000 then + return string.format("%s%.1fK", sign, num / 1000) + end + return sign .. tostring(math.floor(num + 0.5)) +end + +function SFrames:FormatCompactPair(currentValue, maxValue) + return self:FormatCompactNumber(currentValue) .. " / " .. self:FormatCompactNumber(maxValue) +end + -- Generator for 3D Portraits function SFrames:CreatePortrait(parent, name) local portrait = CreateFrame("PlayerModel", name, parent) diff --git a/Focus.lua b/Focus.lua index 937e744..c41e57c 100644 --- a/Focus.lua +++ b/Focus.lua @@ -327,17 +327,23 @@ function SFrames.Focus:CreateFocusFrame() 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 fScale = f:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fScale > 0.01 and math.abs(fScale - 1) > 0.001 then + f:SetPoint(pos.point or "LEFT", UIParent, pos.relativePoint or "LEFT", + (pos.xOfs or 250) / fScale, (pos.yOfs or 0) / fScale) + else + f:SetPoint(pos.point or "LEFT", UIParent, pos.relativePoint or "LEFT", + pos.xOfs or 250, pos.yOfs or 0) + end 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 + elseif SFramesTargetFrame then + f:SetPoint("TOPLEFT", SFramesTargetFrame, "BOTTOMLEFT", 0, -75) else f:SetPoint("LEFT", UIParent, "LEFT", 250, 0) end @@ -352,6 +358,11 @@ function SFrames.Focus:CreateFocusFrame() if not SFramesDB then SFramesDB = {} end if not SFramesDB.Positions then SFramesDB.Positions = {} end local point, _, relativePoint, xOfs, yOfs = this:GetPoint() + local fScale = this:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fScale > 0.01 and math.abs(fScale - 1) > 0.001 then + xOfs = (xOfs or 0) * fScale + yOfs = (yOfs or 0) * fScale + end SFramesDB.Positions["FocusFrame"] = { point = point, relativePoint = relativePoint, xOfs = xOfs, yOfs = yOfs } end) @@ -546,37 +557,27 @@ function SFrames.Focus:CreateFocusFrame() 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) + -- Portrait placeholder (hidden, focus frame does not use 3D portraits) + f.portrait = CreateFrame("Frame", nil, f) f.portrait:SetWidth(pWidth) f.portrait:SetHeight(totalH - 2) f.portrait:SetPoint("RIGHT", f, "RIGHT", -1, 0) f.portrait:EnableMouse(false) + f.portrait:Hide() 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) + pbg:Hide() - if not showPortrait then - f.portrait:Hide() - pbg:Hide() - end - - -- Health bar + -- Health bar (full width, no portrait) 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:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1) + f.health:SetPoint("TOPRIGHT", f, "TOPRIGHT", -1, 0) f.health:SetHeight(hHeight) local hbg = CreateFrame("Frame", nil, f) @@ -596,11 +597,7 @@ function SFrames.Focus:CreateFocusFrame() 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 + f.power:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -1, 1) local powerbg = CreateFrame("Frame", nil, f) powerbg:SetPoint("TOPLEFT", f.power, "TOPLEFT", -1, 1) @@ -615,9 +612,9 @@ function SFrames.Focus:CreateFocusFrame() 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 + -- Class icon (anchored to frame top-right corner) f.classIcon = SFrames:CreateClassIcon(f, 14) - f.classIcon.overlay:SetPoint("CENTER", f.portrait, "TOPRIGHT", 0, 0) + f.classIcon.overlay:SetPoint("CENTER", f, "TOPRIGHT", 0, 0) f.classIcon.overlay:EnableMouse(false) -- Texts @@ -747,12 +744,7 @@ function SFrames.Focus:CreateCastbar() 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 + cb:SetPoint("BOTTOMRIGHT", self.frame, "TOPRIGHT", -(cbH + 6), 6) local cbbg = CreateFrame("Frame", nil, self.frame) cbbg:SetPoint("TOPLEFT", cb, "TOPLEFT", -1, 1) @@ -816,7 +808,6 @@ function SFrames.Focus:UpdateAll() 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() @@ -830,19 +821,6 @@ function SFrames.Focus:UpdateAll() 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 @@ -888,6 +866,7 @@ function SFrames.Focus:UpdateAll() -- Color by class or reaction local useClassColor = not (SFramesDB and SFramesDB.classColorHealth == false) + if SFrames:IsGradientStyle() then useClassColor = true end if UnitIsPlayer(uid) and useClassColor then local _, class = UnitClass(uid) if class and SFrames.Config.colors.class[class] then @@ -911,6 +890,9 @@ function SFrames.Focus:UpdateAll() self.frame.nameText:SetText(formattedLevel .. name) self.frame.nameText:SetTextColor(r, g, b) end + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(self.frame.health) + end end function SFrames.Focus:HideAuras() @@ -933,7 +915,7 @@ function SFrames.Focus:UpdateHealth() self.frame.health:SetValue(hp) if maxHp > 0 then local pct = math.floor(hp / maxHp * 100) - self.frame.healthText:SetText(hp .. " / " .. maxHp .. " (" .. pct .. "%)") + self.frame.healthText:SetText(SFrames:FormatCompactPair(hp, maxHp) .. " (" .. pct .. "%)") else self.frame.healthText:SetText("") end @@ -950,6 +932,9 @@ function SFrames.Focus:UpdatePowerType() else self.frame.power:SetStatusBarColor(0, 0, 1) end + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(self.frame.power) + end end -- STUB: UpdatePower @@ -961,10 +946,11 @@ function SFrames.Focus:UpdatePower() self.frame.power:SetMinMaxValues(0, maxPower) self.frame.power:SetValue(power) if maxPower > 0 then - self.frame.powerText:SetText(power .. " / " .. maxPower) + self.frame.powerText:SetText(SFrames:FormatCompactPair(power, maxPower)) else self.frame.powerText:SetText("") end + SFrames:UpdateRainbowBar(self.frame.power, power, maxPower, uid) end -- STUB: UpdateRaidIcon @@ -1045,8 +1031,8 @@ function SFrames.Focus:CastbarOnUpdate() -- 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) + local _UCI = UnitCastingInfo or CastingInfo + local _UCH = UnitChannelInfo or ChannelInfo if _UCI then local ok, cSpell, _, _, cIcon, cStart, cEnd = pcall(_UCI, uid) if ok and cSpell and cStart then @@ -1177,7 +1163,6 @@ function SFrames.Focus:OnFocusChanged() 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() @@ -1206,9 +1191,28 @@ function SFrames.Focus:ApplySettings() 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) + local powerOnTop = SFramesDB and SFramesDB.focusPowerOnTop == true + local gradientStyle = SFrames:IsGradientStyle() + local defaultPowerWidth = width - 2 + if defaultPowerWidth < 60 then + defaultPowerWidth = 60 + end + local rawPowerWidth = tonumber(SFramesDB and SFramesDB.focusPowerWidth) + local legacyFullWidth = tonumber(SFramesDB and SFramesDB.focusFrameWidth) or width + local maxPowerWidth = gradientStyle and width or (width - 2) + local powerWidth + if gradientStyle then + powerWidth = width + elseif not rawPowerWidth or math.abs(rawPowerWidth - legacyFullWidth) < 0.5 then + powerWidth = defaultPowerWidth + else + powerWidth = rawPowerWidth + end + powerWidth = math.floor(powerWidth + 0.5) + if powerWidth < 60 then powerWidth = 60 end + if powerWidth > maxPowerWidth then powerWidth = maxPowerWidth end -- Main frame size & scale f:SetWidth(width) @@ -1222,40 +1226,75 @@ function SFrames.Focus:ApplySettings() 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 + -- Portrait always hidden (focus frame uses class icon only) + if f.portrait then f.portrait:Hide() end + if f.portraitBG then f.portraitBG:Hide() end - -- Health bar anchors + -- Health bar anchors (always full width, no portrait) 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:SetPoint("TOPRIGHT", f, "TOPRIGHT", -1, 0) 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) + f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", tonumber(SFramesDB and SFramesDB.focusPowerOffsetX) or 0, -1 + (tonumber(SFramesDB and SFramesDB.focusPowerOffsetY) or 0)) + f.power:SetWidth(powerWidth) + f.power:SetHeight(pHeight) + end + + if f.health and f.power then + local healthLevel = f:GetFrameLevel() + 2 + local powerLevel = powerOnTop and (healthLevel + 1) or (healthLevel - 1) + f.health:SetFrameLevel(healthLevel) + f.power:SetFrameLevel(powerLevel) + end + + if SFrames:IsGradientStyle() then + SFrames:ClearBackdrop(f) + SFrames:ClearBackdrop(f.healthBGFrame) + SFrames:ClearBackdrop(f.powerBGFrame) + if f.health then + f.health:ClearAllPoints() + f.health:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0) + f.health:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0) + f.health:SetHeight(hHeight) end + if f.power then + f.power:ClearAllPoints() + f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", tonumber(SFramesDB and SFramesDB.focusPowerOffsetX) or 0, -2 + (tonumber(SFramesDB and SFramesDB.focusPowerOffsetY) or 0)) + f.power:SetWidth(powerWidth) + f.power:SetHeight(pHeight) + end + SFrames:ApplyGradientStyle(f.health) + SFrames:ApplyGradientStyle(f.power) + if f.healthBGFrame then + f.healthBGFrame:ClearAllPoints() + f.healthBGFrame:SetPoint("TOPLEFT", f.health, "TOPLEFT", 0, 0) + f.healthBGFrame:SetPoint("BOTTOMRIGHT", f.health, "BOTTOMRIGHT", 0, 0) + f.healthBGFrame:Hide() + end + if f.powerBGFrame then + f.powerBGFrame:ClearAllPoints() + f.powerBGFrame:SetPoint("TOPLEFT", f.power, "TOPLEFT", 0, 0) + f.powerBGFrame:SetPoint("BOTTOMRIGHT", f.power, "BOTTOMRIGHT", 0, 0) + f.powerBGFrame:Hide() + end + if f.health and f.health.bg then f.health.bg:Hide() end + if f.power and f.power.bg then f.power.bg:Hide() end + else + SFrames:ApplyConfiguredUnitBackdrop(f, "focus") + if f.healthBGFrame then SFrames:ApplyConfiguredUnitBackdrop(f.healthBGFrame, "focus") end + if f.powerBGFrame then SFrames:ApplyConfiguredUnitBackdrop(f.powerBGFrame, "focus") end + SFrames:RemoveGradientStyle(f.health) + SFrames:RemoveGradientStyle(f.power) + if f.healthBGFrame then f.healthBGFrame:Show() end + if f.powerBGFrame then f.powerBGFrame:Show() end + if f.health and f.health.bg then f.health.bg:Show() end + if f.power and f.power.bg then f.power.bg:Show() end end -- Castbar anchors @@ -1264,11 +1303,7 @@ function SFrames.Focus:ApplySettings() 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 + f.castbar:SetPoint("BOTTOMRIGHT", f, "TOPRIGHT", -(cbH + 6), 6) if not showCastBar then f.castbar:Hide() if f.castbar.cbbg then f.castbar.cbbg:Hide() end @@ -1298,8 +1333,6 @@ function SFrames.Focus:ApplySettings() self:UpdateAuras() end - -- Force portrait refresh - f._lastPortraitUID = nil if self:GetFocusName() then self:UpdateAll() end @@ -1336,7 +1369,6 @@ function SFrames.Focus:Initialize() 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) @@ -1378,7 +1410,6 @@ function SFrames.Focus:Initialize() 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 @@ -1512,7 +1543,7 @@ function SFrames.Focus:Initialize() focusSelf.frame.health:SetValue(hp) if maxHp > 0 then local pct = math.floor(hp / maxHp * 100) - focusSelf.frame.healthText:SetText(hp .. " / " .. maxHp .. " (" .. pct .. "%)") + focusSelf.frame.healthText:SetText(SFrames:FormatCompactPair(hp, maxHp) .. " (" .. pct .. "%)") else focusSelf.frame.healthText:SetText("") end @@ -1526,7 +1557,7 @@ function SFrames.Focus:Initialize() focusSelf.frame.power:SetMinMaxValues(0, maxPower) focusSelf.frame.power:SetValue(power) if maxPower > 0 then - focusSelf.frame.powerText:SetText(power .. " / " .. maxPower) + focusSelf.frame.powerText:SetText(SFrames:FormatCompactPair(power, maxPower)) else focusSelf.frame.powerText:SetText("") end @@ -1545,22 +1576,13 @@ function SFrames.Focus:Initialize() focusSelf.frame.power:SetMinMaxValues(0, maxPower) focusSelf.frame.power:SetValue(power) if maxPower > 0 then - focusSelf.frame.powerText:SetText(power .. " / " .. maxPower) + focusSelf.frame.powerText:SetText(SFrames:FormatCompactPair(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) @@ -1585,8 +1607,6 @@ function SFrames.Focus:Initialize() 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() @@ -1594,31 +1614,19 @@ function SFrames.Focus:Initialize() 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 + -- Register mover (Y aligned with pet frame, X aligned with target frame) if SFrames.Movers and SFrames.Movers.RegisterMover then if SFramesTargetFrame then SFrames.Movers:RegisterMover("FocusFrame", self.frame, "焦点", - "TOPLEFT", "SFramesTargetFrame", "BOTTOMLEFT", 0, -10) + "TOPLEFT", "SFramesTargetFrame", "BOTTOMLEFT", 0, -75, + nil, { alwaysShowInLayout = true }) else SFrames.Movers:RegisterMover("FocusFrame", self.frame, "焦点", - "LEFT", "UIParent", "LEFT", 250, 0) + "LEFT", "UIParent", "LEFT", 250, 0, + nil, { alwaysShowInLayout = true }) end end diff --git a/GearScore.lua b/GearScore.lua index 9037ea2..3c73df7 100644 --- a/GearScore.lua +++ b/GearScore.lua @@ -1364,20 +1364,6 @@ function GS:HookTooltips() end end - local origRef = SetItemRef - if origRef then - SetItemRef = function(link, text, button) - origRef(link, text, button) - if IsAltKeyDown() or IsShiftKeyDown() or IsControlKeyDown() then return end - pcall(function() - local _, _, itemStr = string.find(link or "", "(item:[%-?%d:]+)") - if itemStr then - ItemRefTooltip._gsScoreAdded = nil - GS:AddScoreToTooltip(ItemRefTooltip, itemStr) - end - end) - end - end end -------------------------------------------------------------------------------- diff --git a/LootDisplay.lua b/LootDisplay.lua index c03b088..6289d3c 100644 --- a/LootDisplay.lua +++ b/LootDisplay.lua @@ -496,20 +496,31 @@ ShowLootPage = function() row:Show() end - -- Let the ORIGINAL Blizzard LootFrame_Update run so that native - -- LootButton1-4 get their IDs, slot data, and OnClick set up - -- through the trusted native code path (required for LootSlot). + -- Set up the native LootFrame so it stays alive (required by the + -- engine) but completely invisible. We do NOT call origLootFrameUpdate + -- because it uses a different items-per-page (3 when paginated vs our 4) + -- which mis-calculates slot indices. if LootFrame then + LootFrame.numLootItems = numItems LootFrame.page = page if not LootFrame:IsShown() then LootFrame:Show() end end - if origLootFrameUpdate then origLootFrameUpdate() end - -- Now reposition the native buttons on top of our visual rows + -- Directly configure native LootButtons with the correct slot for + -- each visual row. SetSlot is the C++ binding that the native + -- LootButton_OnClick / LootFrameItem_OnClick reads via this.slot. for btnIdx = 1, ITEMS_PER_PAGE do local nb = _G["LootButton" .. btnIdx] local row = lootRows[btnIdx] if nb and row and row:IsShown() and row._qualColor then + local realSlot = row.slotIndex + + -- SetSlot is the native C++ method; .slot is the Lua mirror. + if nb.SetSlot then nb:SetSlot(realSlot) end + nb.slot = realSlot + local _, _, _, rq = GetLootSlotInfo(realSlot) + nb.quality = rq or 0 + nb:ClearAllPoints() nb:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) nb:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0) @@ -521,10 +532,10 @@ ShowLootPage = function() nb._nanamiRow = row nb:SetScript("OnEnter", function() - local slot = this:GetID() - if slot then + local s = this.slot + if s then GameTooltip:SetOwner(this, "ANCHOR_RIGHT") - GameTooltip:SetLootItem(slot) + GameTooltip:SetLootItem(s) if CursorUpdate then CursorUpdate() end end local r2 = this._nanamiRow @@ -724,14 +735,15 @@ local function GetAlertFrame() return CreateAlertFrame() end +-- Layout: newest item at bottom (index 1 = oldest = top, last = newest = bottom slot 0) local function LayoutAlerts() CreateAlertAnchor() - for i = 1, table.getn(activeAlerts) do + local n = table.getn(activeAlerts) + for i = 1, n do local af = activeAlerts[i] - if af._fadeState ~= "fading" then - af:ClearAllPoints() - af:SetPoint("BOTTOMLEFT", alertAnchor, "BOTTOMLEFT", 0, (i - 1) * (ALERT_HEIGHT + ALERT_GAP)) - end + -- oldest at top, newest at bottom: slot = (n - i) + af:ClearAllPoints() + af:SetPoint("BOTTOMLEFT", alertAnchor, "BOTTOMLEFT", 0, (n - i) * (ALERT_HEIGHT + ALERT_GAP)) end end @@ -750,36 +762,15 @@ local function RemoveAlert(frame) LayoutAlerts() end -local function StartAlertFade(frame, delay) - frame._fadeState = "waiting" - frame._fadeElapsed = 0 - frame._fadeDelay = delay +-- Each alert has its own independent timer; when it expires, just disappear (no float animation) +local function StartAlertTimer(frame, delay) + frame._timerElapsed = 0 + frame._timerDelay = delay frame:SetScript("OnUpdate", function() - this._fadeElapsed = (this._fadeElapsed or 0) + arg1 - - if this._fadeState == "waiting" then - if this._fadeElapsed >= this._fadeDelay then - this._fadeState = "fading" - this._fadeElapsed = 0 - this._baseY = 0 - for idx = 1, table.getn(activeAlerts) do - if activeAlerts[idx] == this then - this._baseY = (idx - 1) * (ALERT_HEIGHT + ALERT_GAP) - break - end - end - end - elseif this._fadeState == "fading" then - local p = this._fadeElapsed / ALERT_FADE_DUR - if p >= 1 then - RemoveAlert(this) - else - this:SetAlpha(1 - p) - this:ClearAllPoints() - this:SetPoint("BOTTOMLEFT", alertAnchor, "BOTTOMLEFT", - 0, this._baseY + p * ALERT_FLOAT) - end + this._timerElapsed = (this._timerElapsed or 0) + arg1 + if this._timerElapsed >= this._timerDelay then + RemoveAlert(this) end end) end @@ -794,14 +785,15 @@ local function ShowLootAlert(texture, name, quality, quantity, link) local db = GetDB() if not db.alertEnable then return end + -- Stack same item: update count and reset timer for i = 1, table.getn(activeAlerts) do local af = activeAlerts[i] - if af._itemName == name and af._fadeState == "waiting" then + if af._itemName == name then af._quantity = (af._quantity or 1) + (quantity or 1) if af._quantity > 1 then af.countFS:SetText("x" .. af._quantity) end - af._fadeElapsed = 0 + af._timerElapsed = 0 return end end @@ -815,7 +807,6 @@ local function ShowLootAlert(texture, name, quality, quantity, link) f._quantity = quantity or 1 f._link = link - -- Set icon texture local iconTex = texture or "Interface\\Icons\\INV_Misc_QuestionMark" f.icon:SetTexture(iconTex) @@ -841,12 +832,12 @@ local function ShowLootAlert(texture, name, quality, quantity, link) f:SetAlpha(1) f:Show() + -- New item appended to end of list = bottom position table.insert(activeAlerts, f) LayoutAlerts() local hold = db.alertFadeDelay or ALERT_HOLD - local stagger = table.getn(activeAlerts) * ALERT_STAGGER - StartAlertFade(f, hold + stagger) + StartAlertTimer(f, hold) end -------------------------------------------------------------------------------- @@ -1000,8 +991,9 @@ function LD:Initialize() end end - -- After the native LootFrame_Update runs (called by the engine or - -- by us), reposition native buttons onto our visual rows. + -- Replace LootFrame_Update: run the original for engine compatibility, + -- then re-apply the correct slot on each native button based on our + -- visual rows (which use ITEMS_PER_PAGE=4, not the native 3-when-paged). LootFrame_Update = function() if origLootFrameUpdate then origLootFrameUpdate() end if not (lootFrame and lootFrame:IsShown()) then return end @@ -1009,6 +1001,11 @@ function LD:Initialize() local nb = _G["LootButton" .. i] local row = lootRows[i] if nb and row and row:IsShown() and row._qualColor then + local realSlot = row.slotIndex + if realSlot then + if nb.SetSlot then nb:SetSlot(realSlot) end + nb.slot = realSlot + end nb:ClearAllPoints() nb:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) nb:SetPoint("BOTTOMRIGHT", row, "BOTTOMRIGHT", 0, 0) diff --git a/Mail.lua b/Mail.lua index 7e4ea99..37bc932 100644 --- a/Mail.lua +++ b/Mail.lua @@ -45,6 +45,7 @@ local S = { inboxRows = {}, currentTab = 1, inboxPage = 1, + bagPage = 1, inboxChecked = {}, collectQueue = {}, collectTimer = nil, @@ -53,11 +54,23 @@ local S = { isSending = false, collectElapsed = 0, multiSend = nil, -- active multi-send state table + codMode = false, -- send panel: 付款取信 mode toggle } local L = { W = 360, H = 480, HEADER = 34, PAD = 12, TAB_H = 28, BOTTOM = 46, ROWS = 8, ROW_H = 38, ICON = 30, MAX_SEND = 12, + BAG_SLOT = 38, BAG_GAP = 3, BAG_PER_ROW = 8, BAG_ROWS = 8, +} + +StaticPopupDialogs["NANAMI_MAIL_COD_CONFIRM"] = { + text = "确认支付 %s 取回此物品?", + button1 = "确认", + button2 = "取消", + OnAccept = function() end, + timeout = 0, + whileDead = true, + hideOnEscape = true, } -------------------------------------------------------------------------------- @@ -476,18 +489,27 @@ local function UpdateInbox() if money and money > 0 then row.moneyFrame:SetMoney(money) elseif CODAmount and CODAmount > 0 then - row.codFS:SetText("COD:"); row.codFS:Show() + row.codFS:SetText("付款:"); row.codFS:Show() row.moneyFrame:SetMoney(CODAmount) else row.moneyFrame:SetMoney(0) end row.expiryFS:SetText(FormatExpiry(daysLeft)) - local canTake = (hasItem or (money and money > 0)) and (not CODAmount or CODAmount == 0) + local canTake = hasItem or (money and money > 0) row.takeBtn:SetDisabled(not canTake) row.takeBtn:SetScript("OnClick", function() if row.mailIndex then if hasItem then - TakeInboxItem(row.mailIndex) + if CODAmount and CODAmount > 0 then + local codStr = FormatMoneyString(CODAmount) + local idx = row.mailIndex + StaticPopupDialogs["NANAMI_MAIL_COD_CONFIRM"].OnAccept = function() + TakeInboxItem(idx) + end + StaticPopup_Show("NANAMI_MAIL_COD_CONFIRM", codStr) + else + TakeInboxItem(row.mailIndex) + end elseif money and money > 0 then local idx = row.mailIndex TakeInboxMoney(idx) @@ -543,7 +565,7 @@ end local function StopCollecting() S.isCollecting = false; S.collectQueue = {}; S.collectPendingDelete = nil if S.collectTimer then S.collectTimer:SetScript("OnUpdate", nil) end - UpdateInbox() + if S.currentTab == 3 then ML:UpdateMailBag() else UpdateInbox() end end local function ProcessCollectQueue() @@ -683,6 +705,8 @@ end local function ResetSendForm() if not S.frame then return end ClearSendItems() + S.codMode = false + if S.frame.UpdateMoneyToggle then S.frame.UpdateMoneyToggle() end if S.frame.toEditBox then S.frame.toEditBox:SetText("") end if S.frame.subjectEditBox then S.frame.subjectEditBox:SetText("") end if S.frame.bodyEditBox then S.frame.bodyEditBox:SetText("") end @@ -733,8 +757,15 @@ local function DoMultiSend(recipient, subject, body, money) local items = {} for i = 1, table.getn(S.sendQueue) do table.insert(items, S.sendQueue[i]) end - -- No attachments: plain text / money mail + -- No attachments: plain text / money mail (付款取信 requires attachments) if table.getn(items) == 0 then + if S.codMode and money and money > 0 then + DEFAULT_CHAT_FRAME:AddMessage("|cFFFF6666[Nanami-Mail]|r 付款取信模式需要添加附件物品") + if S.frame and S.frame.sendBtn then + S.frame.sendBtn.label:SetText("发送"); S.frame.sendBtn:SetDisabled(false) + end + return + end if money and money > 0 then SetSendMailMoney(money) end SendMail(recipient, subject, body or "") return @@ -746,6 +777,7 @@ local function DoMultiSend(recipient, subject, body, money) subject = subject or "", body = body or "", money = money, + codMode = S.codMode, total = table.getn(items), sentCount = 0, phase = "attach", -- "attach" → "wait_send" → "cooldown" → "attach" ... @@ -803,9 +835,13 @@ local function DoMultiSend(recipient, subject, body, money) return end - -- Money only on first mail - if ms.sentCount == 1 and ms.money and ms.money > 0 then - SetSendMailMoney(ms.money) + -- Money or 付款取信 + if ms.money and ms.money > 0 then + if ms.codMode then + SetSendMailCOD(ms.money) + elseif ms.sentCount == 1 then + SetSendMailMoney(ms.money) + end end -- Send this single-attachment mail @@ -903,13 +939,18 @@ local function BuildMainFrame() sep:SetPoint("TOPRIGHT", f, "TOPRIGHT", -6, -L.HEADER) sep:SetVertexColor(T.divider[1], T.divider[2], T.divider[3], T.divider[4]) - local tabInbox = CreateTabBtn(f, "收件箱", 70) + local tabInbox = CreateTabBtn(f, "收件箱", 62) tabInbox:SetPoint("TOPLEFT", f, "TOPLEFT", L.PAD, -(L.HEADER + 6)) tabInbox:SetScript("OnClick", function() S.currentTab = 1; ML:ShowInboxPanel() end) f.tabInbox = tabInbox - local tabSend = CreateTabBtn(f, "发送", 70) - tabSend:SetPoint("LEFT", tabInbox, "RIGHT", 4, 0) + local tabBag = CreateTabBtn(f, "邮包", 50) + tabBag:SetPoint("LEFT", tabInbox, "RIGHT", 4, 0) + tabBag:SetScript("OnClick", function() S.currentTab = 3; ML:ShowMailBagPanel() end) + f.tabBag = tabBag + + local tabSend = CreateTabBtn(f, "发送", 62) + tabSend:SetPoint("LEFT", tabBag, "RIGHT", 4, 0) tabSend:SetScript("OnClick", function() S.currentTab = 2; ML:ShowSendPanel() end) f.tabSend = tabSend @@ -1128,16 +1169,25 @@ function ML:ShowMailDetail(mailIndex) dp.detailMoney:SetMoney(money); dp.detailMoney:Show() end if CODAmount and CODAmount > 0 then - dp.detailCodLabel:SetText("COD:"); dp.detailCodLabel:Show() + dp.detailCodLabel:SetText("付款取信:"); dp.detailCodLabel:Show() dp.detailCod:SetMoney(CODAmount); dp.detailCod:Show() end -- Take items button - local canTakeItem = hasItem and (not CODAmount or CODAmount == 0) + local canTakeItem = hasItem dp.takeItemBtn:SetDisabled(not canTakeItem) dp.takeItemBtn:SetScript("OnClick", function() if S.detailMailIndex then - TakeInboxItem(S.detailMailIndex) + if CODAmount and CODAmount > 0 then + local codStr = FormatMoneyString(CODAmount) + local idx = S.detailMailIndex + StaticPopupDialogs["NANAMI_MAIL_COD_CONFIRM"].OnAccept = function() + TakeInboxItem(idx) + end + StaticPopup_Show("NANAMI_MAIL_COD_CONFIRM", codStr) + else + TakeInboxItem(S.detailMailIndex) + end end end) @@ -1205,6 +1255,294 @@ function ML:HideMailDetail() UpdateInbox() end +-------------------------------------------------------------------------------- +-- BUILD: Mail Bag panel (grid view of all inbox items) +-------------------------------------------------------------------------------- +local function BuildMailBagPanel() + local f = S.frame + local panelTop = L.HEADER + 6 + L.TAB_H + 4 + local bp = CreateFrame("Frame", nil, f) + bp:SetPoint("TOPLEFT", f, "TOPLEFT", 0, -panelTop) + bp:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0) + bp:Hide() + f.bagPanel = bp + + local font = GetFont() + local slotsPerPage = L.BAG_PER_ROW * L.BAG_ROWS + + local infoFS = bp:CreateFontString(nil, "OVERLAY") + infoFS:SetFont(font, 10, "OUTLINE") + infoFS:SetPoint("TOPLEFT", bp, "TOPLEFT", L.PAD, -2) + infoFS:SetTextColor(T.dimText[1], T.dimText[2], T.dimText[3]) + f.bagInfoFS = infoFS + + local codLegend = bp:CreateFontString(nil, "OVERLAY") + codLegend:SetFont(font, 9, "OUTLINE") + codLegend:SetPoint("TOPRIGHT", bp, "TOPRIGHT", -L.PAD, -2) + codLegend:SetText("|cFFFF5555■|r 付款取信") + codLegend:SetTextColor(T.dimText[1], T.dimText[2], T.dimText[3]) + + f.bagSlots = {} + local gridTop = 16 + for i = 1, slotsPerPage do + local row = math.floor((i - 1) / L.BAG_PER_ROW) + local col = math.mod((i - 1), L.BAG_PER_ROW) + local sf = CreateFrame("Button", "SFramesMailBagSlot" .. i, bp) + sf:SetWidth(L.BAG_SLOT); sf:SetHeight(L.BAG_SLOT) + sf:SetPoint("TOPLEFT", bp, "TOPLEFT", + L.PAD + col * (L.BAG_SLOT + L.BAG_GAP), + -(gridTop + row * (L.BAG_SLOT + L.BAG_GAP))) + sf: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 }, + }) + sf:SetBackdropColor(T.slotBg[1], T.slotBg[2], T.slotBg[3], T.slotBg[4]) + sf:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) + + local ico = sf:CreateTexture(nil, "ARTWORK") + ico:SetTexCoord(0.08, 0.92, 0.08, 0.92) + ico:SetPoint("TOPLEFT", 3, -3); ico:SetPoint("BOTTOMRIGHT", -3, 3) + ico:Hide() + sf.icon = ico + + local cnt = sf:CreateFontString(nil, "OVERLAY") + cnt:SetFont(font, 11, "OUTLINE") + cnt:SetPoint("BOTTOMRIGHT", sf, "BOTTOMRIGHT", -2, 2) + cnt:SetJustifyH("RIGHT") + sf.countFS = cnt + + local moneyFS = sf:CreateFontString(nil, "OVERLAY") + moneyFS:SetFont(font, 8, "OUTLINE") + moneyFS:SetPoint("BOTTOM", sf, "BOTTOM", 0, 2) + moneyFS:SetWidth(L.BAG_SLOT) + moneyFS:SetJustifyH("CENTER") + moneyFS:Hide() + sf.moneyFS = moneyFS + + local codFS = sf:CreateFontString(nil, "OVERLAY") + codFS:SetFont(font, 7, "OUTLINE") + codFS:SetPoint("TOP", sf, "TOP", 0, -1) + codFS:SetText("|cFFFF3333付款|r") + codFS:Hide() + sf.codFS = codFS + + sf.mailData = nil + sf:RegisterForClicks("LeftButtonUp") + sf:SetScript("OnClick", function() + local data = this.mailData + if not data then return end + if data.codAmount and data.codAmount > 0 then + local codStr = FormatMoneyString(data.codAmount) + local idx = data.mailIndex + StaticPopupDialogs["NANAMI_MAIL_COD_CONFIRM"].OnAccept = function() + TakeInboxItem(idx) + end + StaticPopup_Show("NANAMI_MAIL_COD_CONFIRM", codStr) + elseif data.hasItem then + TakeInboxItem(data.mailIndex) + elseif data.money and data.money > 0 then + local idx = data.mailIndex + TakeInboxMoney(idx) + if not S.deleteTimer then S.deleteTimer = CreateFrame("Frame") end + S.deleteElapsed = 0 + S.deleteTimer:SetScript("OnUpdate", function() + S.deleteElapsed = S.deleteElapsed + arg1 + if S.deleteElapsed >= 0.5 then + this:SetScript("OnUpdate", nil) + if idx <= GetInboxNumItems() then DeleteInboxItem(idx) end + end + end) + end + end) + sf:SetScript("OnEnter", function() + this:SetBackdropBorderColor(T.slotHover[1], T.slotHover[2], T.slotHover[3], T.slotHover[4]) + local data = this.mailData + if not data then return end + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + if data.hasItem then + pcall(GameTooltip.SetInboxItem, GameTooltip, data.mailIndex) + else + GameTooltip:AddLine("金币邮件", 1, 0.84, 0) + end + GameTooltip:AddLine(" ") + if data.sender then + GameTooltip:AddLine("发件人: " .. data.sender, 0.7, 0.7, 0.7) + end + if data.subject and data.subject ~= "" then + GameTooltip:AddLine(data.subject, 0.5, 0.5, 0.5) + end + if data.codAmount and data.codAmount > 0 then + GameTooltip:AddLine(" ") + GameTooltip:AddLine("付款取信: " .. FormatMoneyString(data.codAmount), 1, 0.3, 0.3) + GameTooltip:AddLine("|cFFFFCC00点击支付并取回|r") + elseif data.money and data.money > 0 and not data.hasItem then + GameTooltip:AddLine(" ") + GameTooltip:AddLine("金额: " .. FormatMoneyString(data.money), 1, 0.84, 0) + GameTooltip:AddLine("|cFFFFCC00点击收取金币|r") + elseif data.hasItem then + GameTooltip:AddLine(" ") + GameTooltip:AddLine("|cFFFFCC00点击收取物品|r") + end + GameTooltip:Show() + end) + sf:SetScript("OnLeave", function() + local data = this.mailData + if data and data.codAmount and data.codAmount > 0 then + this:SetBackdropBorderColor(1, 0.3, 0.3, 0.8) + else + this:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) + end + GameTooltip:Hide() + end) + + f.bagSlots[i] = sf + end + + local bsep = bp:CreateTexture(nil, "ARTWORK") + bsep:SetTexture("Interface\\Buttons\\WHITE8X8"); bsep:SetHeight(1) + bsep:SetPoint("BOTTOMLEFT", bp, "BOTTOMLEFT", 6, L.BOTTOM) + bsep:SetPoint("BOTTOMRIGHT", bp, "BOTTOMRIGHT", -6, L.BOTTOM) + bsep:SetVertexColor(T.divider[1], T.divider[2], T.divider[3], T.divider[4]) + + local prev = CreateActionBtn(bp, "<", 28) + prev:SetHeight(22); prev:SetPoint("BOTTOMLEFT", bp, "BOTTOMLEFT", L.PAD, 12) + prev:SetScript("OnClick", function() S.bagPage = S.bagPage - 1; ML:UpdateMailBag() end) + f.bagPrevBtn = prev + + local nxt = CreateActionBtn(bp, ">", 28) + nxt:SetHeight(22); nxt:SetPoint("LEFT", prev, "RIGHT", 4, 0) + nxt:SetScript("OnClick", function() S.bagPage = S.bagPage + 1; ML:UpdateMailBag() end) + f.bagNextBtn = nxt + + local pageFS = bp:CreateFontString(nil, "OVERLAY") + pageFS:SetFont(font, 10, "OUTLINE") + pageFS:SetPoint("LEFT", nxt, "RIGHT", 8, 0) + pageFS:SetTextColor(T.dimText[1], T.dimText[2], T.dimText[3]) + f.bagPageFS = pageFS + + local colAll = CreateActionBtn(bp, "全部收取", 80) + colAll:SetHeight(24); colAll:SetPoint("BOTTOMRIGHT", bp, "BOTTOMRIGHT", -L.PAD, 10) + colAll:SetScript("OnClick", function() + if S.isCollecting then StopCollecting() else CollectAll() end + end) + f.bagCollectAllBtn = colAll +end + +-------------------------------------------------------------------------------- +-- Mail Bag: Update +-------------------------------------------------------------------------------- +function ML:UpdateMailBag() + if not S.frame or not S.frame:IsVisible() or S.currentTab ~= 3 then return end + local slotsPerPage = L.BAG_PER_ROW * L.BAG_ROWS + local numMails = GetInboxNumItems() + + local entries = {} + for mi = 1, numMails do + local _, _, sender, subject, money, CODAmount, daysLeft, hasItem = GetInboxHeaderInfo(mi) + if hasItem or (money and money > 0) or (CODAmount and CODAmount > 0) then + local itemName, itemTex + if hasItem then itemName, itemTex = GetInboxItem(mi) end + table.insert(entries, { + mailIndex = mi, + hasItem = hasItem, + itemName = itemName, + itemTexture = itemTex, + money = money or 0, + codAmount = CODAmount or 0, + sender = sender or "未知", + subject = subject or "", + daysLeft = daysLeft, + }) + end + end + + local totalEntries = table.getn(entries) + local totalPages = math.max(1, math.ceil(totalEntries / slotsPerPage)) + if S.bagPage > totalPages then S.bagPage = totalPages end + if S.bagPage < 1 then S.bagPage = 1 end + + S.frame.bagInfoFS:SetText(string.format("共 %d 件可收取 (%d 封邮件)", totalEntries, numMails)) + S.frame.bagPageFS:SetText(string.format("第 %d/%d 页", S.bagPage, totalPages)) + S.frame.bagPrevBtn:SetDisabled(S.bagPage <= 1) + S.frame.bagNextBtn:SetDisabled(S.bagPage >= totalPages) + S.frame.bagCollectAllBtn:SetDisabled(numMails == 0 and not S.isCollecting) + if S.isCollecting then + S.frame.bagCollectAllBtn.label:SetText("收取中...") + else + S.frame.bagCollectAllBtn.label:SetText("全部收取") + end + + for i = 1, slotsPerPage do + local slot = S.frame.bagSlots[i] + local ei = (S.bagPage - 1) * slotsPerPage + i + local entry = entries[ei] + if entry then + slot.mailData = entry + if entry.hasItem and entry.itemTexture then + slot.icon:SetTexture(entry.itemTexture) + elseif entry.money > 0 then + slot.icon:SetTexture("Interface\\Icons\\INV_Misc_Coin_01") + else + slot.icon:SetTexture("Interface\\Icons\\INV_Misc_Note_01") + end + slot.icon:Show() + slot.countFS:SetText("") + slot.moneyFS:Hide() + + if entry.money > 0 and not entry.hasItem then + local g = math.floor(entry.money / 10000) + if g > 0 then + slot.moneyFS:SetText("|cFFFFD700" .. g .. "g|r") + else + local sv = math.floor(math.mod(entry.money, 10000) / 100) + if sv > 0 then + slot.moneyFS:SetText("|cFFC7C7CF" .. sv .. "s|r") + else + local cv = math.mod(entry.money, 100) + slot.moneyFS:SetText("|cFFB87333" .. cv .. "c|r") + end + end + slot.moneyFS:Show() + end + + if entry.codAmount > 0 then + slot.codFS:Show() + slot:SetBackdropBorderColor(1, 0.3, 0.3, 0.8) + else + slot.codFS:Hide() + slot:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) + end + slot:Show() + else + slot.mailData = nil + slot.icon:Hide() + slot.countFS:SetText("") + slot.moneyFS:Hide() + slot.codFS:Hide() + slot:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) + slot:Show() + end + end +end + +-------------------------------------------------------------------------------- +-- Mail Bag: Panel Switching +-------------------------------------------------------------------------------- +function ML:ShowMailBagPanel() + if not S.frame then return end + S.frame.tabInbox:SetActive(false) + S.frame.tabBag:SetActive(true) + S.frame.tabSend:SetActive(false) + if S.frame.detailPanel then S.frame.detailPanel:Hide() end + S.detailMailIndex = nil + S.frame.inboxPanel:Hide() + S.frame.sendPanel:Hide() + S.frame.bagPanel:Show() + ML:UpdateMailBag() +end + -------------------------------------------------------------------------------- -- BUILD: Send panel -------------------------------------------------------------------------------- @@ -1555,29 +1893,56 @@ local function BuildSendPanel() bsf:SetScrollChild(bodyEB) f.bodyEditBox = bodyEB - -- Money row - local mLabel = sp:CreateFontString(nil, "OVERLAY") - mLabel:SetFont(font, 11, "OUTLINE"); mLabel:SetPoint("TOPLEFT", bsf, "BOTTOMLEFT", 0, -10) - mLabel:SetText("附加金币:"); mLabel:SetTextColor(T.labelText[1], T.labelText[2], T.labelText[3]) + -- Money mode toggle (附加金币 / 付款取信) + local mToggle = CreateActionBtn(sp, "附加金币", 72) + mToggle:SetHeight(20); mToggle:SetPoint("TOPLEFT", bsf, "BOTTOMLEFT", 0, -10) + f.moneyToggle = mToggle + local function UpdateMoneyToggle() + if S.codMode then + mToggle.label:SetText("|cFFFF5555付款取信|r") + mToggle:SetBackdropBorderColor(1, 0.3, 0.3, 0.8) + else + mToggle.label:SetText("附加金币") + mToggle:SetBackdropBorderColor(T.btnBorder[1], T.btnBorder[2], T.btnBorder[3], T.btnBorder[4]) + end + end + f.UpdateMoneyToggle = UpdateMoneyToggle + mToggle:SetScript("OnClick", function() + S.codMode = not S.codMode; UpdateMoneyToggle() + end) + mToggle:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_TOPRIGHT") + if S.codMode then + GameTooltip:AddLine("付款取信模式", 1, 0.5, 0.5) + GameTooltip:AddLine("收件人需支付指定金额才能取回附件", 0.8, 0.8, 0.8) + else + GameTooltip:AddLine("附加金币模式", 1, 0.84, 0) + GameTooltip:AddLine("随邮件附送金币给收件人", 0.8, 0.8, 0.8) + end + GameTooltip:AddLine("|cFFFFCC00点击切换模式|r") + GameTooltip:Show() + end) + mToggle:SetScript("OnLeave", function() GameTooltip:Hide() end) + UpdateMoneyToggle() local gL = sp:CreateFontString(nil, "OVERLAY") - gL:SetFont(font, 10, "OUTLINE"); gL:SetPoint("LEFT", mLabel, "RIGHT", 6, 0) + gL:SetFont(font, 10, "OUTLINE"); gL:SetPoint("LEFT", mToggle, "RIGHT", 6, 0) gL:SetText("金"); gL:SetTextColor(T.moneyGold[1], T.moneyGold[2], T.moneyGold[3]) - local gEB = CreateStyledEditBox(sp, 60, 20, true); gEB:SetPoint("LEFT", gL, "RIGHT", 4, 0); gEB:SetText("0"); f.goldEB = gEB + local gEB = CreateStyledEditBox(sp, 50, 20, true); gEB:SetPoint("LEFT", gL, "RIGHT", 4, 0); gEB:SetText("0"); f.goldEB = gEB local sL = sp:CreateFontString(nil, "OVERLAY") sL:SetFont(font, 10, "OUTLINE"); sL:SetPoint("LEFT", gEB, "RIGHT", 6, 0) sL:SetText("银"); sL:SetTextColor(T.moneySilver[1], T.moneySilver[2], T.moneySilver[3]) - local sEB = CreateStyledEditBox(sp, 40, 20, true); sEB:SetPoint("LEFT", sL, "RIGHT", 4, 0); sEB:SetText("0"); f.silverEB = sEB + local sEB = CreateStyledEditBox(sp, 36, 20, true); sEB:SetPoint("LEFT", sL, "RIGHT", 4, 0); sEB:SetText("0"); f.silverEB = sEB local cL = sp:CreateFontString(nil, "OVERLAY") cL:SetFont(font, 10, "OUTLINE"); cL:SetPoint("LEFT", sEB, "RIGHT", 6, 0) cL:SetText("铜"); cL:SetTextColor(T.moneyCopper[1], T.moneyCopper[2], T.moneyCopper[3]) - local cEB = CreateStyledEditBox(sp, 40, 20, true); cEB:SetPoint("LEFT", cL, "RIGHT", 4, 0); cEB:SetText("0"); f.copperEB = cEB + local cEB = CreateStyledEditBox(sp, 36, 20, true); cEB:SetPoint("LEFT", cL, "RIGHT", 4, 0); cEB:SetText("0"); f.copperEB = cEB -- Attachments local aLabel = sp:CreateFontString(nil, "OVERLAY") - aLabel:SetFont(font, 11, "OUTLINE"); aLabel:SetPoint("TOPLEFT", mLabel, "TOPLEFT", 0, -28) + aLabel:SetFont(font, 11, "OUTLINE"); aLabel:SetPoint("TOPLEFT", mToggle, "TOPLEFT", 0, -28) aLabel:SetText("附件 (右击/拖放背包物品添加):"); aLabel:SetTextColor(T.labelText[1], T.labelText[2], T.labelText[3]) local clrBtn = CreateActionBtn(sp, "清空", 50) @@ -1679,7 +2044,7 @@ local function SetupEvents() f:SetScript("OnEvent", function() if event == "MAIL_SHOW" then if SFramesDB and SFramesDB.enableMail == false then return end - S.currentTab = 1; S.inboxPage = 1; S.inboxChecked = {} + S.currentTab = 1; S.inboxPage = 1; S.bagPage = 1; S.inboxChecked = {} CheckInbox(); f:Show(); ML:ShowInboxPanel() elseif event == "MAIL_INBOX_UPDATE" then if f:IsVisible() then @@ -1689,10 +2054,28 @@ local function SetupEvents() else ML:HideMailDetail() end + elseif S.currentTab == 3 then + ML:UpdateMailBag() else UpdateInbox() end end + -- 收件箱清空后同步小地图信件图标状态 + if MiniMapMailFrame then + if HasNewMail and HasNewMail() then + MiniMapMailFrame:Show() + elseif GetInboxNumItems() == 0 then + MiniMapMailFrame:Hide() + end + end + elseif event == "UPDATE_PENDING_MAIL" then + if MiniMapMailFrame then + if HasNewMail and HasNewMail() then + MiniMapMailFrame:Show() + else + MiniMapMailFrame:Hide() + end + end elseif event == "MAIL_CLOSED" then if S.multiSend then AbortMultiSend("邮箱已关闭") end f:Hide() @@ -1721,6 +2104,7 @@ local function SetupEvents() f:RegisterEvent("MAIL_SHOW"); f:RegisterEvent("MAIL_INBOX_UPDATE") f:RegisterEvent("MAIL_CLOSED"); f:RegisterEvent("MAIL_SEND_SUCCESS") f:RegisterEvent("MAIL_SEND_INFO_UPDATE"); f:RegisterEvent("MAIL_FAILED") + f:RegisterEvent("UPDATE_PENDING_MAIL") if MailFrame then local origMailOnShow = MailFrame:GetScript("OnShow") @@ -1728,6 +2112,7 @@ local function SetupEvents() if origMailOnShow then origMailOnShow() end this:ClearAllPoints() this:SetPoint("TOPLEFT", UIParent, "TOPLEFT", -10000, 10000) + this:SetAlpha(0) this:EnableMouse(false) end) for i = table.getn(UISpecialFrames), 1, -1 do @@ -1772,6 +2157,7 @@ function ML:Initialize() BuildMainFrame() BuildInboxPanel() BuildDetailPanel() + BuildMailBagPanel() BuildSendPanel() SetupEvents() end @@ -1781,19 +2167,19 @@ end -------------------------------------------------------------------------------- function ML:ShowInboxPanel() if not S.frame then return end - S.frame.tabInbox:SetActive(true); S.frame.tabSend:SetActive(false) + S.frame.tabInbox:SetActive(true); S.frame.tabBag:SetActive(false); S.frame.tabSend:SetActive(false) if S.frame.detailPanel then S.frame.detailPanel:Hide() end - S.frame.inboxPanel:Show(); S.frame.sendPanel:Hide() + S.frame.inboxPanel:Show(); S.frame.sendPanel:Hide(); S.frame.bagPanel:Hide() S.detailMailIndex = nil UpdateInbox() end function ML:ShowSendPanel() if not S.frame then return end - S.frame.tabInbox:SetActive(false); S.frame.tabSend:SetActive(true) + S.frame.tabInbox:SetActive(false); S.frame.tabBag:SetActive(false); S.frame.tabSend:SetActive(true) if S.frame.detailPanel then S.frame.detailPanel:Hide() end S.detailMailIndex = nil - S.frame.inboxPanel:Hide(); S.frame.sendPanel:Show() + S.frame.inboxPanel:Hide(); S.frame.sendPanel:Show(); S.frame.bagPanel:Hide() if S.frame.sendStatus then S.frame.sendStatus:SetText("") end if S.statusFadeTimer then S.statusFadeTimer:SetScript("OnUpdate", nil) end ML:UpdateSendPanel() diff --git a/MapIcons.lua b/MapIcons.lua index d776770..377d703 100644 --- a/MapIcons.lua +++ b/MapIcons.lua @@ -127,6 +127,64 @@ local function GetZoneYards() return nil, nil end +local function IsMapStateProtected() + if WorldMapFrame and WorldMapFrame:IsVisible() then + return true + end + if BattlefieldMinimap and BattlefieldMinimap:IsVisible() then + return true + end + if BattlefieldMinimapFrame and BattlefieldMinimapFrame:IsVisible() then + return true + end + return false +end + +local function SafeSetMapToCurrentZone() + if not SetMapToCurrentZone then + return + end + if SFrames and SFrames.CallWithPreservedBattlefieldMinimap then + return SFrames:CallWithPreservedBattlefieldMinimap(SetMapToCurrentZone) + end + return pcall(SetMapToCurrentZone) +end + +local function SafeSetMapZoom(continent, zone) + if not SetMapZoom then + return + end + if SFrames and SFrames.CallWithPreservedBattlefieldMinimap then + return SFrames:CallWithPreservedBattlefieldMinimap(SetMapZoom, continent, zone) + end + return pcall(SetMapZoom, continent, zone) +end + +local function WithPlayerZoneMap(func) + if type(func) ~= "function" then + return + end + if IsMapStateProtected() or not SetMapToCurrentZone then + return func() + end + + local savedC = GetCurrentMapContinent and GetCurrentMapContinent() or 0 + local savedZ = GetCurrentMapZone and GetCurrentMapZone() or 0 + + SafeSetMapToCurrentZone() + local results = { func() } + + if SetMapZoom then + if savedZ and savedZ > 0 and savedC and savedC > 0 then + SafeSetMapZoom(savedC, savedZ) + elseif savedC and savedC > 0 then + SafeSetMapZoom(savedC, 0) + end + end + + return unpack(results) +end + -------------------------------------------------------------------------------- -- 1. World Map + Battlefield Minimap: class icon overlays -------------------------------------------------------------------------------- @@ -224,97 +282,95 @@ local function UpdateMinimapDots() return end - if WorldMapFrame and WorldMapFrame:IsVisible() then + if IsMapStateProtected() then return end - if SetMapToCurrentZone then - pcall(SetMapToCurrentZone) - end + WithPlayerZoneMap(function() + local px, py = GetPlayerMapPosition("player") + if not px or not py or (px == 0 and py == 0) then + for i = 1, MAX_PARTY do + if mmDots[i] then mmDots[i]:Hide() end + end + return + end + + local zw, zh = GetZoneYards() + if not zw or not zh or zw == 0 or zh == 0 then + for i = 1, MAX_PARTY do + if mmDots[i] then mmDots[i]:Hide() end + end + return + end + + local now = GetTime() + if now - indoorCheckTime > 3 then + indoorCheckTime = now + cachedIndoor = DetectIndoor() + end + + local zoom = Minimap:GetZoom() + local mmYards = MM_ZOOM[cachedIndoor] and MM_ZOOM[cachedIndoor][zoom] + or MM_ZOOM[1][zoom] or 466.67 + local mmHalfYards = mmYards / 2 + local mmHalfPx = Minimap:GetWidth() / 2 + + local facing = 0 + local doRotate = false + local okCvar, rotateVal = pcall(GetCVar, "rotateMinimap") + if okCvar and rotateVal == "1" and GetPlayerFacing then + local ok2, f = pcall(GetPlayerFacing) + if ok2 and f then + facing = f + doRotate = true + end + end - local px, py = GetPlayerMapPosition("player") - if not px or not py or (px == 0 and py == 0) then for i = 1, MAX_PARTY do - if mmDots[i] then mmDots[i]:Hide() end - end - return - end + local unit = "party" .. i + if i <= numParty and UnitExists(unit) and UnitIsConnected(unit) then + local mx, my = GetPlayerMapPosition(unit) + if mx and my and (mx ~= 0 or my ~= 0) then + local dx = (mx - px) * zw + local dy = (py - my) * zh - local zw, zh = GetZoneYards() - if not zw or not zh or zw == 0 or zh == 0 then - for i = 1, MAX_PARTY do - if mmDots[i] then mmDots[i]:Hide() end - end - return - end - - local now = GetTime() - if now - indoorCheckTime > 3 then - indoorCheckTime = now - cachedIndoor = DetectIndoor() - end - - local zoom = Minimap:GetZoom() - local mmYards = MM_ZOOM[cachedIndoor] and MM_ZOOM[cachedIndoor][zoom] - or MM_ZOOM[1][zoom] or 466.67 - local mmHalfYards = mmYards / 2 - local mmHalfPx = Minimap:GetWidth() / 2 - - local facing = 0 - local doRotate = false - local okCvar, rotateVal = pcall(GetCVar, "rotateMinimap") - if okCvar and rotateVal == "1" and GetPlayerFacing then - local ok2, f = pcall(GetPlayerFacing) - if ok2 and f then - facing = f - doRotate = true - end - end - - for i = 1, MAX_PARTY do - local unit = "party" .. i - if i <= numParty and UnitExists(unit) and UnitIsConnected(unit) then - local mx, my = GetPlayerMapPosition(unit) - if mx and my and (mx ~= 0 or my ~= 0) then - local dx = (mx - px) * zw - local dy = (py - my) * zh - - if doRotate then - local s = math.sin(facing) - local c = math.cos(facing) - dx, dy = dx * c + dy * s, -dx * s + dy * c - end - - local dist = math.sqrt(dx * dx + dy * dy) - if dist < mmHalfYards * 0.92 then - local scale = mmHalfPx / mmHalfYards - - if not mmDots[i] then - mmDots[i] = CreateMinimapDot(i) + if doRotate then + local s = math.sin(facing) + local c = math.cos(facing) + dx, dy = dx * c + dy * s, -dx * s + dy * c end - local dot = mmDots[i] - local _, class = UnitClass(unit) - local cc = class and CLASS_COLORS and CLASS_COLORS[class] - if cc then - dot.icon:SetVertexColor(cc.r, cc.g, cc.b, 1) + local dist = math.sqrt(dx * dx + dy * dy) + if dist < mmHalfYards * 0.92 then + local scale = mmHalfPx / mmHalfYards + + if not mmDots[i] then + mmDots[i] = CreateMinimapDot(i) + end + local dot = mmDots[i] + + local _, class = UnitClass(unit) + local cc = class and CLASS_COLORS and CLASS_COLORS[class] + if cc then + dot.icon:SetVertexColor(cc.r, cc.g, cc.b, 1) + else + dot.icon:SetVertexColor(1, 0.82, 0, 1) + end + + dot:ClearAllPoints() + dot:SetPoint("CENTER", Minimap, "CENTER", dx * scale, dy * scale) + dot:Show() else - dot.icon:SetVertexColor(1, 0.82, 0, 1) + if mmDots[i] then mmDots[i]:Hide() end end - - dot:ClearAllPoints() - dot:SetPoint("CENTER", Minimap, "CENTER", dx * scale, dy * scale) - dot:Show() else if mmDots[i] then mmDots[i]:Hide() end end else if mmDots[i] then mmDots[i]:Hide() end end - else - if mmDots[i] then mmDots[i]:Hide() end end - end + end) end -------------------------------------------------------------------------------- diff --git a/MapReveal.lua b/MapReveal.lua index 0bf6340..74e40f7 100644 --- a/MapReveal.lua +++ b/MapReveal.lua @@ -34,6 +34,26 @@ local function GetOverlayDB() return MapOverlayData or LibMapOverlayData or zMapOverlayData or mapOverlayData end +local function SafeSetMapToCurrentZone() + if not SetMapToCurrentZone then + return + end + if SFrames and SFrames.CallWithPreservedBattlefieldMinimap then + return SFrames:CallWithPreservedBattlefieldMinimap(SetMapToCurrentZone) + end + return pcall(SetMapToCurrentZone) +end + +local function SafeSetMapZoom(continent, zone) + if not SetMapZoom then + return + end + if SFrames and SFrames.CallWithPreservedBattlefieldMinimap then + return SFrames:CallWithPreservedBattlefieldMinimap(SetMapZoom, continent, zone) + end + return pcall(SetMapZoom, continent, zone) +end + -------------------------------------------------------------------------------- -- Persistence: save/load discovered overlay data to SFramesDB -------------------------------------------------------------------------------- @@ -428,7 +448,7 @@ local function ProcessScanZone() end local entry = scanQueue[scanIndex] - SetMapZoom(entry.cont, entry.zone) + SafeSetMapZoom(entry.cont, entry.zone) local mapFile = GetMapInfo and GetMapInfo() or "" if mapFile == "" then @@ -490,11 +510,11 @@ function MapReveal:FinishScan() end if savedMapZ > 0 then - SetMapZoom(savedMapC, savedMapZ) + SafeSetMapZoom(savedMapC, savedMapZ) elseif savedMapC > 0 then - SetMapZoom(savedMapC, 0) + SafeSetMapZoom(savedMapC, 0) else - if SetMapToCurrentZone then SetMapToCurrentZone() end + SafeSetMapToCurrentZone() end local cf = DEFAULT_CHAT_FRAME diff --git a/Media.lua b/Media.lua index 8105309..8e4dcb2 100644 --- a/Media.lua +++ b/Media.lua @@ -62,6 +62,27 @@ function SFrames:GetTexture() return self.Media.statusbar end +function SFrames:ResolveBarTexture(settingKey, fallbackKey) + local key + if SFramesDB and settingKey then + key = SFramesDB[settingKey] + end + if (not key or key == "") and SFramesDB and fallbackKey then + key = SFramesDB[fallbackKey] + end + if key and key ~= "" then + local builtin = self._barTextureLookup[key] + if builtin then return builtin end + + local LSM = self:GetSharedMedia() + if LSM then + local path = LSM:Fetch("statusbar", key, true) + if path then return path end + end + end + return self:GetTexture() +end + function SFrames:GetFont() -- 1. Check built-in font key if SFramesDB and SFramesDB.fontKey then @@ -79,6 +100,40 @@ function SFrames:GetFont() return self.Media.font end +function SFrames:ResolveFont(settingKey, fallbackKey) + local key + if SFramesDB and settingKey then + key = SFramesDB[settingKey] + end + if (not key or key == "") and SFramesDB and fallbackKey then + key = SFramesDB[fallbackKey] + end + if key and key ~= "" then + local builtin = self._fontLookup[key] + if builtin then return builtin end + local LSM = self:GetSharedMedia() + if LSM then + local path = LSM:Fetch("font", key, true) + if path then return path end + end + end + return self:GetFont() +end + +function SFrames:ResolveFontOutline(settingKey, fallbackKey) + local outline + if SFramesDB and settingKey then + outline = SFramesDB[settingKey] + end + if (not outline or outline == "") and SFramesDB and fallbackKey then + outline = SFramesDB[fallbackKey] + end + if outline and outline ~= "" then + return outline + end + return self.Media.fontOutline or "OUTLINE" +end + function SFrames:GetSharedMediaList(mediaType) local LSM = self:GetSharedMedia() if LSM and LSM.List then return LSM:List(mediaType) end diff --git a/Minimap.lua b/Minimap.lua index 5ce5cf4..3f95e54 100644 --- a/Minimap.lua +++ b/Minimap.lua @@ -93,6 +93,19 @@ local function GetDB() return db end +local function IsMapStateProtected() + if WorldMapFrame and WorldMapFrame:IsVisible() then + return true + end + if BattlefieldMinimap and BattlefieldMinimap:IsVisible() then + return true + end + if BattlefieldMinimapFrame and BattlefieldMinimapFrame:IsVisible() then + return true + end + return false +end + local function ResolveStyleKey() local key = GetDB().mapStyle or "auto" if key == "auto" then @@ -152,6 +165,34 @@ local function ApplyPosition() end end +local function HideTooltipIfOwned(frame) + if not frame or not GameTooltip or not GameTooltip:IsVisible() then + return + end + if GameTooltip.IsOwned and GameTooltip:IsOwned(frame) then + GameTooltip:Hide() + return + end + if GameTooltip.GetOwner and GameTooltip:GetOwner() == frame then + GameTooltip:Hide() + end +end + +local function ForceHideMinimapFrame(frame) + if not frame then return end + HideTooltipIfOwned(frame) + if frame.EnableMouse then + frame:EnableMouse(false) + end + if frame.Hide then + frame:Hide() + end + if frame.SetAlpha then + frame:SetAlpha(0) + end + frame.Show = function() end +end + -------------------------------------------------------------------------------- -- Hide default Blizzard minimap chrome -- MUST be called AFTER BuildFrame (Minimap is already reparented) @@ -165,15 +206,13 @@ local function HideDefaultElements() MinimapToggleButton, MiniMapWorldMapButton, GameTimeFrame, + TimeManagerClockButton, MinimapZoneTextButton, MiniMapTracking, MinimapBackdrop, } for _, f in ipairs(kill) do - if f then - f:Hide() - f.Show = function() end - end + ForceHideMinimapFrame(f) end -- Hide all tracking-related frames (Turtle WoW dual tracking, etc.) @@ -183,10 +222,7 @@ local function HideDefaultElements() } for _, name in ipairs(trackNames) do local f = _G[name] - if f and f.Hide then - f:Hide() - f.Show = function() end - end + ForceHideMinimapFrame(f) end -- Also hide any tracking textures that are children of Minimap @@ -195,8 +231,7 @@ local function HideDefaultElements() for _, child in ipairs(children) do local n = child.GetName and child:GetName() if n and string.find(n, "Track") then - child:Hide() - child.Show = function() end + ForceHideMinimapFrame(child) end end end @@ -455,8 +490,15 @@ local function UpdateZoneText() end local function SetZoneMap() + if IsMapStateProtected() then + return + end if SetMapToCurrentZone then - pcall(SetMapToCurrentZone) + if SFrames and SFrames.CallWithPreservedBattlefieldMinimap then + SFrames:CallWithPreservedBattlefieldMinimap(SetMapToCurrentZone) + else + pcall(SetMapToCurrentZone) + end end end diff --git a/MinimapBuffs.lua b/MinimapBuffs.lua index e9800bb..e91a3eb 100644 --- a/MinimapBuffs.lua +++ b/MinimapBuffs.lua @@ -78,6 +78,43 @@ local DEBUFF_TYPE_COLORS = { local DEBUFF_DEFAULT_COLOR = { r = 0.80, g = 0.00, b = 0.00 } local WEAPON_ENCHANT_COLOR = { r = 0.58, g = 0.22, b = 0.82 } +local function IsTooltipOwnedBy(frame) + if not frame or not GameTooltip or not GameTooltip:IsVisible() then + return false + end + if GameTooltip.IsOwned then + return GameTooltip:IsOwned(frame) + end + if GameTooltip.GetOwner then + return GameTooltip:GetOwner() == frame + end + return false +end + +local function HideOwnedTooltip(frame) + if IsTooltipOwnedBy(frame) then + GameTooltip:Hide() + end +end + +local function SetSlotTooltipKey(btn, key) + if btn._sfTooltipKey ~= key then + HideOwnedTooltip(btn) + btn._sfTooltipKey = key + end +end + +local function ClearSlotState(btn) + HideOwnedTooltip(btn) + btn.buffIndex = -1 + btn._sfSimulated = false + btn._sfSimLabel = nil + btn._sfSimDesc = nil + btn._isWeaponEnchant = false + btn._weaponSlotID = nil + btn._sfTooltipKey = nil +end + local function HideBlizzardBuffs() for i = 0, 23 do local btn = _G["BuffButton" .. i] @@ -170,18 +207,19 @@ local function CreateSlot(parent, namePrefix, index, isBuff) btn:SetScript("OnLeave", function() GameTooltip:Hide() end) + btn:SetScript("OnHide", function() + HideOwnedTooltip(this) + end) btn:SetScript("OnClick", function() if this._sfSimulated then return end if this._isWeaponEnchant then return end if this.isBuff and this.buffIndex and this.buffIndex >= 0 then + HideOwnedTooltip(this) CancelPlayerBuff(this.buffIndex) end end) - btn.buffIndex = -1 - btn._sfSimulated = false - btn._isWeaponEnchant = false - btn._weaponSlotID = nil + ClearSlotState(btn) btn:Hide() return btn end @@ -339,9 +377,12 @@ function MB:UpdateBuffs() local btn = self.buffSlots[slotIdx] local texture = GetPlayerBuffTexture(buffIndex) if texture then + SetSlotTooltipKey(btn, "buff:" .. tostring(buffIndex)) btn.icon:SetTexture(texture) btn.buffIndex = buffIndex btn._sfSimulated = false + btn._sfSimLabel = nil + btn._sfSimDesc = nil btn._isWeaponEnchant = false btn._weaponSlotID = nil @@ -360,10 +401,8 @@ function MB:UpdateBuffs() btn:SetBackdropBorderColor(0.25, 0.25, 0.30, 1) btn:Show() else + ClearSlotState(btn) btn:Hide() - btn.buffIndex = -1 - btn._isWeaponEnchant = false - btn._weaponSlotID = nil end end end @@ -378,9 +417,12 @@ function MB:UpdateBuffs() local btn = self.buffSlots[slotIdx] local texture = GetInventoryItemTexture("player", 16) if texture then + SetSlotTooltipKey(btn, "weapon:16") btn.icon:SetTexture(texture) btn.buffIndex = -1 btn._sfSimulated = false + btn._sfSimLabel = nil + btn._sfSimDesc = nil btn._isWeaponEnchant = true btn._weaponSlotID = 16 @@ -407,9 +449,12 @@ function MB:UpdateBuffs() local btn = self.buffSlots[slotIdx] local texture = GetInventoryItemTexture("player", 17) if texture then + SetSlotTooltipKey(btn, "weapon:17") btn.icon:SetTexture(texture) btn.buffIndex = -1 btn._sfSimulated = false + btn._sfSimLabel = nil + btn._sfSimDesc = nil btn._isWeaponEnchant = true btn._weaponSlotID = 17 @@ -432,10 +477,8 @@ function MB:UpdateBuffs() for j = slotIdx + 1, MAX_BUFFS do local btn = self.buffSlots[j] + ClearSlotState(btn) btn:Hide() - btn.buffIndex = -1 - btn._isWeaponEnchant = false - btn._weaponSlotID = nil end self:UpdateDebuffs() @@ -450,6 +493,7 @@ function MB:UpdateDebuffs() if db.showDebuffs == false then for i = 1, MAX_DEBUFFS do + ClearSlotState(self.debuffSlots[i]) self.debuffSlots[i]:Hide() end if self.debuffContainer then self.debuffContainer:Hide() end @@ -467,9 +511,14 @@ function MB:UpdateDebuffs() local btn = self.debuffSlots[slotIdx] local texture = GetPlayerBuffTexture(buffIndex) if texture then + SetSlotTooltipKey(btn, "debuff:" .. tostring(buffIndex)) btn.icon:SetTexture(texture) btn.buffIndex = buffIndex btn._sfSimulated = false + btn._sfSimLabel = nil + btn._sfSimDesc = nil + btn._isWeaponEnchant = false + btn._weaponSlotID = nil local apps = GetPlayerBuffApplications(buffIndex) if apps and apps > 1 then @@ -495,15 +544,15 @@ function MB:UpdateDebuffs() btn:Show() else + ClearSlotState(btn) btn:Hide() - btn.buffIndex = -1 end end end for j = slotIdx + 1, MAX_DEBUFFS do + ClearSlotState(self.debuffSlots[j]) self.debuffSlots[j]:Hide() - self.debuffSlots[j].buffIndex = -1 end end @@ -556,6 +605,7 @@ function MB:SimulateBuffs() local btn = self.buffSlots[i] local sim = SIM_BUFFS[i] if sim then + SetSlotTooltipKey(btn, "sim-buff:" .. tostring(i)) btn.icon:SetTexture(sim.tex) btn.buffIndex = -1 btn._sfSimulated = true @@ -578,6 +628,7 @@ function MB:SimulateBuffs() btn:SetBackdropBorderColor(0.25, 0.25, 0.30, 1) btn:Show() else + ClearSlotState(btn) btn:Hide() end end @@ -586,11 +637,14 @@ function MB:SimulateBuffs() local btn = self.debuffSlots[i] local sim = SIM_DEBUFFS[i] if sim then + SetSlotTooltipKey(btn, "sim-debuff:" .. tostring(i)) btn.icon:SetTexture(sim.tex) btn.buffIndex = -1 btn._sfSimulated = true btn._sfSimLabel = sim.label btn._sfSimDesc = sim.desc + btn._isWeaponEnchant = false + btn._weaponSlotID = nil btn.timer:SetText(sim.time) ApplyTimerColor(btn, sim.time) @@ -601,6 +655,7 @@ function MB:SimulateBuffs() btn:SetBackdropBorderColor(c.r, c.g, c.b, 1) btn:Show() else + ClearSlotState(btn) btn:Hide() end end diff --git a/Movers.lua b/Movers.lua index b0ae333..ca7b198 100644 --- a/Movers.lua +++ b/Movers.lua @@ -207,8 +207,12 @@ local function SyncMoverToFrame(name) local w = (frame:GetWidth() or 100) * scale local h = (frame:GetHeight() or 50) * scale - mover:SetWidth(math.max(w, 72)) - mover:SetHeight(math.max(h, 40)) + local minW = 72 + local minH = 40 + if h > w * 3 then minW = 40 end + if w > h * 3 then minH = 24 end + mover:SetWidth(math.max(w, minW)) + mover:SetHeight(math.max(h, minH)) local l = frame:GetLeft() local b = frame:GetBottom() @@ -256,17 +260,12 @@ local function SyncFrameToMover(name) local pos = positions[name] if pos then frame:ClearAllPoints() - frame:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) - - local scale = frame:GetEffectiveScale() / UIParent:GetEffectiveScale() - local newL = frame:GetLeft() or 0 - local newB = frame:GetBottom() or 0 - local dL = newL * scale - moverL - local dB = newB * scale - moverB - if math.abs(dL) > 2 or math.abs(dB) > 2 then - SFrames:Print(string.format( - "|cffff6666[位置偏差]|r |cffaaddff%s|r anchor=%s ofs=(%.1f,%.1f) dL=%.1f dB=%.1f scale=%.2f", - entry.label or name, pos.point, pos.xOfs or 0, pos.yOfs or 0, dL, dB, scale)) + local fScale = frame:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fScale > 0.01 and math.abs(fScale - 1) > 0.001 then + frame:SetPoint(pos.point, UIParent, pos.relativePoint, + (pos.xOfs or 0) / fScale, (pos.yOfs or 0) / fScale) + else + frame:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) end end @@ -720,11 +719,14 @@ local function CreateControlBar() local accent = th.accent or { 1, 0.5, 0.8, 0.98 } local titleC = th.title or { 1, 0.88, 1 } + local ROW_Y_TOP = 12 + local ROW_Y_BOT = -12 + controlBar = CreateFrame("Frame", "SFramesLayoutControlBar", UIParent) controlBar:SetFrameStrata("FULLSCREEN_DIALOG") controlBar:SetFrameLevel(200) controlBar:SetWidth(480) - controlBar:SetHeight(44) + controlBar:SetHeight(72) controlBar:SetPoint("TOP", UIParent, "TOP", 0, -8) controlBar:SetClampedToScreen(true) controlBar:SetMovable(true) @@ -739,7 +741,7 @@ local function CreateControlBar() local title = controlBar:CreateFontString(nil, "OVERLAY") title:SetFont(Font(), 13, "OUTLINE") - title:SetPoint("LEFT", controlBar, "LEFT", 14, 0) + title:SetPoint("LEFT", controlBar, "LEFT", 14, ROW_Y_TOP) title:SetText("Nanami 布局") title:SetTextColor(titleC[1], titleC[2], titleC[3], 1) @@ -747,17 +749,19 @@ local function CreateControlBar() sep:SetTexture("Interface\\Buttons\\WHITE8x8") sep:SetWidth(1) sep:SetHeight(24) - sep:SetPoint("LEFT", controlBar, "LEFT", 118, 0) + sep:SetPoint("LEFT", controlBar, "LEFT", 118, ROW_Y_TOP) sep:SetVertexColor(panelBd[1], panelBd[2], panelBd[3], 0.5) local bx = 128 - -- Snap toggle + -- Snap toggle (row 1) local snapBtn = MakeControlButton(controlBar, "", 76, bx, function() local cfg = GetLayoutCfg() cfg.snapEnabled = not cfg.snapEnabled if controlBar._updateSnap then controlBar._updateSnap() end end) + snapBtn:ClearAllPoints() + snapBtn:SetPoint("LEFT", controlBar, "LEFT", bx, ROW_Y_TOP) controlBar.snapBtn = snapBtn local function UpdateSnapBtnText() @@ -773,7 +777,7 @@ local function CreateControlBar() end controlBar._updateSnap = UpdateSnapBtnText - -- Grid toggle + -- Grid toggle (row 1) local gridBtn = MakeControlButton(controlBar, "", 76, bx + 84, function() local cfg = GetLayoutCfg() cfg.showGrid = not cfg.showGrid @@ -782,6 +786,8 @@ local function CreateControlBar() if cfg.showGrid then gridFrame:Show() else gridFrame:Hide() end end end) + gridBtn:ClearAllPoints() + gridBtn:SetPoint("LEFT", controlBar, "LEFT", bx + 84, ROW_Y_TOP) controlBar.gridBtn = gridBtn local function UpdateGridBtnText() @@ -797,18 +803,20 @@ local function CreateControlBar() end controlBar._updateGrid = UpdateGridBtnText - -- Reset all + -- Reset all (row 1) local resetBtn = MakeControlButton(controlBar, "全部重置", 76, bx + 176, function() M:ResetAllMovers() end) + resetBtn:ClearAllPoints() + resetBtn:SetPoint("LEFT", controlBar, "LEFT", bx + 176, ROW_Y_TOP) local wbGold = th.wbGold or { 1, 0.88, 0.55 } resetBtn._text:SetTextColor(wbGold[1], wbGold[2], wbGold[3], 1) - -- Close + -- Close (row 1) local closeBtn = CreateFrame("Button", nil, controlBar) closeBtn:SetWidth(60) closeBtn:SetHeight(26) - closeBtn:SetPoint("RIGHT", controlBar, "RIGHT", -10, 0) + closeBtn:SetPoint("RIGHT", controlBar, "RIGHT", -10, ROW_Y_TOP) closeBtn:SetBackdrop(ROUND_BACKDROP) closeBtn:SetBackdropColor(0.35, 0.08, 0.10, 0.95) closeBtn:SetBackdropBorderColor(0.65, 0.20, 0.25, 0.90) @@ -837,6 +845,64 @@ local function CreateControlBar() end) controlBar.closeBtn = closeBtn + -- Row separator + local rowSep = controlBar:CreateTexture(nil, "ARTWORK") + rowSep:SetTexture("Interface\\Buttons\\WHITE8x8") + rowSep:SetHeight(1) + rowSep:SetPoint("LEFT", controlBar, "LEFT", 10, 0) + rowSep:SetPoint("RIGHT", controlBar, "RIGHT", -10, 0) + rowSep:SetVertexColor(panelBd[1], panelBd[2], panelBd[3], 0.35) + + -- Row 2: Preset buttons + local presetLabel = controlBar:CreateFontString(nil, "OVERLAY") + presetLabel:SetFont(Font(), 10, "OUTLINE") + presetLabel:SetPoint("LEFT", controlBar, "LEFT", 14, ROW_Y_BOT) + presetLabel:SetText("预设:") + presetLabel:SetTextColor(0.7, 0.68, 0.78, 1) + + controlBar._presetBtns = {} + local AB = SFrames.ActionBars + local presets = AB and AB.PRESETS or {} + local px = 56 + for idx = 1, 3 do + local p = presets[idx] + local pName = p and p.name or ("方案" .. idx) + local pDesc = p and p.desc or "" + local pId = idx + local pbtn = MakeControlButton(controlBar, pName, 80, px + (idx - 1) * 88, function() + if AB and AB.ApplyPreset then + AB:ApplyPreset(pId) + end + end) + pbtn:ClearAllPoints() + pbtn:SetPoint("LEFT", controlBar, "LEFT", px + (idx - 1) * 88, ROW_Y_BOT) + pbtn._text:SetFont(Font(), 9, "OUTLINE") + pbtn._presetId = pId + + pbtn:SetScript("OnEnter", function() + local a2 = T().accent or { 1, 0.5, 0.8 } + this:SetBackdropBorderColor(a2[1], a2[2], a2[3], 0.95) + this._text:SetTextColor(1, 1, 1, 1) + GameTooltip:SetOwner(this, "ANCHOR_BOTTOM") + GameTooltip:AddLine(pName, 1, 0.85, 0.55) + GameTooltip:AddLine(pDesc, 0.75, 0.75, 0.85, true) + GameTooltip:Show() + end) + pbtn:SetScript("OnLeave", function() + local th2 = T() + local bd2 = th2.buttonBorder or { 0.35, 0.30, 0.50, 0.90 } + local tc2 = th2.buttonText or { 0.85, 0.82, 0.92 } + this:SetBackdropColor(th2.buttonBg and th2.buttonBg[1] or 0.16, + th2.buttonBg and th2.buttonBg[2] or 0.12, + th2.buttonBg and th2.buttonBg[3] or 0.22, 0.94) + this:SetBackdropBorderColor(bd2[1], bd2[2], bd2[3], bd2[4] or 0.90) + this._text:SetTextColor(tc2[1], tc2[2], tc2[3], 1) + GameTooltip:Hide() + end) + + controlBar._presetBtns[idx] = pbtn + end + controlBar:Hide() return controlBar end @@ -844,7 +910,7 @@ end -------------------------------------------------------------------------------- -- Register mover -------------------------------------------------------------------------------- -function M:RegisterMover(name, frame, label, defaultPoint, defaultRelativeTo, defaultRelPoint, defaultX, defaultY, onMoved) +function M:RegisterMover(name, frame, label, defaultPoint, defaultRelativeTo, defaultRelPoint, defaultX, defaultY, onMoved, opts) if not name or not frame then return end registry[name] = { @@ -856,6 +922,7 @@ function M:RegisterMover(name, frame, label, defaultPoint, defaultRelativeTo, de defaultX = defaultX or 0, defaultY = defaultY or 0, onMoved = onMoved, + alwaysShowInLayout = opts and opts.alwaysShowInLayout or false, } CreateMoverFrame(name, registry[name]) @@ -890,10 +957,19 @@ function M:EnterLayoutMode() UIParent:GetWidth(), UIParent:GetHeight(), UIParent:GetRight() or 0, UIParent:GetTop() or 0)) - for name, _ in pairs(registry) do + for name, entry in pairs(registry) do + local frame = entry and entry.frame + local shouldShow = entry.alwaysShowInLayout + or (frame and frame.IsShown and frame:IsShown()) SyncMoverToFrame(name) local mover = moverFrames[name] - if mover then mover:Show() end + if mover then + if shouldShow then + mover:Show() + else + mover:Hide() + end + end end SFrames:Print("布局模式已开启 - 拖拽移动 | 箭头微调 | 右键重置 | Shift禁用磁吸") @@ -924,6 +1000,22 @@ function M:IsLayoutMode() return isLayoutMode end +function M:SetMoverAlwaysShow(name, alwaysShow) + local entry = registry[name] + if entry then + entry.alwaysShowInLayout = alwaysShow + end + local mover = moverFrames[name] + if mover and isLayoutMode then + if alwaysShow then + SyncMoverToFrame(name) + mover:Show() + else + mover:Hide() + end + end +end + -------------------------------------------------------------------------------- -- Reset movers -------------------------------------------------------------------------------- @@ -979,13 +1071,30 @@ function M:ApplyPosition(name, frame, defaultPoint, defaultRelTo, defaultRelPoin local pos = positions[name] if pos and pos.point and pos.relativePoint then frame:ClearAllPoints() - frame:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + local fScale = frame:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fScale > 0.01 and math.abs(fScale - 1) > 0.001 then + frame:SetPoint(pos.point, UIParent, pos.relativePoint, + (pos.xOfs or 0) / fScale, (pos.yOfs or 0) / fScale) + else + frame:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + end return true else frame:ClearAllPoints() local relFrame = (defaultRelTo and _G[defaultRelTo]) or UIParent - frame:SetPoint(defaultPoint or "CENTER", relFrame, defaultRelPoint or "CENTER", - defaultX or 0, defaultY or 0) + if relFrame == UIParent then + local fScale = frame:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fScale > 0.01 and math.abs(fScale - 1) > 0.001 then + frame:SetPoint(defaultPoint or "CENTER", UIParent, defaultRelPoint or "CENTER", + (defaultX or 0) / fScale, (defaultY or 0) / fScale) + else + frame:SetPoint(defaultPoint or "CENTER", UIParent, defaultRelPoint or "CENTER", + defaultX or 0, defaultY or 0) + end + else + frame:SetPoint(defaultPoint or "CENTER", relFrame, defaultRelPoint or "CENTER", + defaultX or 0, defaultY or 0) + end return false end end @@ -996,3 +1105,7 @@ end function M:GetRegistry() return registry end + +function M:SyncMoverToFrame(name) + SyncMoverToFrame(name) +end diff --git a/Nanami-UI.toc b/Nanami-UI.toc index f7b0af5..c4723fd 100644 --- a/Nanami-UI.toc +++ b/Nanami-UI.toc @@ -9,6 +9,7 @@ Bindings.xml Core.lua Config.lua +AuraTracker.lua Movers.lua Media.lua IconMap.lua @@ -27,7 +28,6 @@ MapIcons.lua Tweaks.lua MinimapBuffs.lua Focus.lua -ClassSkillData.lua Units\Player.lua Units\Pet.lua Units\Target.lua @@ -39,6 +39,7 @@ GearScore.lua Tooltip.lua Units\Raid.lua ActionBars.lua +ExtraBar.lua KeyBindManager.lua Bags\Offline.lua @@ -57,6 +58,8 @@ QuestLogSkin.lua TrainerUI.lua TradeSkillDB.lua BeastTrainingUI.lua +ConsumableDB.lua +ConsumableUI.lua TradeSkillUI.lua CharacterPanel.lua StatSummary.lua diff --git a/SetupWizard.lua b/SetupWizard.lua index 0cc745d..0461b16 100644 --- a/SetupWizard.lua +++ b/SetupWizard.lua @@ -444,6 +444,10 @@ local function ApplyChoices() SFramesDB.enableChat = c.enableChat if type(SFramesDB.Chat) ~= "table" then SFramesDB.Chat = {} end SFramesDB.Chat.translateEnabled = c.translateEnabled + -- 翻译关闭时,聊天监控也自动关闭 + if c.translateEnabled == false then + SFramesDB.Chat.chatMonitorEnabled = false + end SFramesDB.Chat.hcGlobalDisable = c.hcGlobalDisable SFramesDB.enableUnitFrames = c.enableUnitFrames @@ -1316,9 +1320,14 @@ function SW:DoSkip() self:Hide() return end - -- First-run: apply defaults - if not SFramesDB then SFramesDB = {} end - SFramesDB.setupComplete = true + -- First-run: apply defaults then initialize + choices = GetDefaultChoices() + local ok, err = pcall(ApplyChoices) + if not ok then + if not SFramesDB then SFramesDB = {} end + SFramesDB.setupComplete = true + DEFAULT_CHAT_FRAME:AddMessage("|cffff4444[Nanami-UI] Wizard skip apply error: "..tostring(err).."|r") + end self:Hide() if completeCb then completeCb() end end diff --git a/Tooltip.lua b/Tooltip.lua index c6930cc..c179ccd 100644 --- a/Tooltip.lua +++ b/Tooltip.lua @@ -115,6 +115,23 @@ local function TT_DifficultyColor(unitLevel) end end +local function TT_GetClassificationText(unit) + if not UnitExists(unit) then return nil end + + local classif = UnitClassification(unit) + if classif == "rareelite" then + return "|cffc57cff[稀有 精英]|r" + elseif classif == "rare" then + return "|cffc57cff[稀有]|r" + elseif classif == "elite" then + return "|cffffa500[精英]|r" + elseif classif == "worldboss" then + return "|cffff4040[首领]|r" + end + + return nil +end + -------------------------------------------------------------------------------- -- Initialize -------------------------------------------------------------------------------- @@ -680,6 +697,29 @@ function SFrames.FloatingTooltip:FormatLines(tooltip) GameTooltipStatusBar._origSetColor(GameTooltipStatusBar, color.r, color.g, color.b) end end + + local classificationText = TT_GetClassificationText(unit) + if classificationText then + local numLines = tooltip:NumLines() + local appended = false + for i = 2, numLines do + local left = getglobal("GameTooltipTextLeft" .. i) + if left then + local txt = left:GetText() + if txt and (string.find(txt, "^Level ") or string.find(txt, "^等级 ")) then + if not string.find(txt, "%[") then + left:SetText(txt .. " " .. classificationText) + end + appended = true + break + end + end + end + + if not appended then + tooltip:AddLine(classificationText) + end + end end -------------------------------------------------------------------------- @@ -1268,27 +1308,27 @@ function IC:HookTooltips() --------------------------------------------------------------------------- -- SetItemRef (chat item links) --------------------------------------------------------------------------- - local orig_SetItemRef = SetItemRef - if orig_SetItemRef then - SetItemRef = function(link, text, button) - orig_SetItemRef(link, text, button) - if IsAltKeyDown() or IsShiftKeyDown() or IsControlKeyDown() then return end + if ItemRefTooltip and ItemRefTooltip.SetHyperlink then + local orig_ItemRef_SetHyperlink = ItemRefTooltip.SetHyperlink + ItemRefTooltip.SetHyperlink = function(self, link) + self._nanamiSellPriceAdded = nil + self._gsScoreAdded = nil + + local r1, r2, r3, r4 = orig_ItemRef_SetHyperlink(self, link) + + if IsAltKeyDown() or IsShiftKeyDown() or IsControlKeyDown() then + return r1, r2, r3, r4 + end + pcall(function() local _, _, itemStr = string.find(link or "", "(item:[%-?%d:]+)") if itemStr then - ItemRefTooltip._nanamiSellPriceAdded = nil - local itemId = IC_GetItemIdFromLink(itemStr) - local price = IC_QueryAndLearnPrice(itemStr) - if price and price > 0 and not ItemRefTooltip.hasMoney then - SetTooltipMoney(ItemRefTooltip, price) - ItemRefTooltip:Show() - end - if itemId then - ItemRefTooltip:AddLine("物品ID: " .. itemId, 0.55, 0.55, 0.70) - ItemRefTooltip:Show() - end + local moneyAlreadyShown = self.hasMoney + IC_EnhanceTooltip(self, itemStr, nil, moneyAlreadyShown) end end) + + return r1, r2, r3, r4 end end diff --git a/TradeSkillUI.lua b/TradeSkillUI.lua index 1a4abdd..22cac88 100644 --- a/TradeSkillUI.lua +++ b/TradeSkillUI.lua @@ -962,12 +962,7 @@ function TSUI.CreateReagentSlot(parent, i) GameTooltip:SetOwner(this, "ANCHOR_RIGHT") local ok if S.currentMode == "craft" then - local link = GetCraftItemLink and GetCraftItemLink(S.selectedIndex) - if link then - ok = pcall(GameTooltip.SetCraftItem, GameTooltip, S.selectedIndex, this.reagentIndex) - else - ok = pcall(GameTooltip.SetCraftSpell, GameTooltip, S.selectedIndex) - end + ok = pcall(GameTooltip.SetCraftItem, GameTooltip, S.selectedIndex, this.reagentIndex) else ok = pcall(GameTooltip.SetTradeSkillItem, GameTooltip, S.selectedIndex, this.reagentIndex) end @@ -1521,6 +1516,7 @@ end function TSUI.UpdateProfTabs() TSUI.ScanProfessions() local currentSkillName = API.GetSkillLineName() + local numVisible = 0 for i = 1, 10 do local tab = S.profTabs[i] if not tab then break end @@ -1544,10 +1540,16 @@ function TSUI.UpdateProfTabs() tab.glow:Hide(); tab.checked:Hide() end tab:Show() + numVisible = numVisible + 1 else tab.profName = nil; tab.active = false; tab:Hide() end end + if S.encBtn and S.MainFrame then + S.encBtn:ClearAllPoints() + S.encBtn:SetPoint("TOPLEFT", S.MainFrame, "TOPRIGHT", 2, + -(6 + numVisible * (42 + 4) + 10)) + end end function TSUI.IsTabSwitching() @@ -1932,6 +1934,24 @@ function TSUI:Initialize() end end) dIF:SetScript("OnLeave", function() GameTooltip:Hide() end) + dIF:SetScript("OnMouseUp", function() + if IsShiftKeyDown() and S.selectedIndex then + local link = API.GetItemLink(S.selectedIndex) + if link then + if ChatFrameEditBox and ChatFrameEditBox:IsVisible() then + ChatFrameEditBox:Insert(link) + else + if ChatFrame_OpenChat then + ChatFrame_OpenChat(link) + elseif ChatFrameEditBox then + ChatFrameEditBox:Show() + ChatFrameEditBox:SetText(link) + ChatFrameEditBox:SetFocus() + end + end + end + end + end) local dName = det:CreateFontString(nil, "OVERLAY") dName:SetFont(font, 13, "OUTLINE") @@ -2128,6 +2148,52 @@ function TSUI:Initialize() end) TSUI.CreateProfTabs(MF) + -- ── 食物药剂百科 按钮(职业图标条底部)─────────────────────────────── + do + local TAB_SZ, TAB_GAP, TAB_TOP = 42, 4, 6 + local encBtn = CreateFrame("Button", nil, MF) + encBtn:SetWidth(TAB_SZ); encBtn:SetHeight(TAB_SZ) + encBtn:SetPoint("TOPLEFT", MF, "TOPRIGHT", 2, -(TAB_TOP + 10)) + encBtn:SetFrameStrata("HIGH") + encBtn: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 }, + }) + encBtn:SetBackdropColor(T.slotBg[1], T.slotBg[2], T.slotBg[3], T.slotBg[4]) + encBtn:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) + + local ico = encBtn:CreateTexture(nil, "ARTWORK") + ico:SetTexture("Interface\\Icons\\INV_Potion_97") + ico:SetTexCoord(0.08, 0.92, 0.08, 0.92) + ico:SetPoint("TOPLEFT", encBtn, "TOPLEFT", 4, -4) + ico:SetPoint("BOTTOMRIGHT", encBtn, "BOTTOMRIGHT", -4, 4) + + local hl = encBtn:CreateTexture(nil, "HIGHLIGHT") + hl:SetTexture("Interface\\Buttons\\ButtonHilight-Square") + hl:SetBlendMode("ADD"); hl:SetAlpha(0.3); hl:SetAllPoints(ico) + + encBtn:SetScript("OnEnter", function() + this:SetBackdropBorderColor(T.slotSelected[1], T.slotSelected[2], T.slotSelected[3], 1) + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText("食物药剂百科", 1, 0.82, 0.60) + GameTooltip:AddLine("查看各职业消耗品推荐列表", 0.8, 0.8, 0.8) + GameTooltip:AddLine("Shift+点击物品可插入聊天", 0.6, 0.6, 0.65) + GameTooltip:Show() + end) + encBtn:SetScript("OnLeave", function() + this:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) + GameTooltip:Hide() + end) + encBtn:SetScript("OnClick", function() + if SFrames.ConsumableUI then SFrames.ConsumableUI:Toggle() end + end) + encBtn:Show() + S.encBtn = encBtn + end + -- ──────────────────────────────────────────────────────────────────────── + MF:Hide() tinsert(UISpecialFrames, "SFramesTradeSkillFrame") end diff --git a/TrainerUI.lua b/TrainerUI.lua index 45ebcb8..fe50dd1 100644 --- a/TrainerUI.lua +++ b/TrainerUI.lua @@ -61,10 +61,11 @@ local currentFilter = "all" local displayList = {} local rowButtons = {} local collapsedCats = {} -local isTradeskillTrainerCached = false -- Cache to avoid repeated API calls +local isTradeskillTrainerCached = false local function HideBlizzardTrainer() if not ClassTrainerFrame then return end ClassTrainerFrame:SetScript("OnHide", function() end) + ClassTrainerFrame:UnregisterAllEvents() if ClassTrainerFrame:IsVisible() then if HideUIPanel then pcall(HideUIPanel, ClassTrainerFrame) @@ -161,123 +162,20 @@ end 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" + if category == "available" or category == "unavailable" or category == "used" then + return category 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" + return "unavailable" end local scanTip = nil local function GetServiceTooltipInfo(index) - if not scanTip then - scanTip = CreateFrame("GameTooltip", "SFramesTrainerScanTip", nil, "GameTooltipTemplate") - end - scanTip:SetOwner(WorldFrame, "ANCHOR_NONE") - scanTip:ClearLines() - local ok = pcall(scanTip.SetTrainerService, scanTip, index) - if not ok then return "", "" end - - local infoLines = {} - local descLines = {} - local numLines = scanTip:NumLines() - local foundDesc = false - - for i = 2, numLines do - local leftFS = _G["SFramesTrainerScanTipTextLeft" .. i] - local rightFS = _G["SFramesTrainerScanTipTextRight" .. i] - local leftText = leftFS and leftFS:GetText() or "" - local rightText = rightFS and rightFS:GetText() or "" - - if leftText == "" and rightText == "" then - if not foundDesc and table.getn(infoLines) > 0 then - foundDesc = true - end - else - local line - if rightText ~= "" and leftText ~= "" then - line = leftText .. " " .. rightText - elseif leftText ~= "" then - line = leftText - else - line = rightText - end - - local isYellow = leftFS and leftFS.GetTextColor and true - local r, g, b - if leftFS and leftFS.GetTextColor then - r, g, b = leftFS:GetTextColor() - end - local isWhiteOrYellow = r and (r > 0.9 and g > 0.75) - - if not foundDesc and rightText ~= "" then - table.insert(infoLines, line) - elseif not foundDesc and not isWhiteOrYellow and string.len(leftText) < 30 then - table.insert(infoLines, line) - else - foundDesc = true - table.insert(descLines, line) - end - end - end - scanTip:Hide() - return table.concat(infoLines, "\n"), table.concat(descLines, "\n") + return "", "" end local function GetServiceQuality(index) - if not scanTip then - scanTip = CreateFrame("GameTooltip", "SFramesTrainerScanTip", nil, "GameTooltipTemplate") - end - scanTip:SetOwner(WorldFrame, "ANCHOR_NONE") - scanTip:ClearLines() - local ok = pcall(scanTip.SetTrainerService, scanTip, index) - if not ok then scanTip:Hide() return nil end - local firstLine = _G["SFramesTrainerScanTipTextLeft1"] - if not firstLine or not firstLine.GetTextColor then - scanTip:Hide() - return nil - end - local r, g, b = firstLine:GetTextColor() - scanTip:Hide() - return ColorToQuality(r, g, b) + return nil end -------------------------------------------------------------------------------- @@ -392,10 +290,7 @@ end local qualityCache = {} local function GetCachedServiceQuality(index) - if qualityCache[index] ~= nil then return qualityCache[index] end - local q = GetServiceQuality(index) - qualityCache[index] = q or false - return q + return nil end -------------------------------------------------------------------------------- @@ -604,22 +499,8 @@ local function CreateListRow(parent, idx) self.icon:SetVertexColor(T.passive[1], T.passive[2], T.passive[3]) end - -- 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]) - end + self.qualGlow:Hide() + self.iconFrame:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) local ok, cost = pcall(GetTrainerServiceCost, svc.index) if ok and cost and cost > 0 then @@ -838,13 +719,7 @@ local function UpdateDetail() detail.icon:SetTexture(iconTex) detail.iconFrame:Show() - local quality = GetServiceQuality(selectedIndex) - local qc = QUALITY_COLORS[quality] - if qc and quality and quality >= 2 then - detail.iconFrame:SetBackdropBorderColor(qc[1], qc[2], qc[3], 1) - else - detail.iconFrame:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) - end + detail.iconFrame:SetBackdropBorderColor(T.slotBorder[1], T.slotBorder[2], T.slotBorder[3], T.slotBorder[4]) detail.nameFS:SetText(name or "") @@ -873,13 +748,10 @@ local function UpdateDetail() end detail.reqFS:SetText(table.concat(reqParts, " ")) - local spellInfo, descText = GetServiceTooltipInfo(selectedIndex) - detail.infoFS:SetText(spellInfo) - detail.descFS:SetText(descText) - detail.descDivider:Show() - - local textH = detail.descFS:GetHeight() or 40 - detail.descScroll:GetScrollChild():SetHeight(math.max(1, textH)) + detail.infoFS:SetText("") + detail.descFS:SetText("") + detail.descDivider:Hide() + detail.descScroll:GetScrollChild():SetHeight(1) detail.descScroll:SetVerticalScroll(0) local canTrain = (category == "available") and cost and (GetMoney() >= cost) @@ -1342,6 +1214,7 @@ function TUI:Initialize() local function CleanupBlizzardTrainer() if not ClassTrainerFrame then return end ClassTrainerFrame:SetScript("OnHide", function() end) + ClassTrainerFrame:UnregisterAllEvents() if HideUIPanel then pcall(HideUIPanel, ClassTrainerFrame) end if ClassTrainerFrame:IsVisible() then ClassTrainerFrame:Hide() end ClassTrainerFrame:SetAlpha(0) @@ -1361,6 +1234,7 @@ function TUI:Initialize() if event == "TRAINER_SHOW" then if ClassTrainerFrame then ClassTrainerFrame:SetScript("OnHide", function() end) + ClassTrainerFrame:UnregisterAllEvents() ClassTrainerFrame:SetAlpha(0) ClassTrainerFrame:EnableMouse(false) end diff --git a/Tweaks.lua b/Tweaks.lua index ea4715e..2a34d61 100644 --- a/Tweaks.lua +++ b/Tweaks.lua @@ -1148,11 +1148,9 @@ end -- 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. +-- Temporarily try the mouseover unit first while preserving the original +-- target. If the spell cannot resolve on mouseover, stop the pending target +-- mode, restore the original target, and retry there. -------------------------------------------------------------------------------- local mouseoverCastEnabled = false local origUseAction = nil @@ -1176,6 +1174,70 @@ local function GetMouseoverUnit() return nil end +local function CaptureTargetState() + local hadTarget = UnitExists("target") + return { + hadTarget = hadTarget, + name = hadTarget and UnitName("target") or nil, + } +end + +local function RestoreTargetState(state) + if not state then return end + if state.hadTarget and state.name then + TargetLastTarget() + if not UnitExists("target") or UnitName("target") ~= state.name then + TargetByName(state.name, true) + end + else + ClearTarget() + end +end + +local function ResolvePendingSpellTarget(unit) + if not (SpellIsTargeting and SpellIsTargeting()) then + return true + end + + if unit then + if SpellCanTargetUnit then + if SpellCanTargetUnit(unit) then + SpellTargetUnit(unit) + end + else + SpellTargetUnit(unit) + end + end + + if SpellIsTargeting and SpellIsTargeting() then + return false + end + + return true +end + +local function TryActionOnUnit(unit, action, cursor, onSelf) + if not unit then return false end + + if not (UnitIsUnit and UnitExists("target") and UnitIsUnit(unit, "target")) then + TargetUnit(unit) + end + + origUseAction(action, cursor, onSelf) + return ResolvePendingSpellTarget(unit) +end + +local function TryCastSpellOnUnit(unit, spell) + if not unit then return false end + + if not (UnitIsUnit and UnitExists("target") and UnitIsUnit(unit, "target")) then + TargetUnit(unit) + end + + origCastSpellByName(spell) + return ResolvePendingSpellTarget(unit) +end + local function MouseoverUseAction(action, cursor, onSelf) -- Don't interfere: picking up action, or re-entrant call if cursor == 1 or inMouseoverAction then @@ -1187,42 +1249,24 @@ local function MouseoverUseAction(action, cursor, onSelf) 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 + local prevTarget = CaptureTargetState() - -- 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) + local castOnMouseover = TryActionOnUnit(moUnit, action, cursor, onSelf) - -- 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 + if not castOnMouseover and 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) + RestoreTargetState(prevTarget) + + if not castOnMouseover and prevTarget.hadTarget then + origUseAction(action, cursor, onSelf) + if SpellIsTargeting and SpellIsTargeting() then + if not ResolvePendingSpellTarget("target") then + SpellStopTargeting() + end end - else - -- Had no target before, clear - ClearTarget() end inMouseoverAction = false @@ -1244,43 +1288,26 @@ local function MouseoverCastSpellByName(spell, arg2) 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 + local prevTarget = CaptureTargetState() - -- No SuperWoW: target-swap - local hadTarget = UnitExists("target") - local prevTargetName = hadTarget and UnitName("target") or nil + inMouseoverAction = true + local castOnMouseover = TryCastSpellOnUnit(moUnit, spell) - 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 + if not castOnMouseover and SpellIsTargeting and SpellIsTargeting() then SpellStopTargeting() end - if hadTarget and prevTargetName then - TargetLastTarget() - if not UnitExists("target") or UnitName("target") ~= prevTargetName then - TargetByName(prevTargetName, true) + RestoreTargetState(prevTarget) + + if not castOnMouseover and prevTarget.hadTarget then + origCastSpellByName(spell) + if SpellIsTargeting and SpellIsTargeting() then + if not ResolvePendingSpellTarget("target") then + SpellStopTargeting() + end end - elseif not hadTarget then - ClearTarget() end + inMouseoverAction = false end local function InitMouseoverCast() diff --git a/Units/Party.lua b/Units/Party.lua index 5b458b8..5d6ae87 100644 --- a/Units/Party.lua +++ b/Units/Party.lua @@ -9,6 +9,9 @@ local PARTY_HORIZONTAL_GAP = 8 local PARTY_UNIT_LOOKUP = { party1 = true, party2 = true, party3 = true, party4 = true } local PARTYPET_UNIT_LOOKUP = { partypet1 = true, partypet2 = true, partypet3 = true, partypet4 = true } +-- Pre-allocated table reused every UpdateAuras call +local _partyDebuffColor = { r = 0, g = 0, b = 0 } + local function GetIncomingHeals(unit) return SFrames:GetIncomingHeals(unit) end @@ -36,6 +39,12 @@ local function Clamp(value, minValue, maxValue) return value end +local function SetTextureIfPresent(region, texturePath) + if region and region.SetTexture and texturePath then + region:SetTexture(texturePath) + end +end + function SFrames.Party:GetMetrics() local db = SFramesDB or {} @@ -57,6 +66,34 @@ function SFrames.Party:GetMetrics() local powerHeight = tonumber(db.partyPowerHeight) or (height - healthHeight - 3) powerHeight = Clamp(math.floor(powerHeight + 0.5), 6, height - 6) + local gradientStyle = SFrames:IsGradientStyle() + local availablePowerWidth = width - portraitWidth - 5 + if availablePowerWidth < 40 then + availablePowerWidth = 40 + end + + local rawPowerWidth = tonumber(db.partyPowerWidth) + local legacyFullWidth = tonumber(db.partyFrameWidth) or width + local legacyPowerWidth = width - portraitWidth - 3 + local defaultPowerWidth = gradientStyle and width or availablePowerWidth + local maxPowerWidth = gradientStyle and width or availablePowerWidth + local powerWidth + if gradientStyle then + -- 渐变风格:能量条始终与血条等宽(全宽) + powerWidth = width + elseif not rawPowerWidth + or math.abs(rawPowerWidth - legacyFullWidth) < 0.5 + or math.abs(rawPowerWidth - legacyPowerWidth) < 0.5 + or math.abs(rawPowerWidth - availablePowerWidth) < 0.5 then + powerWidth = defaultPowerWidth + else + powerWidth = rawPowerWidth + end + powerWidth = Clamp(math.floor(powerWidth + 0.5), 40, maxPowerWidth) + + local powerOffsetX = Clamp(math.floor((tonumber(db.partyPowerOffsetX) or 0) + 0.5), -120, 120) + local powerOffsetY = Clamp(math.floor((tonumber(db.partyPowerOffsetY) or 0) + 0.5), -80, 80) + if healthHeight + powerHeight + 3 > height then powerHeight = height - healthHeight - 3 if powerHeight < 6 then @@ -81,16 +118,30 @@ function SFrames.Party:GetMetrics() local valueFont = tonumber(db.partyValueFontSize) or 10 valueFont = Clamp(math.floor(valueFont + 0.5), 8, 18) + local healthFont = tonumber(db.partyHealthFontSize) or valueFont + healthFont = Clamp(math.floor(healthFont + 0.5), 8, 18) + + local powerFont = tonumber(db.partyPowerFontSize) or valueFont + powerFont = Clamp(math.floor(powerFont + 0.5), 8, 18) + return { width = width, height = height, portraitWidth = portraitWidth, healthHeight = healthHeight, powerHeight = powerHeight, + powerWidth = powerWidth, + powerOffsetX = powerOffsetX, + powerOffsetY = powerOffsetY, + powerOnTop = db.partyPowerOnTop == true, horizontalGap = hgap, verticalGap = vgap, nameFont = nameFont, valueFont = valueFont, + healthFont = healthFont, + powerFont = powerFont, + healthTexture = SFrames:ResolveBarTexture("partyHealthTexture", "barTexture"), + powerTexture = SFrames:ResolveBarTexture("partyPowerTexture", "barTexture"), } end @@ -142,8 +193,8 @@ function SFrames.Party:ApplyFrameStyle(frame, metrics) if frame.power then frame.power:ClearAllPoints() - frame.power:SetPoint("TOPLEFT", frame.health, "BOTTOMLEFT", 0, -1) - frame.power:SetPoint("TOPRIGHT", frame.health, "BOTTOMRIGHT", 0, 0) + frame.power:SetPoint("TOPLEFT", frame.health, "BOTTOMLEFT", metrics.powerOffsetX, -1 + metrics.powerOffsetY) + frame.power:SetWidth(metrics.powerWidth) frame.power:SetHeight(metrics.powerHeight) end @@ -153,14 +204,114 @@ function SFrames.Party:ApplyFrameStyle(frame, metrics) frame.powerBGFrame:SetPoint("BOTTOMRIGHT", frame.power, "BOTTOMRIGHT", 1, -1) end - local outline = (SFrames and SFrames.Media and SFrames.Media.fontOutline) or "OUTLINE" - local fontPath = SFrames:GetFont() + SFrames:ApplyStatusBarTexture(frame.health, "partyHealthTexture", "barTexture") + SFrames:ApplyStatusBarTexture(frame.power, "partyPowerTexture", "barTexture") + if frame.health and frame.power then + local healthLevel = frame:GetFrameLevel() + 2 + local powerLevel = metrics.powerOnTop and (healthLevel + 1) or (healthLevel - 1) + frame.health:SetFrameLevel(healthLevel) + frame.power:SetFrameLevel(powerLevel) + end + SFrames:ApplyConfiguredUnitBackdrop(frame, "party") + if frame.pbg then SFrames:ApplyConfiguredUnitBackdrop(frame.pbg, "party", true) end + if frame.healthBGFrame then SFrames:ApplyConfiguredUnitBackdrop(frame.healthBGFrame, "party") end + if frame.powerBGFrame then SFrames:ApplyConfiguredUnitBackdrop(frame.powerBGFrame, "party") end + SetTextureIfPresent(frame.health and frame.health.bg, metrics.healthTexture) + SetTextureIfPresent(frame.health and frame.health.healPredMine, metrics.healthTexture) + SetTextureIfPresent(frame.health and frame.health.healPredOther, metrics.healthTexture) + SetTextureIfPresent(frame.health and frame.health.healPredOver, metrics.healthTexture) + SetTextureIfPresent(frame.power and frame.power.bg, metrics.powerTexture) + + -- Gradient style preset + if SFrames:IsGradientStyle() then + -- Hide portrait & its backdrop + if frame.portrait then frame.portrait:Hide() end + if frame.pbg then SFrames:ClearBackdrop(frame.pbg); frame.pbg:Hide() end + -- Strip backdrops + SFrames:ClearBackdrop(frame) + SFrames:ClearBackdrop(frame.healthBGFrame) + SFrames:ClearBackdrop(frame.powerBGFrame) + -- Health bar full width + if frame.health then + frame.health:ClearAllPoints() + frame.health:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + frame.health:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) + frame.health:SetHeight(metrics.healthHeight) + end + -- Power bar full width + if frame.power then + frame.power:ClearAllPoints() + frame.power:SetPoint("TOPLEFT", frame.health, "BOTTOMLEFT", metrics.powerOffsetX, -2 + metrics.powerOffsetY) + frame.power:SetWidth(metrics.powerWidth) + frame.power:SetHeight(metrics.powerHeight) + end + -- Apply gradient overlays + SFrames:ApplyGradientStyle(frame.health) + SFrames:ApplyGradientStyle(frame.power) + -- Flush BG frames + if frame.healthBGFrame then + frame.healthBGFrame:ClearAllPoints() + frame.healthBGFrame:SetPoint("TOPLEFT", frame.health, "TOPLEFT", 0, 0) + frame.healthBGFrame:SetPoint("BOTTOMRIGHT", frame.health, "BOTTOMRIGHT", 0, 0) + end + if frame.powerBGFrame then + frame.powerBGFrame:ClearAllPoints() + frame.powerBGFrame:SetPoint("TOPLEFT", frame.power, "TOPLEFT", 0, 0) + frame.powerBGFrame:SetPoint("BOTTOMRIGHT", frame.power, "BOTTOMRIGHT", 0, 0) + end + -- Hide bar backgrounds (transparent) + if frame.healthBGFrame then frame.healthBGFrame:Hide() end + if frame.powerBGFrame then frame.powerBGFrame:Hide() end + if frame.health and frame.health.bg then frame.health.bg:Hide() end + if frame.power and frame.power.bg then frame.power.bg:Hide() end + else + SFrames:RemoveGradientStyle(frame.health) + SFrames:RemoveGradientStyle(frame.power) + -- Restore bar backgrounds + if frame.healthBGFrame then frame.healthBGFrame:Show() end + if frame.powerBGFrame then frame.powerBGFrame:Show() end + if frame.health and frame.health.bg then frame.health.bg:Show() end + if frame.power and frame.power.bg then frame.power.bg:Show() end + + local use3D = not (SFramesDB and SFramesDB.partyPortrait3D == false) + if use3D then + if frame.portrait then frame.portrait:Show() end + if frame.pbg then frame.pbg:Show() end + else + -- Hide portrait area and extend health/power bars to full width + if frame.portrait then frame.portrait:Hide() end + if frame.pbg then frame.pbg:Hide() end + local fullWidth = metrics.width - 2 + if frame.health then + frame.health:ClearAllPoints() + frame.health:SetPoint("TOPLEFT", frame, "TOPLEFT", 1, -1) + frame.health:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -1, -1) + frame.health:SetHeight(metrics.healthHeight) + end + if frame.healthBGFrame then + frame.healthBGFrame:ClearAllPoints() + frame.healthBGFrame:SetPoint("TOPLEFT", frame.health, "TOPLEFT", -1, 1) + frame.healthBGFrame:SetPoint("BOTTOMRIGHT", frame.health, "BOTTOMRIGHT", 1, -1) + end + if frame.power then + frame.power:SetWidth(fullWidth) + end + if frame.powerBGFrame then + frame.powerBGFrame:ClearAllPoints() + frame.powerBGFrame:SetPoint("TOPLEFT", frame.power, "TOPLEFT", -1, 1) + frame.powerBGFrame:SetPoint("BOTTOMRIGHT", frame.power, "BOTTOMRIGHT", 1, -1) + end + end + end if frame.nameText then - frame.nameText:SetFont(fontPath, metrics.nameFont, outline) + SFrames:ApplyFontString(frame.nameText, metrics.nameFont, "partyNameFontKey", "fontKey") end if frame.healthText then - frame.healthText:SetFont(fontPath, metrics.valueFont, outline) + SFrames:ApplyFontString(frame.healthText, metrics.healthFont, "partyHealthFontKey", "fontKey") + end + if frame.powerText then + SFrames:ApplyFontString(frame.powerText, metrics.powerFont, "partyPowerFontKey", "fontKey") end end @@ -250,12 +401,13 @@ function SFrames.Party:ApplyLayout() end end + local auraRowHeight = 24 -- 20px icon + 2px gap above + 2px padding if mode == "horizontal" then self.parent:SetWidth((metrics.width * 4) + (metrics.horizontalGap * 3)) - self.parent:SetHeight(metrics.height) + self.parent:SetHeight(metrics.height + auraRowHeight) else self.parent:SetWidth(metrics.width) - self.parent:SetHeight(metrics.height + ((metrics.height + metrics.verticalGap) * 3)) + self.parent:SetHeight(metrics.height + ((metrics.height + metrics.verticalGap) * 3) + auraRowHeight) end end @@ -432,11 +584,16 @@ function SFrames.Party:Initialize() f.healthText = SFrames:CreateFontString(f.health, 10, "RIGHT") f.healthText:SetPoint("RIGHT", f.health, "RIGHT", -4, 0) + + f.powerText = SFrames:CreateFontString(f.power, 9, "RIGHT") + f.powerText:SetPoint("RIGHT", f.power, "RIGHT", -4, 0) f.nameText:SetShadowColor(0, 0, 0, 1) f.nameText:SetShadowOffset(1, -1) f.healthText:SetShadowColor(0, 0, 0, 1) f.healthText:SetShadowOffset(1, -1) + f.powerText:SetShadowColor(0, 0, 0, 1) + f.powerText:SetShadowOffset(1, -1) -- Leader / Master Looter overlay (high frame level so icons aren't hidden by portrait) local roleOvr = CreateFrame("Frame", nil, f) @@ -621,74 +778,79 @@ end function SFrames.Party:CreateAuras(index) local f = self.frames[index].frame + -- Use self.parent (plain Frame) as parent for aura buttons so they are + -- never clipped by the party Button frame's boundaries. + local auraParent = self.parent f.buffs = {} f.debuffs = {} local size = 20 local spacing = 2 - + -- Party Buffs for i = 1, 4 do - local b = CreateFrame("Button", "SFramesParty"..index.."Buff"..i, f) + local b = CreateFrame("Button", "SFramesParty"..index.."Buff"..i, auraParent) b:SetWidth(size) b:SetHeight(size) + b:SetFrameLevel((f:GetFrameLevel() or 0) + 3) 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, 9, "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() GameTooltip:SetOwner(this, "ANCHOR_BOTTOMRIGHT") GameTooltip:SetUnitBuff(f.unit, this:GetID()) end) b:SetScript("OnLeave", function() GameTooltip:Hide() end) - - -- Anchored BELOW the frame on the left side + + -- Anchored BELOW the party frame on the left side if i == 1 then b:SetPoint("TOPLEFT", f, "BOTTOMLEFT", 0, -2) else b:SetPoint("LEFT", f.buffs[i-1], "RIGHT", spacing, 0) end - + b:Hide() f.buffs[i] = b end - + -- Debuffs (Starting right after Buffs to remain linear) for i = 1, 4 do - local b = CreateFrame("Button", "SFramesParty"..index.."Debuff"..i, f) + local b = CreateFrame("Button", "SFramesParty"..index.."Debuff"..i, auraParent) b:SetWidth(size) b:SetHeight(size) + b:SetFrameLevel((f:GetFrameLevel() or 0) + 3) 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, 9, "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() GameTooltip:SetOwner(this, "ANCHOR_BOTTOMRIGHT") GameTooltip:SetUnitDebuff(f.unit, this:GetID()) end) b:SetScript("OnLeave", function() GameTooltip:Hide() end) - + if i == 1 then b:SetPoint("LEFT", f.buffs[4], "RIGHT", spacing * 4, 0) else b:SetPoint("LEFT", f.debuffs[i-1], "RIGHT", spacing, 0) end - + b:Hide() f.debuffs[i] = b end @@ -726,6 +888,7 @@ end function SFrames.Party:TickAuras(unit) + if self.testing then return end local data = self:GetFrameByUnit(unit) if not data then return end local f = data.frame @@ -772,7 +935,10 @@ function SFrames.Party:UpdateAll() if inRaid and raidFramesEnabled then for i = 1, 4 do if self.frames[i] and self.frames[i].frame then - self.frames[i].frame:Hide() + local f = self.frames[i].frame + f:Hide() + if f.buffs then for j = 1, 4 do f.buffs[j]:Hide() end end + if f.debuffs then for j = 1, 4 do f.debuffs[j]:Hide() end end end end if self._globalUpdateFrame then @@ -793,6 +959,8 @@ function SFrames.Party:UpdateAll() hasVisible = true else f:Hide() + if f.buffs then for j = 1, 4 do f.buffs[j]:Hide() end end + if f.debuffs then for j = 1, 4 do f.debuffs[j]:Hide() end end end end if self._globalUpdateFrame then @@ -812,11 +980,16 @@ function SFrames.Party:UpdateFrame(unit) if not data then return end local f = data.frame - f.portrait:SetUnit(unit) - f.portrait:SetCamera(0) - f.portrait:Hide() - f.portrait:Show() - + local use3D = not (SFramesDB and SFramesDB.partyPortrait3D == false) + if use3D then + f.portrait:SetUnit(unit) + f.portrait:SetCamera(0) + f.portrait:Hide() + f.portrait:Show() + else + f.portrait:Hide() + end + local name = UnitName(unit) or "" local level = UnitLevel(unit) if level == -1 then level = "??" end @@ -841,6 +1014,10 @@ function SFrames.Party:UpdateFrame(unit) f.nameText:SetTextColor(1, 1, 1) end end + -- Re-apply gradient after color change + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(f.health) + end -- Update Leader/Master Looter if GetPartyLeaderIndex() == data.index then @@ -870,6 +1047,8 @@ function SFrames.Party:UpdatePortrait(unit) local data = self:GetFrameByUnit(unit) if not data then return end local f = data.frame + local use3D = not (SFramesDB and SFramesDB.partyPortrait3D == false) + if not use3D then return end f.portrait:SetUnit(unit) f.portrait:SetCamera(0) f.portrait:Hide() @@ -917,14 +1096,8 @@ function SFrames.Party:UpdateHealPrediction(unit) 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 - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -946,7 +1119,7 @@ function SFrames.Party:UpdateHealPrediction(unit) end if maxHp <= 0 then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -963,7 +1136,7 @@ function SFrames.Party:UpdateHealPrediction(unit) end local missing = maxHp - hp if missing <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -971,13 +1144,19 @@ function SFrames.Party:UpdateHealPrediction(unit) local remaining = missing - mineShown local otherShown = math.min(math.max(0, othersIncoming), remaining) if mineIncoming <= 0 and othersIncoming <= 0 then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end - local barWidth = f:GetWidth() - (f.portrait:GetWidth() + 4) + local use3DForPred = not (SFramesDB and SFramesDB.partyPortrait3D == false) + local barWidth + if use3DForPred then + barWidth = f:GetWidth() - (f.portrait:GetWidth() + 4) + else + barWidth = f:GetWidth() - 2 + end if barWidth <= 0 then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -1054,6 +1233,9 @@ function SFrames.Party:UpdatePowerType(unit) else f.power:SetStatusBarColor(0, 0, 1) end + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(f.power) + end end function SFrames.Party:UpdatePower(unit) @@ -1064,6 +1246,7 @@ function SFrames.Party:UpdatePower(unit) if not UnitIsConnected(unit) then f.power:SetMinMaxValues(0, 100) f.power:SetValue(0) + if f.powerText then f.powerText:SetText("") end return end @@ -1071,6 +1254,14 @@ function SFrames.Party:UpdatePower(unit) local maxPower = UnitManaMax(unit) f.power:SetMinMaxValues(0, maxPower) f.power:SetValue(power) + if f.powerText then + if maxPower and maxPower > 0 then + f.powerText:SetText(SFrames:FormatCompactPair(power, maxPower)) + else + f.powerText:SetText("") + end + end + SFrames:UpdateRainbowBar(f.power, power, maxPower, unit) end function SFrames.Party:UpdateRaidIcons() @@ -1106,6 +1297,7 @@ function SFrames.Party:UpdateRaidIcon(unit) end function SFrames.Party:UpdateAuras(unit) + if self.testing then return end local data = self:GetFrameByUnit(unit) if not data then return end local f = data.frame @@ -1114,7 +1306,9 @@ function SFrames.Party:UpdateAuras(unit) local showBuffs = not (SFramesDB and SFramesDB.partyShowBuffs == false) local hasDebuff = false - local debuffColor = {r=_A.slotBg[1], g=_A.slotBg[2], b=_A.slotBg[3]} + _partyDebuffColor.r = _A.slotBg[1] + _partyDebuffColor.g = _A.slotBg[2] + _partyDebuffColor.b = _A.slotBg[3] SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") @@ -1128,10 +1322,10 @@ function SFrames.Party:UpdateAuras(unit) if texture then if debuffType then hasDebuff = true - if debuffType == "Magic" then debuffColor = {r=0.2, g=0.6, b=1} - elseif debuffType == "Curse" then debuffColor = {r=0.6, g=0, b=1} - elseif debuffType == "Disease" then debuffColor = {r=0.6, g=0.4, b=0} - elseif debuffType == "Poison" then debuffColor = {r=0, g=0.6, b=0} + if debuffType == "Magic" then _partyDebuffColor.r = 0.2; _partyDebuffColor.g = 0.6; _partyDebuffColor.b = 1 + elseif debuffType == "Curse" then _partyDebuffColor.r = 0.6; _partyDebuffColor.g = 0; _partyDebuffColor.b = 1 + elseif debuffType == "Disease" then _partyDebuffColor.r = 0.6; _partyDebuffColor.g = 0.4; _partyDebuffColor.b = 0 + elseif debuffType == "Poison" then _partyDebuffColor.r = 0; _partyDebuffColor.g = 0.6; _partyDebuffColor.b = 0 end end @@ -1174,7 +1368,7 @@ function SFrames.Party:UpdateAuras(unit) end if hasDebuff then - f.health.bg:SetVertexColor(debuffColor.r, debuffColor.g, debuffColor.b, 1) + f.health.bg:SetVertexColor(_partyDebuffColor.r, _partyDebuffColor.g, _partyDebuffColor.b, 1) else f.health.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) end @@ -1254,10 +1448,38 @@ function SFrames.Party:TestMode() f.masterIcon:Show() end - -- Show one dummy debuff to test positioning - f.debuffs[1].icon:SetTexture("Interface\\Icons\\Spell_Shadow_ShadowWordPain") - f.debuffs[1]:Show() - + -- Show test buffs (all 4) + local testBuffIcons = { + "Interface\\Icons\\Spell_Holy_PowerWordFortitude", + "Interface\\Icons\\Spell_Holy_Renew", + "Interface\\Icons\\Spell_Holy_GreaterHeal", + "Interface\\Icons\\Spell_Nature_Abolishmagic", + } + for j = 1, 4 do + local fakeTime = math.random(30, 300) + f.buffs[j].icon:SetTexture(testBuffIcons[j]) + f.buffs[j].expirationTime = GetTime() + fakeTime + f.buffs[j].cdText:SetText(SFrames:FormatTime(fakeTime)) + f.buffs[j]:Show() + end + + -- Show test debuffs (all 4, different types for color test) + local testDebuffIcons = { + "Interface\\Icons\\Spell_Shadow_ShadowWordPain", -- Magic (blue) + "Interface\\Icons\\Spell_Shadow_Curse", -- Curse (purple) + "Interface\\Icons\\Ability_Rogue_FeignDeath", -- Disease (brown) + "Interface\\Icons\\Ability_Poisoning", -- Poison (green) + } + for j = 1, 4 do + local debuffTime = math.random(5, 25) + f.debuffs[j].icon:SetTexture(testDebuffIcons[j]) + f.debuffs[j].expirationTime = GetTime() + debuffTime + f.debuffs[j].cdText:SetText(SFrames:FormatTime(debuffTime)) + f.debuffs[j]:Show() + end + -- Magic debuff background color + f.health.bg:SetVertexColor(0.2, 0.6, 1, 1) + -- Test pet if f.petFrame then f.petFrame:Show() @@ -1268,9 +1490,18 @@ function SFrames.Party:TestMode() end end else - self:UpdateAll() for i = 1, 4 do - self.frames[i].frame.debuffs[1]:Hide() + local f = self.frames[i].frame + f.health.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) + for j = 1, 4 do + f.buffs[j].expirationTime = nil + f.buffs[j].cdText:SetText("") + f.buffs[j]:Hide() + f.debuffs[j].expirationTime = nil + f.debuffs[j].cdText:SetText("") + f.debuffs[j]:Hide() + end end + self:UpdateAll() end end diff --git a/Units/Pet.lua b/Units/Pet.lua index 33ddc14..386fb31 100644 --- a/Units/Pet.lua +++ b/Units/Pet.lua @@ -225,16 +225,22 @@ function SFrames.Pet:Initialize() local f = CreateFrame("Button", "SFramesPetFrame", UIParent) f:SetWidth(150) f:SetHeight(30) - - if SFramesDB and SFramesDB.Positions and SFramesDB.Positions["PetFrame"] then - local pos = SFramesDB.Positions["PetFrame"] - f:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs, pos.yOfs) - else - f:SetPoint("TOPLEFT", SFramesPlayerFrame, "BOTTOMLEFT", 10, -55) - end - + local frameScale = (SFramesDB and type(SFramesDB.petFrameScale) == "number") and SFramesDB.petFrameScale or 1 f:SetScale(Clamp(frameScale, 0.7, 1.8)) + + if SFramesDB and SFramesDB.Positions and SFramesDB.Positions["PetFrame"] then + local pos = SFramesDB.Positions["PetFrame"] + local fScale = f:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fScale > 0.01 and math.abs(fScale - 1) > 0.001 then + f:SetPoint(pos.point, UIParent, pos.relativePoint, + (pos.xOfs or 0) / fScale, (pos.yOfs or 0) / fScale) + else + f:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + end + else + f:SetPoint("TOPLEFT", SFramesPlayerFrame, "BOTTOMLEFT", 0, -75) + end f:SetMovable(true) f:EnableMouse(true) @@ -245,6 +251,11 @@ function SFrames.Pet:Initialize() if not SFramesDB then SFramesDB = {} end if not SFramesDB.Positions then SFramesDB.Positions = {} end local point, relativeTo, relativePoint, xOfs, yOfs = f:GetPoint() + local fScale = f:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fScale > 0.01 and math.abs(fScale - 1) > 0.001 then + xOfs = (xOfs or 0) * fScale + yOfs = (yOfs or 0) * fScale + end SFramesDB.Positions["PetFrame"] = { point = point, relativePoint = relativePoint, xOfs = xOfs, yOfs = yOfs } end) @@ -294,6 +305,8 @@ function SFrames.Pet:Initialize() hbg:SetFrameLevel(f:GetFrameLevel() - 1) SFrames:CreateUnitBackdrop(hbg) + f.healthBGFrame = hbg + f.health.bg = f.health:CreateTexture(nil, "BACKGROUND") f.health.bg:SetAllPoints() f.health.bg:SetTexture(SFrames:GetTexture()) @@ -328,6 +341,7 @@ function SFrames.Pet:Initialize() pbg:SetPoint("BOTTOMRIGHT", f.power, "BOTTOMRIGHT", 1, -1) pbg:SetFrameLevel(f:GetFrameLevel() - 1) SFrames:CreateUnitBackdrop(pbg) + f.powerBGFrame = pbg f.power.bg = f.power:CreateTexture(nil, "BACKGROUND") f.power.bg:SetAllPoints() @@ -394,11 +408,13 @@ function SFrames.Pet:Initialize() SFrames:RegisterEvent("PLAYER_ENTERING_WORLD", function() self:UpdateAll() end) self:InitFoodFeature() + self:ApplyConfig() self:UpdateAll() if SFrames.Movers and SFrames.Movers.RegisterMover and self.frame then SFrames.Movers:RegisterMover("PetFrame", self.frame, "宠物", - "TOPLEFT", "SFramesPlayerFrame", "BOTTOMLEFT", 10, -55) + "TOPLEFT", "SFramesPlayerFrame", "BOTTOMLEFT", 0, -75, + nil, { alwaysShowInLayout = true }) end if StaticPopup_Show then @@ -413,6 +429,90 @@ function SFrames.Pet:Initialize() end end +function SFrames.Pet:ApplyConfig() + if not self.frame then return end + local f = self.frame + + -- Apply bar textures + SFrames:ApplyStatusBarTexture(f.health, "petHealthTexture", "barTexture") + SFrames:ApplyStatusBarTexture(f.power, "petPowerTexture", "barTexture") + local healthTex = SFrames:ResolveBarTexture("petHealthTexture", "barTexture") + local powerTex = SFrames:ResolveBarTexture("petPowerTexture", "barTexture") + if f.health and f.health.bg then f.health.bg:SetTexture(healthTex) end + if f.power and f.power.bg then f.power.bg:SetTexture(powerTex) end + + if SFrames:IsGradientStyle() then + -- Strip backdrops + SFrames:ClearBackdrop(f) + SFrames:ClearBackdrop(f.healthBGFrame) + SFrames:ClearBackdrop(f.powerBGFrame) + -- Health bar full width + if f.health then + f.health:ClearAllPoints() + f.health:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0) + f.health:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0) + f.health:SetHeight(18) + end + -- Power bar full width + if f.power then + f.power:ClearAllPoints() + f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", 0, -2) + f.power:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, 0) + end + -- Apply gradient overlays + SFrames:ApplyGradientStyle(f.health) + SFrames:ApplyGradientStyle(f.power) + -- Flush BG frames + if f.healthBGFrame then + f.healthBGFrame:ClearAllPoints() + f.healthBGFrame:SetPoint("TOPLEFT", f.health, "TOPLEFT", 0, 0) + f.healthBGFrame:SetPoint("BOTTOMRIGHT", f.health, "BOTTOMRIGHT", 0, 0) + end + if f.powerBGFrame then + f.powerBGFrame:ClearAllPoints() + f.powerBGFrame:SetPoint("TOPLEFT", f.power, "TOPLEFT", 0, 0) + f.powerBGFrame:SetPoint("BOTTOMRIGHT", f.power, "BOTTOMRIGHT", 0, 0) + end + -- Hide bar backgrounds (transparent) + if f.healthBGFrame then f.healthBGFrame:Hide() end + if f.powerBGFrame then f.powerBGFrame:Hide() end + if f.health and f.health.bg then f.health.bg:Hide() end + if f.power and f.power.bg then f.power.bg:Hide() end + else + -- Classic style: restore backdrops + SFrames:CreateUnitBackdrop(f) + if f.health and f.health.bg then f.health.bg:Show() end + if f.power and f.power.bg then f.power.bg:Show() end + if f.healthBGFrame then + SFrames:CreateUnitBackdrop(f.healthBGFrame) + f.healthBGFrame:Show() + f.healthBGFrame:ClearAllPoints() + f.healthBGFrame:SetPoint("TOPLEFT", f.health, "TOPLEFT", -1, 1) + f.healthBGFrame:SetPoint("BOTTOMRIGHT", f.health, "BOTTOMRIGHT", 1, -1) + end + if f.powerBGFrame then + SFrames:CreateUnitBackdrop(f.powerBGFrame) + f.powerBGFrame:Show() + f.powerBGFrame:ClearAllPoints() + f.powerBGFrame:SetPoint("TOPLEFT", f.power, "TOPLEFT", -1, 1) + f.powerBGFrame:SetPoint("BOTTOMRIGHT", f.power, "BOTTOMRIGHT", 1, -1) + end + if f.health then + f.health:ClearAllPoints() + f.health:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1) + f.health:SetPoint("TOPRIGHT", f, "TOPRIGHT", -1, -1) + f.health:SetHeight(18) + end + if f.power then + f.power:ClearAllPoints() + f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", 0, -1) + f.power:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -1, 1) + end + SFrames:RemoveGradientStyle(f.health) + SFrames:RemoveGradientStyle(f.power) + end +end + function SFrames.Pet:UpdateAll() if UnitExists("pet") then if SFramesDB and SFramesDB.showPetFrame == false then @@ -437,6 +537,9 @@ function SFrames.Pet:UpdateAll() local r, g, b = 0.33, 0.59, 0.33 self.frame.health:SetStatusBarColor(r, g, b) + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(self.frame.health) + end else self.frame:Hide() if self.foodPanel then self.foodPanel:Hide() end @@ -465,12 +568,6 @@ function SFrames.Pet:UpdateHealPrediction() 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 @@ -485,7 +582,7 @@ function SFrames.Pet:UpdateHealPrediction() end if maxHp <= 0 or UnitIsDeadOrGhost("pet") then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -497,7 +594,7 @@ function SFrames.Pet:UpdateHealPrediction() local missing = maxHp - hp if missing <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -505,13 +602,13 @@ function SFrames.Pet:UpdateHealPrediction() 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() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end local barWidth = self.frame.health:GetWidth() if barWidth <= 0 then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -521,7 +618,7 @@ function SFrames.Pet:UpdateHealPrediction() local availableWidth = barWidth - currentWidth if availableWidth <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -587,6 +684,9 @@ function SFrames.Pet:UpdatePowerType() else self.frame.power:SetStatusBarColor(0, 0, 1) end + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(self.frame.power) + end end function SFrames.Pet:UpdatePower() @@ -594,6 +694,7 @@ function SFrames.Pet:UpdatePower() local maxPower = UnitManaMax("pet") self.frame.power:SetMinMaxValues(0, maxPower) self.frame.power:SetValue(power) + SFrames:UpdateRainbowBar(self.frame.power, power, maxPower, "pet") end function SFrames.Pet:UpdateHappiness() diff --git a/Units/Player.lua b/Units/Player.lua index cb41dd5..ca99e97 100644 --- a/Units/Player.lua +++ b/Units/Player.lua @@ -34,6 +34,17 @@ local function Clamp(value, minValue, maxValue) return value end +local function SetTextureIfPresent(region, texturePath) + if region and region.SetTexture and texturePath then + region:SetTexture(texturePath) + end +end + +local function ApplyFontIfPresent(fs, size, fontKey, fallbackFontKey) + if not fs then return end + SFrames:ApplyFontString(fs, size, fontKey, fallbackFontKey) +end + function SFrames.Player:GetConfig() local db = SFramesDB or {} @@ -52,6 +63,33 @@ function SFrames.Player:GetConfig() local powerHeight = tonumber(db.playerPowerHeight) or 9 powerHeight = Clamp(math.floor(powerHeight + 0.5), 6, 40) + local showPortrait = db.playerShowPortrait ~= false + local gradientStyle = SFrames:IsGradientStyle() + local classicDefaultPowerWidth = width - (showPortrait and portraitWidth or 0) - 2 + if classicDefaultPowerWidth < 60 then + classicDefaultPowerWidth = 60 + end + + local rawPowerWidth = tonumber(db.playerPowerWidth) + local legacyFullWidth = tonumber(db.playerFrameWidth) or width + local defaultPowerWidth = gradientStyle and width or classicDefaultPowerWidth + local maxPowerWidth = gradientStyle and width or (width - 2) + local powerWidth + if gradientStyle then + -- 渐变风格:能量条始终与血条等宽(全宽) + powerWidth = width + elseif not rawPowerWidth + or math.abs(rawPowerWidth - legacyFullWidth) < 0.5 + or math.abs(rawPowerWidth - classicDefaultPowerWidth) < 0.5 then + powerWidth = defaultPowerWidth + else + powerWidth = rawPowerWidth + end + powerWidth = Clamp(math.floor(powerWidth + 0.5), 60, maxPowerWidth) + + local powerOffsetX = Clamp(math.floor((tonumber(db.playerPowerOffsetX) or 0) + 0.5), -120, 120) + local powerOffsetY = Clamp(math.floor((tonumber(db.playerPowerOffsetY) or 0) + 0.5), -80, 80) + local height = healthHeight + powerHeight + 4 height = Clamp(height, 30, 140) @@ -61,6 +99,12 @@ function SFrames.Player:GetConfig() local valueFont = tonumber(db.playerValueFontSize) or 10 valueFont = Clamp(math.floor(valueFont + 0.5), 8, 18) + local healthFont = tonumber(db.playerHealthFontSize) or valueFont + healthFont = Clamp(math.floor(healthFont + 0.5), 8, 18) + + local powerFont = tonumber(db.playerPowerFontSize) or valueFont + powerFont = Clamp(math.floor(powerFont + 0.5), 8, 18) + local frameScale = tonumber(db.playerFrameScale) or 1 frameScale = Clamp(frameScale, 0.7, 1.8) @@ -70,8 +114,16 @@ function SFrames.Player:GetConfig() portraitWidth = portraitWidth, healthHeight = healthHeight, powerHeight = powerHeight, + powerWidth = powerWidth, + powerOffsetX = powerOffsetX, + powerOffsetY = powerOffsetY, + powerOnTop = db.playerPowerOnTop == true, nameFont = nameFont, valueFont = valueFont, + healthFont = healthFont, + powerFont = powerFont, + healthTexture = SFrames:ResolveBarTexture("playerHealthTexture", "barTexture"), + powerTexture = SFrames:ResolveBarTexture("playerPowerTexture", "barTexture"), scale = frameScale, } end @@ -146,8 +198,8 @@ function SFrames.Player:ApplyConfig() if f.power then f.power:ClearAllPoints() - f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", 0, -1) - f.power:SetPoint("TOPRIGHT", f.health, "BOTTOMRIGHT", 0, 0) + f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", cfg.powerOffsetX, -1 + cfg.powerOffsetY) + f.power:SetWidth(cfg.powerWidth) f.power:SetHeight(cfg.powerHeight) end @@ -161,6 +213,18 @@ function SFrames.Player:ApplyConfig() if showPortrait then f.restOverlay:SetAlpha(1) else f.restOverlay:SetAlpha(0) end end + if f.health and f.power then + local healthLevel = f:GetFrameLevel() + 2 + local powerLevel = cfg.powerOnTop and (healthLevel + 1) or (healthLevel - 1) + f.health:SetFrameLevel(healthLevel) + f.power:SetFrameLevel(powerLevel) + end + + SFrames:ApplyConfiguredUnitBackdrop(f, "player") + if f.healthBGFrame then SFrames:ApplyConfiguredUnitBackdrop(f.healthBGFrame, "player") end + if f.powerBGFrame then SFrames:ApplyConfiguredUnitBackdrop(f.powerBGFrame, "player") end + if f.portraitBG then SFrames:ApplyConfiguredUnitBackdrop(f.portraitBG, "player", true) end + if f.castbar then f.castbar:ClearAllPoints() if showPortrait then @@ -172,49 +236,134 @@ function SFrames.Player:ApplyConfig() end end - local outline = (SFrames and SFrames.Media and SFrames.Media.fontOutline) or "OUTLINE" - local fontPath = SFrames:GetFont() + SFrames:ApplyStatusBarTexture(f.health, "playerHealthTexture", "barTexture") + SFrames:ApplyStatusBarTexture(f.power, "playerPowerTexture", "barTexture") + if f.manaBar then + SFrames:ApplyStatusBarTexture(f.manaBar, "playerPowerTexture", "barTexture") + end + SetTextureIfPresent(f.health and f.health.bg, cfg.healthTexture) + SetTextureIfPresent(f.health and f.health.healPredMine, cfg.healthTexture) + SetTextureIfPresent(f.health and f.health.healPredOther, cfg.healthTexture) + SetTextureIfPresent(f.health and f.health.healPredOver, cfg.healthTexture) + SetTextureIfPresent(f.power and f.power.bg, cfg.powerTexture) + SetTextureIfPresent(f.manaBar and f.manaBar.bg, cfg.powerTexture) - if f.nameText then - f.nameText:SetFont(fontPath, cfg.nameFont, outline) - end - if f.healthText then - f.healthText:SetFont(fontPath, cfg.valueFont, outline) - end - if f.powerText then - f.powerText:SetFont(fontPath, cfg.valueFont, outline) + -- Gradient style preset + if SFrames:IsGradientStyle() then + -- Hide portrait, its backdrop, and class icon (no portrait in gradient mode) + if f.portrait then f.portrait:Hide() end + if f.portraitBG then f.portraitBG:Hide() end + if f.restOverlay then f.restOverlay:SetAlpha(0) end + if f.classIcon then f.classIcon:Hide(); if f.classIcon.overlay then f.classIcon.overlay:Hide() end end + -- Strip backdrops + SFrames:ClearBackdrop(f) + SFrames:ClearBackdrop(f.healthBGFrame) + SFrames:ClearBackdrop(f.powerBGFrame) + -- Health bar full width + if f.health then + f.health:ClearAllPoints() + f.health:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0) + f.health:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0) + f.health:SetHeight(cfg.healthHeight) + end + -- Power bar full width, below health + if f.power then + f.power:ClearAllPoints() + f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", cfg.powerOffsetX, -2 + cfg.powerOffsetY) + f.power:SetWidth(cfg.powerWidth) + f.power:SetHeight(cfg.powerHeight) + end + -- Apply gradient overlays + SFrames:ApplyGradientStyle(f.health) + SFrames:ApplyGradientStyle(f.power) + if f.manaBar then SFrames:ApplyGradientStyle(f.manaBar) end + -- Reposition healthBGFrame / powerBGFrame flush (no border padding) + if f.healthBGFrame then + f.healthBGFrame:ClearAllPoints() + f.healthBGFrame:SetPoint("TOPLEFT", f.health, "TOPLEFT", 0, 0) + f.healthBGFrame:SetPoint("BOTTOMRIGHT", f.health, "BOTTOMRIGHT", 0, 0) + end + if f.powerBGFrame then + f.powerBGFrame:ClearAllPoints() + f.powerBGFrame:SetPoint("TOPLEFT", f.power, "TOPLEFT", 0, 0) + f.powerBGFrame:SetPoint("BOTTOMRIGHT", f.power, "BOTTOMRIGHT", 0, 0) + end + -- Hide bar backgrounds (transparent) + if f.healthBGFrame then f.healthBGFrame:Hide() end + if f.powerBGFrame then f.powerBGFrame:Hide() end + if f.health and f.health.bg then f.health.bg:Hide() end + if f.power and f.power.bg then f.power.bg:Hide() end + if f.manaBar and f.manaBar.bg then f.manaBar.bg:Hide() end + else + -- Classic style: remove gradient overlays if they exist + SFrames:RemoveGradientStyle(f.health) + SFrames:RemoveGradientStyle(f.power) + if f.manaBar then SFrames:RemoveGradientStyle(f.manaBar) end + -- Restore bar backgrounds + if f.healthBGFrame then f.healthBGFrame:Show() end + if f.powerBGFrame then f.powerBGFrame:Show() end + if f.health and f.health.bg then f.health.bg:Show() end + if f.power and f.power.bg then f.power.bg:Show() end + if f.manaBar and f.manaBar.bg then f.manaBar.bg:Show() end end + + ApplyFontIfPresent(f.nameText, cfg.nameFont, "playerNameFontKey") + ApplyFontIfPresent(f.healthText, cfg.healthFont, "playerHealthFontKey") + ApplyFontIfPresent(f.powerText, cfg.powerFont, "playerPowerFontKey") if f.manaText then - local manaFont = cfg.valueFont - 1 + local manaFont = cfg.powerFont - 1 if manaFont < 8 then manaFont = 8 end - f.manaText:SetFont(fontPath, manaFont, outline) + ApplyFontIfPresent(f.manaText, manaFont, "playerPowerFontKey") end if f.zLetters then for i = 1, 3 do if f.zLetters[i] and f.zLetters[i].text then - f.zLetters[i].text:SetFont(fontPath, 8 + (i - 1) * 3, "OUTLINE") + SFrames:ApplyFontString(f.zLetters[i].text, 8 + (i - 1) * 3, nil, "fontKey") end end end + -- Icon positions + if f.leaderIcon then + local ox = tonumber(db.playerLeaderIconOffsetX) or 0 + local oy = tonumber(db.playerLeaderIconOffsetY) or 0 + f.leaderIcon:ClearAllPoints() + f.leaderIcon:SetPoint("TOPLEFT", f, "TOPLEFT", ox, oy) + end + if f.raidIconOverlay then + local ox = tonumber(db.playerRaidIconOffsetX) or 0 + local oy = tonumber(db.playerRaidIconOffsetY) or 0 + f.raidIconOverlay:ClearAllPoints() + f.raidIconOverlay:SetPoint("CENTER", f.health, "TOP", ox, oy) + end + self:UpdateAll() end +local RAINBOW_TEX_PATH = "Interface\\AddOns\\Nanami-UI\\img\\progress" + function SFrames.Player:Initialize() local f = CreateFrame("Button", "SFramesPlayerFrame", UIParent) f:SetWidth(SFrames.Config.width) f:SetHeight(SFrames.Config.height) + + local frameScale = (SFramesDB and type(SFramesDB.playerFrameScale) == "number") and SFramesDB.playerFrameScale or 1 + f:SetScale(frameScale) + if SFramesDB and SFramesDB.Positions and SFramesDB.Positions["PlayerFrame"] then local pos = SFramesDB.Positions["PlayerFrame"] - f:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs, pos.yOfs) + local fScale = f:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fScale > 0.01 and math.abs(fScale - 1) > 0.001 then + f:SetPoint(pos.point, UIParent, pos.relativePoint, + (pos.xOfs or 0) / fScale, (pos.yOfs or 0) / fScale) + else + f:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + end else f:SetPoint("CENTER", UIParent, "CENTER", -200, -100) end - local frameScale = (SFramesDB and type(SFramesDB.playerFrameScale) == "number") and SFramesDB.playerFrameScale or 1 - f:SetScale(frameScale) - - -- Make it movable + f:SetMovable(true) f:EnableMouse(true) f:RegisterForDrag("LeftButton") @@ -224,6 +373,11 @@ function SFrames.Player:Initialize() if not SFramesDB then SFramesDB = {} end if not SFramesDB.Positions then SFramesDB.Positions = {} end local point, relativeTo, relativePoint, xOfs, yOfs = f:GetPoint() + local fSc = f:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fSc > 0.01 and math.abs(fSc - 1) > 0.001 then + xOfs = (xOfs or 0) * fSc + yOfs = (yOfs or 0) * fSc + end SFramesDB.Positions["PlayerFrame"] = { point = point, relativePoint = relativePoint, xOfs = xOfs, yOfs = yOfs } end) @@ -269,7 +423,7 @@ function SFrames.Player:Initialize() 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) + hbg:SetFrameLevel(math.max(0, f:GetFrameLevel() - 1)) SFrames:CreateUnitBackdrop(hbg) f.healthBGFrame = hbg @@ -308,7 +462,7 @@ function SFrames.Player:Initialize() 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) + powerbg:SetFrameLevel(math.max(0, f:GetFrameLevel() - 1)) SFrames:CreateUnitBackdrop(powerbg) f.powerBGFrame = powerbg @@ -318,6 +472,12 @@ function SFrames.Player:Initialize() f.power.bg:SetTexture(SFrames:GetTexture()) f.power.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) + -- Rainbow overlay for power bar + f.power.rainbowTex = f.power:CreateTexture(nil, "OVERLAY") + f.power.rainbowTex:SetTexture(RAINBOW_TEX_PATH) + f.power.rainbowTex:SetAllPoints() + f.power.rainbowTex:Hide() + -- Five-second rule ticker (mana regen delay indicator) f.power.fsrGlow = f.power:CreateTexture(nil, "OVERLAY") f.power.fsrGlow:SetTexture("Interface\\CastingBar\\UI-CastingBar-Spark") @@ -433,7 +593,7 @@ function SFrames.Player:Initialize() f.leaderIcon:SetTexture("Interface\\GroupFrame\\UI-Group-LeaderIcon") f.leaderIcon:SetWidth(16) f.leaderIcon:SetHeight(16) - f.leaderIcon:SetPoint("TOPLEFT", f.portrait, "TOPLEFT", -4, 4) + f.leaderIcon:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0) f.leaderIcon:Hide() -- Raid Target Icon (top center of health bar, half outside frame) @@ -467,39 +627,6 @@ function SFrames.Player:Initialize() SFrames:RegisterEvent("PLAYER_LEVEL_UP", function() if arg1 then self.currentLevel = arg1 end self:UpdateAll() - if arg1 and mod(arg1, 2) == 0 and (not SFramesDB or SFramesDB.trainerReminder ~= false) then - self:ShowTrainerReminder(arg1) - end - end) - SFrames:RegisterEvent("TRAINER_SHOW", function() - SFrames.Player.trainerScannedThisVisit = nil - SFrames.Player.trainerShowPending = true - SFrames.Player.trainerRetryCount = 0 - if not SFrames.Player.trainerRetryFrame then - SFrames.Player.trainerRetryFrame = CreateFrame("Frame") - end - SFrames.Player.trainerRetryFrame:SetScript("OnUpdate", function() - if not this.elapsed then this.elapsed = 0 end - this.elapsed = this.elapsed + arg1 - if this.elapsed < 0.3 then return end - this.elapsed = 0 - if SFrames.Player.trainerScannedThisVisit then - this:SetScript("OnUpdate", nil) - return - end - SFrames.Player.trainerRetryCount = (SFrames.Player.trainerRetryCount or 0) + 1 - if SFrames.Player.trainerRetryCount > 10 then - SFrames.Player:ScanTrainer() - this:SetScript("OnUpdate", nil) - return - end - SFrames.Player:ScanTrainer() - end) - end) - SFrames:RegisterEvent("TRAINER_UPDATE", function() - if not SFrames.Player.trainerScannedThisVisit then - SFrames.Player:ScanTrainer() - end end) SFrames:RegisterEvent("PARTY_MEMBERS_CHANGED", function() self:UpdateLeaderIcon() end) SFrames:RegisterEvent("PARTY_LEADER_CHANGED", function() self:UpdateLeaderIcon() end) @@ -528,471 +655,6 @@ function SFrames.Player:Initialize() end) end -function SFrames.Player:HasSpellInBook(spellName) - local baseName = string.gsub(spellName, " 等级 %d+$", "") - baseName = string.gsub(baseName, " %d+级$", "") - local i = 1 - while true do - local name = GetSpellName(i, BOOKTYPE_SPELL) - if not name then return false end - if name == spellName or name == baseName then return true end - i = i + 1 - end -end - -function SFrames.Player:GetSpellIcon(skillDisplayName) - local baseName = string.gsub(skillDisplayName, " %d+级$", "") - local altName1 = string.gsub(baseName, ":", ":") - local altName2 = string.gsub(baseName, ":", ":") - local i = 1 - while true do - local name = GetSpellName(i, BOOKTYPE_SPELL) - if not name then break end - if name == baseName or name == altName1 or name == altName2 then - return GetSpellTexture(i, BOOKTYPE_SPELL) - end - i = i + 1 - end - return nil -end - -function SFrames.Player:ParseTrainerTooltipLevel(serviceIndex) - if not self.trainerScanTip then - local tt = CreateFrame("GameTooltip", "NanamiTrainerScanTip", nil, "GameTooltipTemplate") - tt:SetOwner(UIParent, "ANCHOR_NONE") - self.trainerScanTip = tt - end - local tt = self.trainerScanTip - tt:ClearLines() - if not tt.SetTrainerService then return nil end - tt:SetTrainerService(serviceIndex) - for j = 2, tt:NumLines() do - local textObj = getglobal("NanamiTrainerScanTipTextLeft" .. j) - if textObj then - local text = textObj:GetText() - if text then - local _, _, lvl = string.find(text, "需要等级%s*(%d+)") - if not lvl then - _, _, lvl = string.find(text, "Requires Level (%d+)") - end - if lvl then return tonumber(lvl) end - end - end - end - return nil -end - -function SFrames.Player:FindSkillLevelInStaticData(classEn, skillName) - local staticData = SFrames.ClassSkillData and SFrames.ClassSkillData[classEn] - if not staticData then return nil end - local baseName = string.gsub(skillName, " %d+级$", "") - baseName = string.gsub(baseName, "(等级 %d+)$", "") - baseName = string.gsub(baseName, "%s+$", "") - for level, skills in pairs(staticData) do - for _, s in ipairs(skills) do - local sBase = string.gsub(s, " %d+级$", "") - sBase = string.gsub(sBase, "(等级 %d+)$", "") - sBase = string.gsub(sBase, "%s+$", "") - if baseName == sBase or skillName == s then - return level - end - end - end - local talentData = SFrames.TalentTrainerSkills and SFrames.TalentTrainerSkills[classEn] - if talentData then - for level, entries in pairs(talentData) do - for _, entry in ipairs(entries) do - local tBase = string.gsub(entry[1], " %d+级$", "") - if baseName == tBase or skillName == entry[1] then - return level - end - end - end - end - return nil -end - -function SFrames.Player:ScanTrainer() - if self.scanningTrainer then return end - self.scanningTrainer = true - - local hex = SFrames.Theme and SFrames.Theme:GetAccentHex() or "ffffb3d9" - - if not GetNumTrainerServices then - DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r |cffff6666训练师扫描失败: GetNumTrainerServices 不存在|r") - self.scanningTrainer = nil - return - end - if IsTradeskillTrainer and IsTradeskillTrainer() then - self.scanningTrainer = nil - return - end - - if SetTrainerServiceTypeFilter then - SetTrainerServiceTypeFilter("available", 1) - SetTrainerServiceTypeFilter("unavailable", 1) - SetTrainerServiceTypeFilter("used", 1) - end - - local _, classEn = UnitClass("player") - if not classEn or not SFramesDB then self.scanningTrainer = nil return end - - local cache = {} - local numServices = GetNumTrainerServices() - if not numServices or numServices == 0 then - DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r |cffff6666训练师扫描: 未检测到技能列表 (0项),将在数据加载后重试|r") - self.scanningTrainer = nil - return - end - - local hasLevelAPI = (GetTrainerServiceLevelReq ~= nil) - local noLevelCount = 0 - local totalAdded = 0 - - local iconMissCount = 0 - - for i = 1, numServices do - local name, subText, serviceType = GetTrainerServiceInfo(i) - - if name and subText and subText ~= "" then - local icon = GetTrainerServiceIcon and GetTrainerServiceIcon(i) - - if (not icon or icon == "") and ClassTrainerFrame then - local skillButton = getglobal("ClassTrainerSkill" .. i) - if skillButton then - local iconTex = getglobal("ClassTrainerSkill" .. i .. "Icon") - if iconTex and iconTex.GetTexture then - icon = iconTex:GetTexture() - end - end - end - - if not icon or icon == "" then - iconMissCount = iconMissCount + 1 - end - - local reqLevel - if hasLevelAPI then - reqLevel = GetTrainerServiceLevelReq(i) - end - if not reqLevel or reqLevel == 0 then - reqLevel = self:ParseTrainerTooltipLevel(i) - end - if not reqLevel or reqLevel == 0 then - local lookupName = name .. " " .. subText - reqLevel = self:FindSkillLevelInStaticData(classEn, lookupName) - if not reqLevel then - reqLevel = self:FindSkillLevelInStaticData(classEn, name) - end - end - if not reqLevel or reqLevel == 0 then - noLevelCount = noLevelCount + 1 - end - - if reqLevel and reqLevel > 0 then - local displayName = name .. " " .. subText - if not cache[reqLevel] then - cache[reqLevel] = {} - end - table.insert(cache[reqLevel], { - name = displayName, - icon = icon or "", - }) - totalAdded = totalAdded + 1 - end - end - end - - if not SFramesDB.trainerCache then - SFramesDB.trainerCache = {} - end - SFramesDB.trainerCache[classEn] = cache - self.trainerScannedThisVisit = true - - local levelCount = 0 - for _ in pairs(cache) do levelCount = levelCount + 1 end - local iconOk = totalAdded - iconMissCount - DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r 已从训练师更新技能数据(" .. levelCount .. " 个等级," .. totalAdded .. " 项技能," .. iconOk .. " 个图标)") - if noLevelCount > 0 then - DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r |cffff9900有 " .. noLevelCount .. " 项技能无法确定等级要求,已跳过|r") - end - if iconMissCount > 0 then - DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r |cffff9900有 " .. iconMissCount .. " 项技能未获取到图标|r") - end - - self.scanningTrainer = nil -end - -function SFrames.Player:ShowTrainerReminder(newLevel) - local _, classEn = UnitClass("player") - local classNames = { - WARRIOR = "战士", PALADIN = "圣骑士", HUNTER = "猎人", - ROGUE = "盗贼", PRIEST = "牧师", SHAMAN = "萨满祭司", - MAGE = "法师", WARLOCK = "术士", DRUID = "德鲁伊", - } - local className = classNames[classEn] or UnitClass("player") - - local classFallbackIcons = { - WARRIOR = "Interface\\Icons\\Ability_Warrior_OffensiveStance", - PALADIN = "Interface\\Icons\\Spell_Holy_HolyBolt", - HUNTER = "Interface\\Icons\\Ability_Marksmanship", - ROGUE = "Interface\\Icons\\Ability_BackStab", - PRIEST = "Interface\\Icons\\Spell_Holy_HolyBolt", - SHAMAN = "Interface\\Icons\\Spell_Nature_Lightning", - MAGE = "Interface\\Icons\\Spell_Frost_IceStorm", - WARLOCK = "Interface\\Icons\\Spell_Shadow_DeathCoil", - DRUID = "Interface\\Icons\\Spell_Nature_Regeneration", - } - local fallbackIcon = classFallbackIcons[classEn] or "Interface\\Icons\\Trade_Engraving" - - local allSkills = {} - local allIcons = {} - local talentSkills = {} - local usedCache = false - - local classCache = SFramesDB and SFramesDB.trainerCache and SFramesDB.trainerCache[classEn] - if classCache then - local lowLevel = newLevel - 1 - if lowLevel < 1 then lowLevel = 1 end - for lv = lowLevel, newLevel do - if classCache[lv] then - usedCache = true - for _, entry in ipairs(classCache[lv]) do - if not self:HasSpellInBook(entry.name) then - table.insert(allSkills, entry.name) - local ico = entry.icon - if not ico or ico == "" then - ico = self:GetSpellIcon(entry.name) or fallbackIcon - end - table.insert(allIcons, ico) - end - end - end - end - end - - if not usedCache then - local baseSkills = SFrames.ClassSkillData and SFrames.ClassSkillData[classEn] and SFrames.ClassSkillData[classEn][newLevel] - if baseSkills then - for _, s in ipairs(baseSkills) do - table.insert(allSkills, s) - table.insert(allIcons, self:GetSpellIcon(s) or fallbackIcon) - end - end - - local talentData = SFrames.TalentTrainerSkills and SFrames.TalentTrainerSkills[classEn] and SFrames.TalentTrainerSkills[classEn][newLevel] - if talentData then - for _, entry in ipairs(talentData) do - local displayName = entry[1] - local requiredSpell = entry[2] - if self:HasSpellInBook(requiredSpell) then - table.insert(allSkills, displayName) - table.insert(allIcons, self:GetSpellIcon(displayName) or fallbackIcon) - table.insert(talentSkills, displayName) - end - end - end - end - - local mountQuest = SFrames.ClassMountQuests and SFrames.ClassMountQuests[classEn] and SFrames.ClassMountQuests[classEn][newLevel] - - local skillCount = table.getn(allSkills) - - if skillCount == 0 and not mountQuest then - return - end - - local hex = SFrames.Theme and SFrames.Theme:GetAccentHex() or "ffffb3d9" - - SFrames:Print(string.format("已达到 %d 级!你的%s训练师有新技能可以学习。", newLevel, className)) - if skillCount > 0 then - DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r 可学习的技能(共 " .. skillCount .. " 项):") - local line = " " - local lineCount = 0 - for i = 1, skillCount do - if lineCount > 0 then line = line .. ", " end - line = line .. "|cffffd100" .. allSkills[i] .. "|r" - lineCount = lineCount + 1 - if i == skillCount or lineCount == 4 then - DEFAULT_CHAT_FRAME:AddMessage(line) - line = " " - lineCount = 0 - end - end - end - if not usedCache and table.getn(talentSkills) > 0 then - DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r |cff00ff00(天赋)|r " .. table.concat(talentSkills, ", ")) - end - if mountQuest then - DEFAULT_CHAT_FRAME:AddMessage("|c" .. hex .. "[Nanami-UI]|r |cffffff00★|r " .. mountQuest) - end - - local bannerMsg = string.format("Lv.%d - %s训练师有 %d 项新技能可学习", newLevel, className, skillCount) - if mountQuest then - bannerMsg = bannerMsg .. " + " .. mountQuest - end - if skillCount == 0 and not mountQuest then - bannerMsg = string.format("Lv.%d - 前往%s训练师查看可学技能", newLevel, className) - elseif skillCount == 0 and mountQuest then - bannerMsg = string.format("Lv.%d - %s", newLevel, mountQuest) - end - UIErrorsFrame:AddMessage(bannerMsg, 1.0, 0.82, 0.0, 1, 5) - PlaySound("LEVELUP") - - if not self.trainerReminderFrame then - local fr = CreateFrame("Frame", "NanamiTrainerReminder", UIParent) - fr:SetWidth(440) - fr:SetHeight(106) - fr:SetPoint("TOP", UIParent, "TOP", 0, -120) - fr:SetFrameStrata("DIALOG") - - local bg = fr:CreateTexture(nil, "BACKGROUND") - bg:SetAllPoints(fr) - bg:SetTexture(0, 0, 0, 0.78) - - local border = fr:CreateTexture(nil, "BORDER") - border:SetPoint("TOPLEFT", fr, "TOPLEFT", -1, 1) - border:SetPoint("BOTTOMRIGHT", fr, "BOTTOMRIGHT", 1, -1) - border:SetTexture(1, 0.82, 0, 0.3) - - local icon = fr:CreateTexture(nil, "ARTWORK") - icon:SetWidth(36) - icon:SetHeight(36) - icon:SetPoint("TOPLEFT", fr, "TOPLEFT", 10, -6) - icon:SetTexture("Interface\\Icons\\INV_Misc_Book_11") - fr.icon = icon - - local title = fr:CreateFontString(nil, "OVERLAY", "GameFontNormal") - title:SetPoint("TOPLEFT", icon, "TOPRIGHT", 10, -2) - title:SetPoint("RIGHT", fr, "RIGHT", -10, 0) - title:SetJustifyH("LEFT") - title:SetTextColor(1, 0.82, 0) - fr.title = title - - local subtitle = fr:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - subtitle:SetPoint("TOPLEFT", icon, "TOPRIGHT", 10, -18) - subtitle:SetPoint("RIGHT", fr, "RIGHT", -10, 0) - subtitle:SetJustifyH("LEFT") - subtitle:SetTextColor(0.75, 0.75, 0.75) - fr.subtitle = subtitle - - fr.skillIcons = {} - fr.skillBorders = {} - local maxIcons = 13 - for idx = 1, maxIcons do - local bdr = fr:CreateTexture(nil, "BORDER") - bdr:SetWidth(30) - bdr:SetHeight(30) - bdr:SetPoint("TOPLEFT", fr, "TOPLEFT", 9 + (idx - 1) * 32, -45) - bdr:SetTexture(1, 0.82, 0, 0.25) - bdr:Hide() - fr.skillBorders[idx] = bdr - - local si = fr:CreateTexture(nil, "ARTWORK") - si:SetWidth(28) - si:SetHeight(28) - si:SetPoint("CENTER", bdr, "CENTER", 0, 0) - si:SetTexCoord(0.07, 0.93, 0.07, 0.93) - si:Hide() - fr.skillIcons[idx] = si - end - - local detail = fr:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") - detail:SetPoint("BOTTOMLEFT", fr, "BOTTOMLEFT", 10, 8) - detail:SetPoint("RIGHT", fr, "RIGHT", -10, 0) - detail:SetJustifyH("LEFT") - detail:SetTextColor(0.85, 0.85, 0.85) - fr.detail = detail - - fr:SetAlpha(0) - fr:Hide() - self.trainerReminderFrame = fr - end - - local fr = self.trainerReminderFrame - - for idx = 1, 13 do - fr.skillIcons[idx]:Hide() - fr.skillBorders[idx]:Hide() - end - - local iconCount = 0 - - for i = 1, skillCount do - if iconCount >= 13 then break end - iconCount = iconCount + 1 - fr.skillIcons[iconCount]:SetTexture(allIcons[i]) - fr.skillIcons[iconCount]:Show() - fr.skillBorders[iconCount]:Show() - end - if mountQuest and iconCount < 13 then - iconCount = iconCount + 1 - fr.skillIcons[iconCount]:SetTexture("Interface\\Icons\\Spell_Nature_Swiftness") - fr.skillIcons[iconCount]:Show() - fr.skillBorders[iconCount]:Show() - end - - fr.title:SetText(string.format("已达到 |cffffffff%d|r 级 — %s训练师有新技能", newLevel, className)) - - if skillCount > 0 or mountQuest then - local preview = "" - if skillCount > 0 then - preview = "|cffffd100" .. allSkills[1] .. "|r" - if skillCount > 1 then preview = preview .. ", |cffffd100" .. allSkills[2] .. "|r" end - if skillCount > 2 then preview = preview .. ", |cffffd100" .. allSkills[3] .. "|r" end - if skillCount > 3 then preview = preview .. " 等 " .. skillCount .. " 项" end - end - if mountQuest then - if preview ~= "" then preview = preview .. " | " end - preview = preview .. "|cffffff00" .. mountQuest .. "|r" - end - fr.subtitle:SetText(preview) - fr.detail:SetText("详见聊天窗口") - else - fr.subtitle:SetText("") - fr.detail:SetText("前往职业训练师查看可学习的技能") - end - - if iconCount > 0 then - fr:SetHeight(106) - else - fr:SetHeight(80) - end - fr:Show() - fr:SetAlpha(0) - - fr.fadeState = "in" - fr.fadeTimer = 0 - fr.holdTimer = 0 - fr:SetScript("OnUpdate", function() - local dt = arg1 - if fr.fadeState == "in" then - fr.fadeTimer = fr.fadeTimer + dt - local a = fr.fadeTimer / 0.5 - if a >= 1 then - a = 1 - fr.fadeState = "hold" - end - fr:SetAlpha(a) - elseif fr.fadeState == "hold" then - fr.holdTimer = fr.holdTimer + dt - if fr.holdTimer >= 8 then - fr.fadeState = "out" - fr.fadeTimer = 0 - end - elseif fr.fadeState == "out" then - fr.fadeTimer = fr.fadeTimer + dt - local a = 1 - fr.fadeTimer / 1.0 - if a <= 0 then - a = 0 - fr:Hide() - fr:SetScript("OnUpdate", nil) - end - fr:SetAlpha(a) - end - end) -end - function SFrames.Player:UpdateAll() if not self.frame then return end self:UpdateHealth() @@ -1029,7 +691,7 @@ function SFrames.Player:UpdateAll() nameLine = nameLine .. " " .. className end - if not (SFramesDB and SFramesDB.playerShowClassIcon == false) then + if not SFrames:IsGradientStyle() and not (SFramesDB and SFramesDB.playerShowClassIcon == false) then SFrames:SetClassIcon(self.frame.classIcon, class) else self.frame.classIcon:Hide() @@ -1037,6 +699,8 @@ function SFrames.Player:UpdateAll() end local useClassColor = not (SFramesDB and SFramesDB.classColorHealth == false) + -- Gradient style always uses class colors + if SFrames:IsGradientStyle() then useClassColor = true end if useClassColor and class and SFrames.Config.colors.class[class] then local color = SFrames.Config.colors.class[class] @@ -1050,6 +714,10 @@ function SFrames.Player:UpdateAll() self.frame.nameText:SetText(nameLine) self.frame.nameText:SetTextColor(1, 1, 1) end + -- Re-apply gradient after color change (SetStatusBarColor resets SetGradientAlpha) + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(self.frame.health) + end end function SFrames.Player:UpdateRestingStatus() @@ -1108,9 +776,9 @@ function SFrames.Player:UpdateHealth() self.frame.health:SetValue(hp) if maxHp > 0 then - self.frame.healthText:SetText(hp .. " / " .. maxHp) + self.frame.healthText:SetText(SFrames:FormatCompactPair(hp, maxHp)) else - self.frame.healthText:SetText(hp) + self.frame.healthText:SetText(SFrames:FormatCompactNumber(hp)) end self:UpdateHealPrediction() @@ -1122,12 +790,6 @@ function SFrames.Player:UpdateHealPrediction() 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 @@ -1146,7 +808,7 @@ function SFrames.Player:UpdateHealPrediction() end if maxHp <= 0 or UnitIsDeadOrGhost("player") then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -1158,7 +820,7 @@ function SFrames.Player:UpdateHealPrediction() end local missing = maxHp - hp if missing <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -1166,14 +828,14 @@ function SFrames.Player:UpdateHealPrediction() 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() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end 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() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -1183,7 +845,7 @@ function SFrames.Player:UpdateHealPrediction() local availableWidth = barWidth - currentWidth if availableWidth <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -1249,6 +911,9 @@ function SFrames.Player:UpdatePowerType() else self.frame.power:SetStatusBarColor(0, 0, 1) end + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(self.frame.power) + end end function SFrames.Player:GetDruidAltMana(currentPower, currentMaxPower) @@ -1393,7 +1058,9 @@ function SFrames.Player:UpdatePower() local maxPower = UnitManaMax("player") self.frame.power:SetMinMaxValues(0, maxPower) self.frame.power:SetValue(power) - self.frame.powerText:SetText(power .. " / " .. maxPower) + self.frame.powerText:SetText(SFrames:FormatCompactPair(power, maxPower)) + + SFrames:UpdateRainbowBar(self.frame.power, power, maxPower, "player") local _, class = UnitClass("player") local powerType = UnitPowerType("player") @@ -1464,7 +1131,7 @@ function SFrames.Player:UpdatePower() if maxMana and maxMana > 0 then local pct = math.floor(mana / maxMana * 100 + 0.5) - self.frame.manaText:SetText(pct .. "% " .. mana) + self.frame.manaText:SetText(pct .. "% " .. SFrames:FormatCompactNumber(mana)) self.frame.manaText:Show() if self.frame.manaBar then self.frame.manaBar:SetMinMaxValues(0, maxMana) @@ -1633,38 +1300,16 @@ local function HSVtoRGB(h, s, 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 + if not cb.rainbowTex 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 + cb.rainbowTex:ClearAllPoints() + cb.rainbowTex:SetPoint("TOPLEFT", cb, "TOPLEFT", 0, 0) + cb.rainbowTex:SetPoint("BOTTOMRIGHT", cb, "BOTTOMLEFT", fillW, 0) + cb.rainbowTex:SetTexCoord(0, progress, 0, 1) + cb.rainbowTex:Show() end function SFrames.Player:CreateCastbar() @@ -1710,16 +1355,9 @@ function SFrames.Player:CreateCastbar() 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.rainbowTex = cb:CreateTexture(nil, "ARTWORK") + cb.rainbowTex:SetTexture(RAINBOW_TEX_PATH) + cb.rainbowTex:Hide() cb:Hide() cbbg:Hide() @@ -1759,15 +1397,27 @@ function SFrames.Player:ApplyCastbarPosition() cb:SetParent(UIParent) cb.cbbg:SetParent(UIParent) cb.ibg:SetParent(UIParent) - cb:SetWidth(280) - cb:SetHeight(20) - cb:SetPoint("BOTTOM", UIParent, "BOTTOM", 0, 120) + local cbW = db.castbarWidth or 280 + local cbH = db.castbarHeight or 20 + cb:SetWidth(cbW) + cb:SetHeight(cbH) cb:SetFrameStrata("HIGH") + if SFrames.Movers and SFrames.Movers.ApplyPosition then + SFrames.Movers:ApplyPosition("PlayerCastbar", cb, + "BOTTOM", "SFramesPetHolder", "TOP", 0, 6) + else + local petHolder = _G["SFramesPetHolder"] + if petHolder then + cb:SetPoint("BOTTOM", petHolder, "TOP", 0, 6) + else + cb:SetPoint("BOTTOM", UIParent, "BOTTOM", 0, 120) + end + end 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: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) @@ -1775,7 +1425,11 @@ function SFrames.Player:ApplyCastbarPosition() if SFrames.Movers and SFrames.Movers.RegisterMover then SFrames.Movers:RegisterMover("PlayerCastbar", cb, "施法条", - "BOTTOM", "UIParent", "BOTTOM", 0, 120) + "BOTTOM", "SFramesPetHolder", "TOP", 0, 6, + nil, { alwaysShowInLayout = true }) + end + if SFrames.Movers and SFrames.Movers.SetMoverAlwaysShow then + SFrames.Movers:SetMoverAlwaysShow("PlayerCastbar", true) end else cb:SetParent(self.frame) @@ -1796,6 +1450,9 @@ function SFrames.Player:ApplyCastbarPosition() 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.SetMoverAlwaysShow then + SFrames.Movers:SetMoverAlwaysShow("PlayerCastbar", false) + end end end @@ -1836,11 +1493,12 @@ function SFrames.Player:CastbarStart(spellName, duration) cb.ibg:Hide() end - cb:SetAlpha(1) - cb.cbbg:SetAlpha(1) + local cbAlpha = tonumber((SFramesDB or {}).castbarAlpha) or 1 + cb:SetAlpha(cbAlpha) + cb.cbbg:SetAlpha(cbAlpha) if texture then - cb.icon:SetAlpha(1) - cb.ibg:SetAlpha(1) + cb.icon:SetAlpha(cbAlpha) + cb.ibg:SetAlpha(cbAlpha) end cb:Show() cb.cbbg:Show() @@ -1901,11 +1559,12 @@ function SFrames.Player:CastbarChannelStart(duration, spellName) cb.ibg:Hide() end - cb:SetAlpha(1) - cb.cbbg:SetAlpha(1) + local cbAlpha = tonumber((SFramesDB or {}).castbarAlpha) or 1 + cb:SetAlpha(cbAlpha) + cb.cbbg:SetAlpha(cbAlpha) if texture then - cb.icon:SetAlpha(1) - cb.ibg:SetAlpha(1) + cb.icon:SetAlpha(cbAlpha) + cb.ibg:SetAlpha(cbAlpha) end cb:Show() cb.cbbg:Show() @@ -1961,6 +1620,9 @@ function SFrames.Player:CastbarOnUpdate() cb.casting = nil cb.fadeOut = true cb:SetValue(cb.maxValue) + if db.castbarRainbow and cb.rainbowActive then + UpdateRainbowProgress(cb, 1) + end return end cb:SetValue(elapsed) @@ -1973,9 +1635,7 @@ function SFrames.Player:CastbarOnUpdate() 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 + if cb.rainbowTex then cb.rainbowTex:Hide() end cb.rainbowActive = nil end if not cb.icon:IsShown() then @@ -1987,6 +1647,9 @@ function SFrames.Player:CastbarOnUpdate() cb.channeling = nil cb.fadeOut = true cb:SetValue(0) + if db.castbarRainbow and cb.rainbowActive then + UpdateRainbowProgress(cb, 0) + end return end cb:SetValue(timeRemaining) @@ -1999,20 +1662,13 @@ function SFrames.Player:CastbarOnUpdate() 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 + if cb.rainbowTex then cb.rainbowTex:Hide() 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) @@ -2021,6 +1677,11 @@ function SFrames.Player:CastbarOnUpdate() cb.ibg:SetAlpha(alpha) else cb.fadeOut = nil + if cb.rainbowActive then + if cb.rainbowTex then cb.rainbowTex:Hide() end + cb:SetStatusBarColor(1, 0.7, 0) + cb.rainbowActive = nil + end cb:Hide() cb.cbbg:Hide() cb.icon:Hide() @@ -2059,9 +1720,10 @@ function SFrames.Player:CastbarTryResolveIcon() end if texture then + local cbAlpha = tonumber((SFramesDB or {}).castbarAlpha) or 1 cb.icon:SetTexture(texture) - cb.icon:SetAlpha(1) - cb.ibg:SetAlpha(1) + cb.icon:SetAlpha(cbAlpha) + cb.ibg:SetAlpha(cbAlpha) cb.icon:Show() cb.ibg:Show() end diff --git a/Units/Raid.lua b/Units/Raid.lua index e5677af..1dd9ad3 100644 --- a/Units/Raid.lua +++ b/Units/Raid.lua @@ -9,10 +9,76 @@ local UNIT_PADDING = 2 local RAID_UNIT_LOOKUP = {} for i = 1, 40 do RAID_UNIT_LOOKUP["raid" .. i] = true end +-- Pre-allocated tables reused every UpdateAuras call to avoid per-call garbage +local _foundIndicators = { [1] = false, [2] = false, [3] = false, [4] = false } +local _debuffColor = { r = 0, g = 0, b = 0 } + +-- Module-level helper: match aura name against a list (no closure allocation) +local function MatchesList(auraName, list) + for _, name in ipairs(list) do + if string.find(auraName, name) then + return true + end + end + return false +end + +-- Module-level helper: get buff name via SuperWoW aura ID or tooltip scan +local function RaidGetBuffName(unit, index) + if SFrames.superwow_active and SpellInfo 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 + 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 RaidGetDebuffName(unit, index) + if SFrames.superwow_active and SpellInfo 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 + 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 + local function GetIncomingHeals(unit) return SFrames:GetIncomingHeals(unit) end +local function Clamp(value, minValue, maxValue) + if value < minValue then + return minValue + end + if value > maxValue then + return maxValue + end + return value +end + +local function SetTextureIfPresent(region, texturePath) + if region and region.SetTexture and texturePath then + region:SetTexture(texturePath) + end +end + function SFrames.Raid:GetMetrics() local db = SFramesDB or {} @@ -25,29 +91,66 @@ function SFrames.Raid:GetMetrics() local healthHeight = tonumber(db.raidHealthHeight) or math.floor((height - 3) * 0.8) healthHeight = math.max(10, math.min(height - 6, healthHeight)) - local powerHeight = height - healthHeight - 3 + local powerHeight = tonumber(db.raidPowerHeight) or (height - healthHeight - 3) + powerHeight = math.max(0, math.min(height - 3, powerHeight)) if not db.raidShowPower then powerHeight = 0 healthHeight = height - 2 end + local gradientStyle = SFrames:IsGradientStyle() + local availablePowerWidth = width - 2 + if availablePowerWidth < 20 then + availablePowerWidth = 20 + end + + local rawPowerWidth = tonumber(db.raidPowerWidth) + local legacyFullWidth = tonumber(db.raidFrameWidth) or width + local defaultPowerWidth = gradientStyle and width or availablePowerWidth + local maxPowerWidth = gradientStyle and width or availablePowerWidth + local powerWidth + if gradientStyle then + -- 渐变风格:能量条始终与血条等宽(全宽) + powerWidth = width + elseif not rawPowerWidth + or math.abs(rawPowerWidth - legacyFullWidth) < 0.5 + or math.abs(rawPowerWidth - availablePowerWidth) < 0.5 then + powerWidth = defaultPowerWidth + else + powerWidth = rawPowerWidth + end + powerWidth = Clamp(math.floor(powerWidth + 0.5), 20, maxPowerWidth) + + local powerOffsetX = Clamp(math.floor((tonumber(db.raidPowerOffsetX) or 0) + 0.5), -120, 120) + local powerOffsetY = Clamp(math.floor((tonumber(db.raidPowerOffsetY) or 0) + 0.5), -80, 80) + local hgap = tonumber(db.raidHorizontalGap) or UNIT_PADDING local vgap = tonumber(db.raidVerticalGap) or UNIT_PADDING local groupGap = tonumber(db.raidGroupGap) or GROUP_PADDING local nameFont = tonumber(db.raidNameFontSize) or 10 local valueFont = tonumber(db.raidValueFontSize) or 9 + local healthFont = tonumber(db.raidHealthFontSize) or valueFont + local powerFont = tonumber(db.raidPowerFontSize) or valueFont return { width = width, height = height, healthHeight = healthHeight, powerHeight = powerHeight, + powerWidth = powerWidth, + powerOffsetX = powerOffsetX, + powerOffsetY = powerOffsetY, + powerOnTop = db.raidPowerOnTop == true, horizontalGap = hgap, verticalGap = vgap, groupGap = groupGap, nameFont = nameFont, valueFont = valueFont, + healthFont = healthFont, + powerFont = powerFont, + healthTexture = SFrames:ResolveBarTexture("raidHealthTexture", "barTexture"), + powerTexture = SFrames:ResolveBarTexture("raidPowerTexture", "barTexture"), showPower = db.raidShowPower ~= false, } end @@ -87,8 +190,8 @@ function SFrames.Raid:ApplyFrameStyle(frame, metrics) frame.power:Show() if frame.powerBGFrame then frame.powerBGFrame:Show() end frame.power:ClearAllPoints() - frame.power:SetPoint("TOPLEFT", frame.health, "BOTTOMLEFT", 0, -1) - frame.power:SetPoint("TOPRIGHT", frame.health, "BOTTOMRIGHT", 0, 0) + frame.power:SetPoint("TOPLEFT", frame.health, "BOTTOMLEFT", metrics.powerOffsetX, -1 + metrics.powerOffsetY) + frame.power:SetWidth(metrics.powerWidth) frame.power:SetHeight(metrics.powerHeight) else frame.power:Hide() @@ -96,14 +199,80 @@ function SFrames.Raid:ApplyFrameStyle(frame, metrics) end end - local outline = (SFrames and SFrames.Media and SFrames.Media.fontOutline) or "OUTLINE" - local fontPath = SFrames:GetFont() + SFrames:ApplyStatusBarTexture(frame.health, "raidHealthTexture", "barTexture") + SFrames:ApplyStatusBarTexture(frame.power, "raidPowerTexture", "barTexture") + if frame.health and frame.power then + local healthLevel = frame:GetFrameLevel() + 2 + local powerLevel = metrics.powerOnTop and (healthLevel + 1) or (healthLevel - 1) + frame.health:SetFrameLevel(healthLevel) + frame.power:SetFrameLevel(powerLevel) + end + SFrames:ApplyConfiguredUnitBackdrop(frame, "raid") + if frame.healthBGFrame then SFrames:ApplyConfiguredUnitBackdrop(frame.healthBGFrame, "raid") end + if frame.powerBGFrame then SFrames:ApplyConfiguredUnitBackdrop(frame.powerBGFrame, "raid") end + SetTextureIfPresent(frame.health and frame.health.bg, metrics.healthTexture) + SetTextureIfPresent(frame.health and frame.health.healPredMine, metrics.healthTexture) + SetTextureIfPresent(frame.health and frame.health.healPredOther, metrics.healthTexture) + SetTextureIfPresent(frame.health and frame.health.healPredOver, metrics.healthTexture) + SetTextureIfPresent(frame.power and frame.power.bg, metrics.powerTexture) + + -- Gradient style preset + if SFrames:IsGradientStyle() then + -- Strip backdrops + SFrames:ClearBackdrop(frame) + SFrames:ClearBackdrop(frame.healthBGFrame) + SFrames:ClearBackdrop(frame.powerBGFrame) + -- Health bar full width + if frame.health then + frame.health:ClearAllPoints() + frame.health:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + frame.health:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) + frame.health:SetHeight(metrics.healthHeight) + end + -- Power bar full width + if frame.power and metrics.showPower then + frame.power:ClearAllPoints() + frame.power:SetPoint("TOPLEFT", frame.health, "BOTTOMLEFT", metrics.powerOffsetX, -1 + metrics.powerOffsetY) + frame.power:SetWidth(metrics.powerWidth) + frame.power:SetHeight(metrics.powerHeight) + end + -- Apply gradient overlays + SFrames:ApplyGradientStyle(frame.health) + SFrames:ApplyGradientStyle(frame.power) + -- Flush BG frames + if frame.healthBGFrame then + frame.healthBGFrame:ClearAllPoints() + frame.healthBGFrame:SetPoint("TOPLEFT", frame.health, "TOPLEFT", 0, 0) + frame.healthBGFrame:SetPoint("BOTTOMRIGHT", frame.health, "BOTTOMRIGHT", 0, 0) + end + if frame.powerBGFrame then + frame.powerBGFrame:ClearAllPoints() + frame.powerBGFrame:SetPoint("TOPLEFT", frame.power, "TOPLEFT", 0, 0) + frame.powerBGFrame:SetPoint("BOTTOMRIGHT", frame.power, "BOTTOMRIGHT", 0, 0) + end + -- Hide bar backgrounds (transparent) + if frame.healthBGFrame then frame.healthBGFrame:Hide() end + if frame.powerBGFrame then frame.powerBGFrame:Hide() end + if frame.health and frame.health.bg then frame.health.bg:Hide() end + if frame.power and frame.power.bg then frame.power.bg:Hide() end + else + SFrames:RemoveGradientStyle(frame.health) + SFrames:RemoveGradientStyle(frame.power) + -- Restore bar backgrounds + if frame.healthBGFrame then frame.healthBGFrame:Show() end + if frame.powerBGFrame then frame.powerBGFrame:Show() end + if frame.health and frame.health.bg then frame.health.bg:Show() end + if frame.power and frame.power.bg then frame.power.bg:Show() end + end if frame.nameText then - frame.nameText:SetFont(fontPath, metrics.nameFont, outline) + SFrames:ApplyFontString(frame.nameText, metrics.nameFont, "raidNameFontKey", "fontKey") end if frame.healthText then - frame.healthText:SetFont(fontPath, metrics.valueFont, outline) + SFrames:ApplyFontString(frame.healthText, metrics.healthFont, "raidHealthFontKey", "fontKey") + end + if frame.powerText then + SFrames:ApplyFontString(frame.powerText, metrics.powerFont, "raidPowerFontKey", "fontKey") end end @@ -724,6 +893,9 @@ function SFrames.Raid:UpdateFrame(unit) f.nameText:SetTextColor(1, 1, 1) end end + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(f.health) + end self:UpdateHealth(unit) self:UpdatePower(unit) @@ -794,11 +966,10 @@ function SFrames.Raid:UpdateHealth(unit) txt = percent .. "%" elseif db.raidHealthFormat == "deficit" then if maxHp - hp > 0 then - txt = "-" .. (maxHp - hp) + txt = "-" .. SFrames:FormatCompactNumber(maxHp - hp) end else - txt = (math.floor(hp/100)/10).."k" -- default compact e.g. 4.5k - if hp < 1000 then txt = tostring(hp) end + txt = SFrames:FormatCompactNumber(hp) end f.healthText:SetText(txt) @@ -826,14 +997,8 @@ function SFrames.Raid:UpdateHealPrediction(unit) 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 - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -855,7 +1020,7 @@ function SFrames.Raid:UpdateHealPrediction(unit) end if maxHp <= 0 then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -872,7 +1037,7 @@ function SFrames.Raid:UpdateHealPrediction(unit) end local missing = maxHp - hp if missing <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -880,13 +1045,13 @@ function SFrames.Raid:UpdateHealPrediction(unit) local remaining = missing - mineShown local otherShown = math.min(math.max(0, othersIncoming), remaining) if mineIncoming <= 0 and othersIncoming <= 0 then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end local barWidth = f:GetWidth() - 2 if barWidth <= 0 then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -896,7 +1061,7 @@ function SFrames.Raid:UpdateHealPrediction(unit) local availableWidth = barWidth - currentPosition if availableWidth <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -972,6 +1137,10 @@ function SFrames.Raid:UpdatePower(unit) local pType = UnitPowerType(unit) local color = SFrames.Config.colors.power[pType] or SFrames.Config.colors.power[0] f.power:SetStatusBarColor(color.r, color.g, color.b) + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(f.power) + end + SFrames:UpdateRainbowBar(f.power, power, maxPower, unit) break end end @@ -1072,7 +1241,11 @@ function SFrames.Raid:UpdateAuras(unit) local f = frameData.frame local buffsNeeded = self:GetClassBuffs() - local foundIndicators = { [1] = false, [2] = false, [3] = false, [4] = false } + -- Reuse pre-allocated table + _foundIndicators[1] = false + _foundIndicators[2] = false + _foundIndicators[3] = false + _foundIndicators[4] = false -- Hide all first for i = 1, 4 do @@ -1085,70 +1258,22 @@ function SFrames.Raid:UpdateAuras(unit) return end - local function MatchesList(auraName, list) - for _, name in ipairs(list) do - if string.find(auraName, name) then - return true - end - end - 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 + -- Check Buffs (using module-level helpers, no closures) for i = 1, 32 do local texture, applications = UnitBuff(unit, i) if not texture then break end - local buffName = GetBuffName(unit, i) + local buffName = RaidGetBuffName(unit, i) if buffName then for pos, listData in pairs(buffsNeeded) do - if pos <= 4 and not listData.isDebuff and not foundIndicators[pos] then + if pos <= 4 and not listData.isDebuff and not _foundIndicators[pos] then if MatchesList(buffName, listData) then f.indicators[pos].icon:SetTexture(texture) f.indicators[pos].index = i f.indicators[pos].isDebuff = false f.indicators[pos]:Show() - foundIndicators[pos] = true + _foundIndicators[pos] = true end end end @@ -1156,7 +1281,10 @@ function SFrames.Raid:UpdateAuras(unit) end local hasDebuff = false - local debuffColor = {r=_A.slotBg[1], g=_A.slotBg[2], b=_A.slotBg[3]} + -- Reuse pre-allocated table + _debuffColor.r = _A.slotBg[1] + _debuffColor.g = _A.slotBg[2] + _debuffColor.b = _A.slotBg[3] -- Check Debuffs for i = 1, 16 do @@ -1165,24 +1293,24 @@ function SFrames.Raid:UpdateAuras(unit) if dispelType then hasDebuff = true - if dispelType == "Magic" then debuffColor = {r=0.2, g=0.6, b=1} - elseif dispelType == "Curse" then debuffColor = {r=0.6, g=0, b=1} - elseif dispelType == "Disease" then debuffColor = {r=0.6, g=0.4, b=0} - elseif dispelType == "Poison" then debuffColor = {r=0, g=0.6, b=0} + if dispelType == "Magic" then _debuffColor.r = 0.2; _debuffColor.g = 0.6; _debuffColor.b = 1 + elseif dispelType == "Curse" then _debuffColor.r = 0.6; _debuffColor.g = 0; _debuffColor.b = 1 + elseif dispelType == "Disease" then _debuffColor.r = 0.6; _debuffColor.g = 0.4; _debuffColor.b = 0 + elseif dispelType == "Poison" then _debuffColor.r = 0; _debuffColor.g = 0.6; _debuffColor.b = 0 end end - local debuffName = GetDebuffName(unit, i) + local debuffName = RaidGetDebuffName(unit, i) if debuffName then for pos, listData in pairs(buffsNeeded) do - if pos <= 4 and listData.isDebuff and not foundIndicators[pos] then + if pos <= 4 and listData.isDebuff and not _foundIndicators[pos] then if MatchesList(debuffName, listData) then f.indicators[pos].icon:SetTexture(texture) f.indicators[pos].index = i f.indicators[pos].isDebuff = true f.indicators[pos]:Show() - foundIndicators[pos] = true + _foundIndicators[pos] = true end end end @@ -1190,9 +1318,8 @@ function SFrames.Raid:UpdateAuras(unit) end if hasDebuff then - f.health.bg:SetVertexColor(debuffColor.r, debuffColor.g, debuffColor.b, 1) + f.health.bg:SetVertexColor(_debuffColor.r, _debuffColor.g, _debuffColor.b, 1) else f.health.bg:SetVertexColor(_A.slotBg[1], _A.slotBg[2], _A.slotBg[3], _A.slotBg[4] or 1) end end - diff --git a/Units/Target.lua b/Units/Target.lua index a51386e..6c940f7 100644 --- a/Units/Target.lua +++ b/Units/Target.lua @@ -182,6 +182,17 @@ local function Clamp(value, minValue, maxValue) return value end +local function SetTextureIfPresent(region, texturePath) + if region and region.SetTexture and texturePath then + region:SetTexture(texturePath) + end +end + +local function ApplyFontIfPresent(fs, size, fontKey, fallbackFontKey) + if not fs then return end + SFrames:ApplyFontString(fs, size, fontKey, fallbackFontKey) +end + local DIST_BASE_WIDTH = 80 local DIST_BASE_HEIGHT = 24 local DIST_BASE_FONTSIZE = 14 @@ -232,6 +243,33 @@ function SFrames.Target:GetConfig() local powerHeight = tonumber(db.targetPowerHeight) or 9 powerHeight = Clamp(math.floor(powerHeight + 0.5), 6, 40) + local showPortrait = db.targetShowPortrait ~= false + local gradientStyle = SFrames:IsGradientStyle() + local classicDefaultPowerWidth = width - (showPortrait and portraitWidth or 0) - 2 + if classicDefaultPowerWidth < 60 then + classicDefaultPowerWidth = 60 + end + + local rawPowerWidth = tonumber(db.targetPowerWidth) + local legacyFullWidth = tonumber(db.targetFrameWidth) or width + local defaultPowerWidth = gradientStyle and width or classicDefaultPowerWidth + local maxPowerWidth = gradientStyle and width or (width - 2) + local powerWidth + if gradientStyle then + -- 渐变风格:能量条始终与血条等宽(全宽) + powerWidth = width + elseif not rawPowerWidth + or math.abs(rawPowerWidth - legacyFullWidth) < 0.5 + or math.abs(rawPowerWidth - classicDefaultPowerWidth) < 0.5 then + powerWidth = defaultPowerWidth + else + powerWidth = rawPowerWidth + end + powerWidth = Clamp(math.floor(powerWidth + 0.5), 60, maxPowerWidth) + + local powerOffsetX = Clamp(math.floor((tonumber(db.targetPowerOffsetX) or 0) + 0.5), -120, 120) + local powerOffsetY = Clamp(math.floor((tonumber(db.targetPowerOffsetY) or 0) + 0.5), -80, 80) + local height = healthHeight + powerHeight + 4 height = Clamp(height, 30, 140) @@ -241,6 +279,12 @@ function SFrames.Target:GetConfig() local valueFont = tonumber(db.targetValueFontSize) or 10 valueFont = Clamp(math.floor(valueFont + 0.5), 8, 18) + local healthFont = tonumber(db.targetHealthFontSize) or valueFont + healthFont = Clamp(math.floor(healthFont + 0.5), 8, 18) + + local powerFont = tonumber(db.targetPowerFontSize) or valueFont + powerFont = Clamp(math.floor(powerFont + 0.5), 8, 18) + local frameScale = tonumber(db.targetFrameScale) or 1 frameScale = Clamp(frameScale, 0.7, 1.8) @@ -250,8 +294,16 @@ function SFrames.Target:GetConfig() portraitWidth = portraitWidth, healthHeight = healthHeight, powerHeight = powerHeight, + powerWidth = powerWidth, + powerOffsetX = powerOffsetX, + powerOffsetY = powerOffsetY, + powerOnTop = db.targetPowerOnTop == true, nameFont = nameFont, valueFont = valueFont, + healthFont = healthFont, + powerFont = powerFont, + healthTexture = SFrames:ResolveBarTexture("targetHealthTexture", "barTexture"), + powerTexture = SFrames:ResolveBarTexture("targetPowerTexture", "barTexture"), scale = frameScale, } end @@ -334,8 +386,8 @@ function SFrames.Target:ApplyConfig() if f.power then f.power:ClearAllPoints() - f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", 0, -1) - f.power:SetPoint("TOPRIGHT", f.health, "BOTTOMRIGHT", 0, 0) + f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", cfg.powerOffsetX, -1 + cfg.powerOffsetY) + f.power:SetWidth(cfg.powerWidth) f.power:SetHeight(cfg.powerHeight) end @@ -345,19 +397,71 @@ function SFrames.Target:ApplyConfig() f.powerBGFrame:SetPoint("BOTTOMRIGHT", f.power, "BOTTOMRIGHT", 1, -1) end - local outline = (SFrames and SFrames.Media and SFrames.Media.fontOutline) or "OUTLINE" - local fontPath = SFrames:GetFont() + SFrames:ApplyStatusBarTexture(f.health, "targetHealthTexture", "barTexture") + SFrames:ApplyStatusBarTexture(f.power, "targetPowerTexture", "barTexture") + SetTextureIfPresent(f.health and f.health.bg, cfg.healthTexture) + SetTextureIfPresent(f.health and f.health.healPredMine, cfg.healthTexture) + SetTextureIfPresent(f.health and f.health.healPredOther, cfg.healthTexture) + SetTextureIfPresent(f.health and f.health.healPredOver, cfg.healthTexture) + SetTextureIfPresent(f.power and f.power.bg, cfg.powerTexture) - if f.nameText then - f.nameText:SetFont(fontPath, cfg.nameFont, outline) - end - if f.healthText then - f.healthText:SetFont(fontPath, cfg.valueFont, outline) - end - if f.powerText then - f.powerText:SetFont(fontPath, cfg.valueFont, outline) + -- Gradient style preset + if SFrames:IsGradientStyle() then + -- Hide portrait & its backdrop + if f.portrait then f.portrait:Hide() end + if f.portraitBG then f.portraitBG:Hide() end + -- Strip backdrops + SFrames:ClearBackdrop(f) + SFrames:ClearBackdrop(f.healthBGFrame) + SFrames:ClearBackdrop(f.powerBGFrame) + -- Health bar full width + if f.health then + f.health:ClearAllPoints() + f.health:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0) + f.health:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0) + f.health:SetHeight(cfg.healthHeight) + end + -- Power bar full width, below health + if f.power then + f.power:ClearAllPoints() + f.power:SetPoint("TOPLEFT", f.health, "BOTTOMLEFT", cfg.powerOffsetX, -2 + cfg.powerOffsetY) + f.power:SetWidth(cfg.powerWidth) + f.power:SetHeight(cfg.powerHeight) + end + -- Apply gradient overlays + SFrames:ApplyGradientStyle(f.health) + SFrames:ApplyGradientStyle(f.power) + -- Flush BG frames (no border padding) + if f.healthBGFrame then + f.healthBGFrame:ClearAllPoints() + f.healthBGFrame:SetPoint("TOPLEFT", f.health, "TOPLEFT", 0, 0) + f.healthBGFrame:SetPoint("BOTTOMRIGHT", f.health, "BOTTOMRIGHT", 0, 0) + end + if f.powerBGFrame then + f.powerBGFrame:ClearAllPoints() + f.powerBGFrame:SetPoint("TOPLEFT", f.power, "TOPLEFT", 0, 0) + f.powerBGFrame:SetPoint("BOTTOMRIGHT", f.power, "BOTTOMRIGHT", 0, 0) + end + -- Hide bar backgrounds (transparent) + if f.healthBGFrame then f.healthBGFrame:Hide() end + if f.powerBGFrame then f.powerBGFrame:Hide() end + if f.health and f.health.bg then f.health.bg:Hide() end + if f.power and f.power.bg then f.power.bg:Hide() end + else + -- Classic style: remove gradient overlays if they exist + SFrames:RemoveGradientStyle(f.health) + SFrames:RemoveGradientStyle(f.power) + -- Restore bar backgrounds + if f.healthBGFrame then f.healthBGFrame:Show() end + if f.powerBGFrame then f.powerBGFrame:Show() end + if f.health and f.health.bg then f.health.bg:Show() end + if f.power and f.power.bg then f.power.bg:Show() end end + ApplyFontIfPresent(f.nameText, cfg.nameFont, "targetNameFontKey") + ApplyFontIfPresent(f.healthText, cfg.healthFont, "targetHealthFontKey") + ApplyFontIfPresent(f.powerText, cfg.powerFont, "targetPowerFontKey") + if f.castbar then f.castbar:ClearAllPoints() if showPortrait then @@ -374,6 +478,24 @@ function SFrames.Target:ApplyConfig() self:ApplyDistanceScale(dScale) end + if f.distText then + local dfs = (db.targetDistanceFontSize and tonumber(db.targetDistanceFontSize)) or 10 + dfs = Clamp(dfs, 8, 24) + SFrames:ApplyFontString(f.distText, dfs, "targetDistanceFontKey", "fontKey") + end + + if f.health and f.power then + local healthLevel = f:GetFrameLevel() + 2 + local powerLevel = cfg.powerOnTop and (healthLevel + 1) or (healthLevel - 1) + f.health:SetFrameLevel(healthLevel) + f.power:SetFrameLevel(powerLevel) + end + + SFrames:ApplyConfiguredUnitBackdrop(f, "target") + if f.healthBGFrame then SFrames:ApplyConfiguredUnitBackdrop(f.healthBGFrame, "target") end + if f.powerBGFrame then SFrames:ApplyConfiguredUnitBackdrop(f.powerBGFrame, "target") end + if f.portraitBG then SFrames:ApplyConfiguredUnitBackdrop(f.portraitBG, "target", true) end + if UnitExists("target") then self:UpdateAll() end @@ -386,8 +508,6 @@ function SFrames.Target:ApplyDistanceScale(scale) f:SetWidth(DIST_BASE_WIDTH * scale) f:SetHeight(DIST_BASE_HEIGHT * scale) if f.text then - local fontPath = SFrames:GetFont() - local outline = (SFrames.Media and SFrames.Media.fontOutline) or "OUTLINE" local customSize = SFramesDB and tonumber(SFramesDB.targetDistanceFontSize) local fontSize if customSize and customSize >= 8 and customSize <= 24 then @@ -395,7 +515,7 @@ function SFrames.Target:ApplyDistanceScale(scale) else fontSize = math.max(8, math.floor(DIST_BASE_FONTSIZE * scale + 0.5)) end - f.text:SetFont(fontPath, fontSize, outline) + SFrames:ApplyFontString(f.text, fontSize, "targetDistanceFontKey", "fontKey") end end @@ -437,54 +557,46 @@ 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() - f.timer = 0 - f:SetScript("OnUpdate", function() - if SFramesDB and SFramesDB.targetDistanceEnabled == false then - if this:IsShown() then this:Hide() end + local ticker = CreateFrame("Frame", nil, UIParent) + ticker:SetWidth(1) + ticker:SetHeight(1) + ticker.timer = 0 + ticker:Show() + ticker:SetScript("OnUpdate", function() + local distFrame = SFrames.Target.distanceFrame + if not distFrame then return end + local disabled = SFramesDB and SFramesDB.targetDistanceEnabled == false + local onFrame = not SFramesDB or SFramesDB.targetDistanceOnFrame ~= false + local tgtFrame = SFrames.Target and SFrames.Target.frame + local embeddedText = tgtFrame and tgtFrame.distText + + if disabled then + if distFrame:IsShown() then distFrame:Hide() end + if embeddedText and embeddedText:IsShown() then embeddedText:Hide() end return end if not UnitExists("target") then - if this:IsShown() then this:Hide() end - if this.behindText then this.behindText:Hide() end + if distFrame:IsShown() then distFrame:Hide() end + if embeddedText and embeddedText:IsShown() then embeddedText:Hide() end return end this.timer = this.timer + (arg1 or 0) if this.timer >= 0.4 then this.timer = 0 local dist = SFrames.Target:GetDistance("target") - this.text:SetText(dist or "---") - if not this:IsShown() then this:Show() end + local distStr = dist or "---" - -- 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 + if onFrame and embeddedText then + embeddedText:SetText(distStr) + if not embeddedText:IsShown() then embeddedText:Show() end + if distFrame:IsShown() then distFrame:Hide() end + else + distFrame.text:SetText(distStr) + if not distFrame:IsShown() then distFrame:Show() end + if embeddedText and embeddedText:IsShown() then embeddedText:Hide() end end end end) @@ -515,15 +627,23 @@ function SFrames.Target:Initialize() local f = CreateFrame("Button", "SFramesTargetFrame", UIParent) f:SetWidth(SFrames.Config.width) f:SetHeight(SFrames.Config.height) - if SFramesDB and SFramesDB.Positions and SFramesDB.Positions["TargetFrame"] then - local pos = SFramesDB.Positions["TargetFrame"] - f:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs, pos.yOfs) - else - f:SetPoint("CENTER", UIParent, "CENTER", 200, -100) -- Mirrored from player - end + local frameScale = (SFramesDB and type(SFramesDB.targetFrameScale) == "number") and SFramesDB.targetFrameScale or 1 f:SetScale(frameScale) - + + if SFramesDB and SFramesDB.Positions and SFramesDB.Positions["TargetFrame"] then + local pos = SFramesDB.Positions["TargetFrame"] + local fScale = f:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fScale > 0.01 and math.abs(fScale - 1) > 0.001 then + f:SetPoint(pos.point, UIParent, pos.relativePoint, + (pos.xOfs or 0) / fScale, (pos.yOfs or 0) / fScale) + else + f:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + end + else + f:SetPoint("CENTER", UIParent, "CENTER", 200, -100) + end + f:SetMovable(true) f:EnableMouse(true) f:RegisterForDrag("LeftButton") @@ -533,25 +653,21 @@ function SFrames.Target:Initialize() if not SFramesDB then SFramesDB = {} end if not SFramesDB.Positions then SFramesDB.Positions = {} end local point, relativeTo, relativePoint, xOfs, yOfs = f:GetPoint() + local fSc = f:GetEffectiveScale() / UIParent:GetEffectiveScale() + if fSc > 0.01 and math.abs(fSc - 1) > 0.001 then + xOfs = (xOfs or 0) * fSc + yOfs = (yOfs or 0) * fSc + end SFramesDB.Positions["TargetFrame"] = { point = point, relativePoint = relativePoint, xOfs = xOfs, yOfs = yOfs } end) 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") + pcall(SFrames.Focus.SetFromTarget, SFrames.Focus) end return end @@ -562,31 +678,25 @@ function SFrames.Target:Initialize() SpellTargetUnit(this.unit) end elseif arg1 == "RightButton" then - DEFAULT_CHAT_FRAME:AddMessage("|cff00ff00[Nanami] RightButton hit|r") if SpellIsTargeting and SpellIsTargeting() then SpellStopTargeting() return end 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 @@ -678,16 +788,9 @@ function SFrames.Target:Initialize() -- 取消按钮不添加,点击菜单外部即可关闭(节省按钮位) 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 + pcall(ToggleDropDownMenu, 1, nil, SFrames.Target.dropDown, "cursor") end end) f:SetScript("OnReceiveDrag", function() @@ -799,6 +902,19 @@ function SFrames.Target:Initialize() f.comboText:SetTextColor(1, 0.8, 0) f.comboText:SetText("") + -- Embedded distance text (high-level overlay so it's never covered) + local distOverlay = CreateFrame("Frame", nil, f) + distOverlay:SetFrameLevel((f:GetFrameLevel() or 0) + 20) + distOverlay:SetAllPoints(f.health) + local distFS = (SFramesDB and tonumber(SFramesDB.targetDistanceFontSize)) or 10 + f.distText = SFrames:CreateFontString(distOverlay, distFS, "CENTER") + f.distText:SetPoint("CENTER", f.health, "TOP", 0, 0) + f.distText:SetTextColor(1, 0.82, 0.25) + f.distText:SetShadowColor(0, 0, 0, 1) + f.distText:SetShadowOffset(1, -1) + f.distText:SetText("") + f.distText:Hide() + -- Raid Target Icon (top center of health bar, half outside frame) local raidIconSize = 22 local raidIconOvr = CreateFrame("Frame", nil, f) @@ -864,7 +980,8 @@ function SFrames.Target:Initialize() -- Register movers if SFrames.Movers and SFrames.Movers.RegisterMover then SFrames.Movers:RegisterMover("TargetFrame", f, "目标", - "CENTER", "UIParent", "CENTER", 200, -100) + "CENTER", "UIParent", "CENTER", 200, -100, + nil, { alwaysShowInLayout = true }) if SFrames.Target.distanceFrame then SFrames.Movers:RegisterMover("TargetDistanceFrame", SFrames.Target.distanceFrame, "目标距离", "CENTER", "UIParent", "CENTER", 0, 100) @@ -1047,18 +1164,24 @@ function SFrames.Target:OnTargetChanged() if UnitExists("target") then self.frame:Show() self:UpdateAll() + local enabled = not (SFramesDB and SFramesDB.targetDistanceEnabled == false) + local onFrame = not SFramesDB or SFramesDB.targetDistanceOnFrame ~= false if SFrames.Target.distanceFrame then local dist = self:GetDistance("target") - SFrames.Target.distanceFrame.text:SetText(dist or "---") - if not (SFramesDB and SFramesDB.targetDistanceEnabled == false) then - SFrames.Target.distanceFrame:Show() - else + if onFrame and self.frame.distText then + self.frame.distText:SetText(dist or "---") + if enabled then self.frame.distText:Show() else self.frame.distText:Hide() end SFrames.Target.distanceFrame:Hide() + else + SFrames.Target.distanceFrame.text:SetText(dist or "---") + if enabled then SFrames.Target.distanceFrame:Show() else SFrames.Target.distanceFrame:Hide() end + if self.frame.distText then self.frame.distText:Hide() end end end else self.frame:Hide() if SFrames.Target.distanceFrame then SFrames.Target.distanceFrame:Hide() end + if self.frame and self.frame.distText then self.frame.distText:Hide() end end end @@ -1153,6 +1276,8 @@ function SFrames.Target:UpdateAll() end local useClassColor = not (SFramesDB and SFramesDB.classColorHealth == false) + -- Gradient style always uses class colors + if SFrames:IsGradientStyle() then useClassColor = true end if UnitIsPlayer("target") and useClassColor then local _, class = UnitClass("target") @@ -1184,6 +1309,10 @@ function SFrames.Target:UpdateAll() self.frame.nameText:SetText(formattedLevel .. name) self.frame.nameText:SetTextColor(r, g, b) end + -- Re-apply gradient after color change + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(self.frame.health) + end end function SFrames.Target:UpdateHealth() @@ -1208,9 +1337,9 @@ function SFrames.Target:UpdateHealth() end if displayMax > 0 then - self.frame.healthText:SetText(displayHp .. " / " .. displayMax) + self.frame.healthText:SetText(SFrames:FormatCompactPair(displayHp, displayMax)) else - self.frame.healthText:SetText(displayHp) + self.frame.healthText:SetText(SFrames:FormatCompactNumber(displayHp)) end self:UpdateHealPrediction() @@ -1222,14 +1351,8 @@ function SFrames.Target:UpdateHealPrediction() 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 - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -1251,7 +1374,7 @@ function SFrames.Target:UpdateHealPrediction() end if maxHp <= 0 then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -1268,7 +1391,7 @@ function SFrames.Target:UpdateHealPrediction() end local missing = maxHp - hp if missing <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -1276,14 +1399,14 @@ function SFrames.Target:UpdateHealPrediction() 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() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end 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() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -1293,7 +1416,7 @@ function SFrames.Target:UpdateHealPrediction() local availableWidth = barWidth - currentWidth if availableWidth <= 0 and (mineIncoming <= 0 and othersIncoming <= 0) then - HidePredictions() + predMine:Hide(); predOther:Hide(); predOver:Hide() return end @@ -1359,6 +1482,9 @@ function SFrames.Target:UpdatePowerType() else self.frame.power:SetStatusBarColor(0, 0, 1) end + if SFrames:IsGradientStyle() then + SFrames:ApplyBarGradient(self.frame.power) + end end function SFrames.Target:UpdatePower() @@ -1367,10 +1493,11 @@ function SFrames.Target:UpdatePower() self.frame.power:SetMinMaxValues(0, maxPower) self.frame.power:SetValue(power) if maxPower > 0 then - self.frame.powerText:SetText(power .. " / " .. maxPower) + self.frame.powerText:SetText(SFrames:FormatCompactPair(power, maxPower)) else self.frame.powerText:SetText("") end + SFrames:UpdateRainbowBar(self.frame.power, power, maxPower, "target") end function SFrames.Target:UpdateComboPoints() @@ -1501,23 +1628,15 @@ end function SFrames.Target:TickAuras() if not UnitExists("target") then return end - local timeNow = GetTime() + local tracker = SFrames.AuraTracker local npFormat = NanamiPlates_Auras and NanamiPlates_Auras.FormatTime - local hasNP = NanamiPlates_SpellDB and NanamiPlates_SpellDB.FindEffectData - - local targetName, targetLevel, targetGUID - if hasNP then - targetName = UnitName("target") - targetLevel = UnitLevel("target") or 0 - targetGUID = UnitGUID and UnitGUID("target") - end -- Buffs for i = 1, 32 do local b = self.frame.buffs[i] - if b:IsShown() and b.expirationTime then - local timeLeft = b.expirationTime - timeNow - if timeLeft > 0 and timeLeft < 3600 then + if b:IsShown() then + local timeLeft = tracker and tracker:GetAuraTimeLeft("target", "buff", i) + if timeLeft and timeLeft > 0 and timeLeft < 3600 then if npFormat then local text, r, g, bc, a = npFormat(timeLeft) b.cdText:SetText(text) @@ -1531,30 +1650,11 @@ function SFrames.Target:TickAuras() end end - -- Debuffs: re-query SpellDB for live-accurate timers + -- Debuffs for i = 1, 32 do local b = self.frame.debuffs[i] if b:IsShown() then - local timeLeft = nil - - if hasNP and b.effectName then - local data = targetGUID and NanamiPlates_SpellDB:FindEffectData(targetGUID, targetLevel, b.effectName) - if not data and targetName then - data = NanamiPlates_SpellDB:FindEffectData(targetName, targetLevel, b.effectName) - end - if data and data.start and data.duration then - local remaining = data.duration + data.start - timeNow - if remaining > 0 then - timeLeft = remaining - b.expirationTime = timeNow + remaining - end - end - end - - if not timeLeft and b.expirationTime then - timeLeft = b.expirationTime - timeNow - end - + local timeLeft = tracker and tracker:GetAuraTimeLeft("target", "debuff", i) if timeLeft and timeLeft > 0 and timeLeft < 3600 then if npFormat then local text, r, g, bc, a = npFormat(timeLeft) @@ -1573,6 +1673,11 @@ end function SFrames.Target:UpdateAuras() if not UnitExists("target") then return end + local tracker = SFrames.AuraTracker + if tracker and tracker.HandleAuraSnapshot then + tracker:HandleAuraSnapshot("target") + end + local hasSuperWoW = SFrames.superwow_active and SpellInfo local numBuffs = 0 -- Buffs @@ -1584,12 +1689,9 @@ function SFrames.Target:UpdateAuras() 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() - SFrames.Tooltip:SetUnitBuff("target", i) - local timeLeft = SFrames:GetAuraTimeLeft("target", i, true) - SFrames.Tooltip:Hide() + + local state = tracker and tracker:GetAuraState("target", "buff", i) + local timeLeft = state and tracker:GetAuraTimeLeft("target", "buff", i) if timeLeft and timeLeft > 0 then b.expirationTime = GetTime() + timeLeft b.cdText:SetText(SFrames:FormatTime(timeLeft)) @@ -1621,7 +1723,6 @@ function SFrames.Target:UpdateAuras() end -- Debuffs - local hasNP = NanamiPlates_SpellDB and NanamiPlates_SpellDB.UnitDebuff local npFormat = NanamiPlates_Auras and NanamiPlates_Auras.FormatTime for i = 1, 32 do @@ -1633,39 +1734,12 @@ function SFrames.Target:UpdateAuras() -- Store aura ID when SuperWoW is available b.auraID = hasSuperWoW and swDebuffAuraID or nil - local timeLeft = 0 - local effectName = nil - - if hasNP then - local effect, rank, _, stacks, dtype, duration, npTimeLeft, isOwn = NanamiPlates_SpellDB:UnitDebuff("target", i) - effectName = effect - if npTimeLeft and npTimeLeft > 0 then - timeLeft = npTimeLeft - elseif effect and effect ~= "" and duration and duration > 0 - and NanamiPlates_Auras and NanamiPlates_Auras.timers then - local unitKey = (UnitGUID and UnitGUID("target")) or UnitName("target") or "" - local cached = NanamiPlates_Auras.timers[unitKey .. "_" .. effect] - if not cached and UnitName("target") then - cached = NanamiPlates_Auras.timers[UnitName("target") .. "_" .. effect] - end - if cached and cached.startTime and cached.duration then - local remaining = cached.duration - (GetTime() - cached.startTime) - if remaining > 0 then timeLeft = remaining end - end - end - end - - if timeLeft <= 0 then - SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE") - SFrames.Tooltip:ClearLines() - SFrames.Tooltip:SetUnitDebuff("target", i) - timeLeft = SFrames:GetAuraTimeLeft("target", i, false) - SFrames.Tooltip:Hide() - end + local state = tracker and tracker:GetAuraState("target", "debuff", i) + local timeLeft = state and tracker:GetAuraTimeLeft("target", "debuff", i) if timeLeft and timeLeft > 0 then b.expirationTime = GetTime() + timeLeft - b.effectName = effectName + b.effectName = state and state.name or nil if npFormat then local text, r, g, bc, a = npFormat(timeLeft) b.cdText:SetText(text) diff --git a/Units/ToT.lua b/Units/ToT.lua index 0519d0d..92f7027 100644 --- a/Units/ToT.lua +++ b/Units/ToT.lua @@ -1,12 +1,36 @@ SFrames.ToT = {} local _A = SFrames.ActiveTheme +function SFrames.ToT:ApplyConfig() + local f = self.frame + if not f then return end + SFrames:ApplyStatusBarTexture(f.health, "totHealthTexture", "barTexture") + local tex = SFrames:ResolveBarTexture("totHealthTexture", "barTexture") + if f.health and f.health.bg then f.health.bg:SetTexture(tex) end + if SFrames:IsGradientStyle() then + SFrames:ApplyGradientStyle(f.health) + if f.hbg then f.hbg:Hide() end + if f.health and f.health.bg then f.health.bg:Hide() end + else + SFrames:RemoveGradientStyle(f.health) + if f.hbg then f.hbg:Show() end + if f.health and f.health.bg then f.health.bg:Show() end + end +end + function SFrames.ToT:Initialize() local f = CreateFrame("Button", "SFramesToTFrame", UIParent) f:SetWidth(120) f:SetHeight(25) - f:SetPoint("BOTTOMLEFT", SFramesTargetFrame, "BOTTOMRIGHT", 5, 0) - + + if SFramesDB and SFramesDB.Positions and SFramesDB.Positions["ToTFrame"] then + local pos = SFramesDB.Positions["ToTFrame"] + f:SetPoint(pos.point or "BOTTOMLEFT", UIParent, pos.relativePoint or "BOTTOMLEFT", + pos.xOfs or 0, pos.yOfs or 0) + else + f:SetPoint("BOTTOMLEFT", SFramesTargetFrame, "BOTTOMRIGHT", 5, 0) + end + f:RegisterForClicks("LeftButtonUp", "RightButtonUp") f:SetScript("OnClick", function() if arg1 == "LeftButton" then @@ -24,6 +48,7 @@ function SFrames.ToT:Initialize() hbg:SetPoint("BOTTOMRIGHT", f.health, "BOTTOMRIGHT", 1, -1) hbg:SetFrameLevel(f:GetFrameLevel() - 1) SFrames:CreateUnitBackdrop(hbg) + f.hbg = hbg f.health.bg = f.health:CreateTexture(nil, "BACKGROUND") f.health.bg:SetAllPoints() @@ -35,7 +60,15 @@ function SFrames.ToT:Initialize() self.frame = f f:Hide() - + + self:ApplyConfig() + + if SFrames.Movers and SFrames.Movers.RegisterMover then + SFrames.Movers:RegisterMover("ToTFrame", f, "目标的目标", + "BOTTOMLEFT", "SFramesTargetFrame", "BOTTOMRIGHT", 5, 0, + nil, { alwaysShowInLayout = true }) + end + -- Update loop since targettarget changes don't fire precise events in Vanilla self.updater = CreateFrame("Frame") self.updater.timer = 0 diff --git a/WorldMap.lua b/WorldMap.lua index 24238e0..1cd4471 100644 --- a/WorldMap.lua +++ b/WorldMap.lua @@ -52,6 +52,26 @@ local function HookScript(frame, script, fn) end) end +local function SafeSetMapToCurrentZone() + if not SetMapToCurrentZone then + return + end + if SFrames and SFrames.CallWithPreservedBattlefieldMinimap then + return SFrames:CallWithPreservedBattlefieldMinimap(SetMapToCurrentZone) + end + return pcall(SetMapToCurrentZone) +end + +local function SafeSetMapZoom(continent, zone) + if not SetMapZoom then + return + end + if SFrames and SFrames.CallWithPreservedBattlefieldMinimap then + return SFrames:CallWithPreservedBattlefieldMinimap(SetMapZoom, continent, zone) + end + return pcall(SetMapZoom, continent, zone) +end + -------------------------------------------------------------------------------- -- 1. Hide Blizzard Decorations -- Called at init AND on every WorldMapFrame:OnShow to counter Blizzard resets @@ -951,12 +971,18 @@ local function CreateWaypointPin() pinLabel:SetText("") local btnW, btnH = 52, 18 + local PIN_BTN_BACKDROP = { + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + } pinShareBtn = CreateFrame("Button", nil, pinFrame) pinShareBtn:SetWidth(btnW) pinShareBtn:SetHeight(btnH) - pinShareBtn:SetPoint("TOPLEFT", pinFrame, "BOTTOMLEFT", -10, -8) - pinShareBtn:SetBackdrop(PANEL_BACKDROP) + pinShareBtn:SetPoint("TOP", pinLabel, "BOTTOM", -(btnW / 2 + 2), -4) + pinShareBtn:SetBackdrop(PIN_BTN_BACKDROP) pinShareBtn:SetBackdropColor(_A.btnBg[1], _A.btnBg[2], _A.btnBg[3], _A.btnBg[4]) pinShareBtn:SetBackdropBorderColor(_A.btnBorder[1], _A.btnBorder[2], _A.btnBorder[3], _A.btnBorder[4]) local shareFS = pinShareBtn:CreateFontString(nil, "OVERLAY") @@ -966,9 +992,11 @@ local function CreateWaypointPin() shareFS:SetTextColor(_A.btnText[1], _A.btnText[2], _A.btnText[3]) pinShareBtn:SetScript("OnEnter", function() this:SetBackdropColor(_A.btnHoverBg[1], _A.btnHoverBg[2], _A.btnHoverBg[3], _A.btnHoverBg[4]) + this:SetBackdropBorderColor(_A.btnHoverBd[1], _A.btnHoverBd[2], _A.btnHoverBd[3], _A.btnHoverBd[4]) end) pinShareBtn:SetScript("OnLeave", function() this:SetBackdropColor(_A.btnBg[1], _A.btnBg[2], _A.btnBg[3], _A.btnBg[4]) + this:SetBackdropBorderColor(_A.btnBorder[1], _A.btnBorder[2], _A.btnBorder[3], _A.btnBorder[4]) end) pinShareBtn:SetScript("OnClick", function() WM:ShareWaypoint() @@ -978,7 +1006,7 @@ local function CreateWaypointPin() pinClearBtn:SetWidth(btnW) pinClearBtn:SetHeight(btnH) pinClearBtn:SetPoint("LEFT", pinShareBtn, "RIGHT", 4, 0) - pinClearBtn:SetBackdrop(PANEL_BACKDROP) + pinClearBtn:SetBackdrop(PIN_BTN_BACKDROP) pinClearBtn:SetBackdropColor(_A.buttonDownBg[1], _A.buttonDownBg[2], _A.buttonDownBg[3], _A.buttonDownBg[4]) pinClearBtn:SetBackdropBorderColor(_A.btnBorder[1], _A.btnBorder[2], _A.btnBorder[3], _A.btnBorder[4]) local clearFS = pinClearBtn:CreateFontString(nil, "OVERLAY") @@ -988,9 +1016,11 @@ local function CreateWaypointPin() clearFS:SetTextColor(_A.dimText[1], _A.dimText[2], _A.dimText[3]) pinClearBtn:SetScript("OnEnter", function() this:SetBackdropColor(_A.btnHoverBg[1], _A.btnHoverBg[2], _A.btnHoverBg[3], _A.btnHoverBg[4]) + this:SetBackdropBorderColor(_A.btnHoverBd[1], _A.btnHoverBd[2], _A.btnHoverBd[3], _A.btnHoverBd[4]) end) pinClearBtn:SetScript("OnLeave", function() this:SetBackdropColor(_A.buttonDownBg[1], _A.buttonDownBg[2], _A.buttonDownBg[3], _A.buttonDownBg[4]) + this:SetBackdropBorderColor(_A.btnBorder[1], _A.btnBorder[2], _A.btnBorder[3], _A.btnBorder[4]) end) pinClearBtn:SetScript("OnClick", function() WM:ClearWaypoint() @@ -1099,7 +1129,7 @@ function WM:HandleWaypointLink(data) if waited >= 0.05 then timer:SetScript("OnUpdate", nil) if SetMapZoom then - SetMapZoom(pending.continent, pending.zone) + SafeSetMapZoom(pending.continent, pending.zone) end WM:SetWaypoint(pending.continent, pending.zone, pending.x, pending.y, pending.name) end @@ -1138,7 +1168,7 @@ local function DiscoverDmfZoneIndices() local target = string.lower(loc.zone) local zones = { GetMapZones(loc.cont) } for idx = 1, table.getn(zones) do - SetMapZoom(loc.cont, idx) + SafeSetMapZoom(loc.cont, idx) local mf = GetMapInfo and GetMapInfo() or "" if mf ~= "" then if mf == loc.zone then @@ -1158,11 +1188,11 @@ local function DiscoverDmfZoneIndices() end end if savedZ > 0 then - SetMapZoom(savedC, savedZ) + SafeSetMapZoom(savedC, savedZ) elseif savedC > 0 then - SetMapZoom(savedC, 0) + SafeSetMapZoom(savedC, 0) else - if SetMapToCurrentZone then SetMapToCurrentZone() end + SafeSetMapToCurrentZone() end end @@ -1390,7 +1420,7 @@ SlashCmdList["DMFMAP"] = function(msg) cf:AddMessage("|cffffcc66[DMF Scan] " .. cname .. " (cont=" .. c .. "):|r") local zones = { GetMapZones(c) } for idx = 1, table.getn(zones) do - SetMapZoom(c, idx) + SafeSetMapZoom(c, idx) local mf = GetMapInfo and GetMapInfo() or "(nil)" local zname = zones[idx] or "?" if string.find(string.lower(mf), "elwynn") or string.find(string.lower(zname), "elwynn") @@ -1401,9 +1431,9 @@ SlashCmdList["DMFMAP"] = function(msg) end end end - if savedZ > 0 then SetMapZoom(savedC, savedZ) - elseif savedC > 0 then SetMapZoom(savedC, 0) - else if SetMapToCurrentZone then SetMapToCurrentZone() end end + if savedZ > 0 then SafeSetMapZoom(savedC, savedZ) + elseif savedC > 0 then SafeSetMapZoom(savedC, 0) + else SafeSetMapToCurrentZone() end return end local ai, iw, dl, ds = GetDmfSchedule() @@ -1931,7 +1961,7 @@ local function UpdateNavMap() if not N.frame or not N.tiles or not N.frame:IsVisible() then return end if WorldMapFrame and WorldMapFrame:IsVisible() then return end - if SetMapToCurrentZone then SetMapToCurrentZone() end + SafeSetMapToCurrentZone() local mapFile = GetMapInfo and GetMapInfo() or "" if mapFile ~= "" and mapFile ~= N.curMap then diff --git a/agent-tools/generate_consumable_db.py b/agent-tools/generate_consumable_db.py new file mode 100644 index 0000000..10f56c7 --- /dev/null +++ b/agent-tools/generate_consumable_db.py @@ -0,0 +1,426 @@ +from __future__ import annotations + +import argparse +import re +import textwrap +import xml.etree.ElementTree as ET +import zipfile +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path, PurePosixPath + + +MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +DOC_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" +PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" +NS = { + "main": MAIN_NS, +} + +ROLE_META = { + "坦克 (物理坦克)": { + "key": "tank_physical", + "detail": "坦克 · 物理坦克", + "color": (0.40, 0.70, 1.00), + }, + "坦克 (法系坦克)": { + "key": "tank_caster", + "detail": "坦克 · 法系坦克", + "color": (1.00, 0.80, 0.20), + }, + "法系输出": { + "key": "caster_dps", + "detail": "输出 · 法系输出", + "color": (0.65, 0.45, 1.00), + }, + "物理近战": { + "key": "melee_dps", + "detail": "输出 · 物理近战", + "color": (1.00, 0.55, 0.25), + }, + "物理远程": { + "key": "ranged_dps", + "detail": "输出 · 物理远程", + "color": (0.55, 0.88, 0.42), + }, + "治疗": { + "key": "healer", + "detail": "治疗", + "color": (0.42, 1.00, 0.72), + }, +} + +CATEGORY_ORDER = [ + "合剂", + "药剂", + "攻强", + "诅咒之地buff", + "赞达拉", + "武器", + "食物", + "酒", + "药水", +] + +# The exported sheet does not include item IDs. We only preserve a very small +# set of IDs that already existed in the addon and can be matched confidently. +ITEM_ID_OVERRIDES = { + "巨人药剂": 9206, + "猫鼬药剂": 13452, + "精炼智慧合剂": 13511, + "夜鳞鱼汤": 13931, + "烤鱿鱼": 13928, + "炎夏火水": 12820, + "自由行动药水": 5634, +} + +HEADER_MAP = { + "A": "role", + "B": "category", + "C": "name", + "D": "effect", + "E": "duration", +} + + +@dataclass +class Row: + role: str + category: str + name: str + effect: str + duration: str + row_number: int + + +def read_xlsx_rows(path: Path) -> list[Row]: + with zipfile.ZipFile(path) as archive: + shared_strings = load_shared_strings(archive) + workbook = ET.fromstring(archive.read("xl/workbook.xml")) + relationships = ET.fromstring(archive.read("xl/_rels/workbook.xml.rels")) + rel_map = { + rel.attrib["Id"]: rel.attrib["Target"] + for rel in relationships.findall(f"{{{PKG_REL_NS}}}Relationship") + } + + sheet = workbook.find("main:sheets", NS)[0] + target = rel_map[sheet.attrib[f"{{{DOC_REL_NS}}}id"]] + sheet_path = normalize_sheet_path(target) + root = ET.fromstring(archive.read(sheet_path)) + sheet_rows = root.find("main:sheetData", NS).findall("main:row", NS) + + rows: list[Row] = [] + for row in sheet_rows[1:]: + values = {} + for cell in row.findall("main:c", NS): + ref = cell.attrib.get("r", "") + col_match = re.match(r"([A-Z]+)", ref) + if not col_match: + continue + col = col_match.group(1) + values[col] = read_cell_value(cell, shared_strings).strip() + + if not any(values.values()): + continue + + normalized = { + field: normalize_text(values.get(col, "")) + for col, field in HEADER_MAP.items() + } + rows.append( + Row( + role=normalized["role"], + category=normalized["category"], + name=normalized["name"], + effect=normalized["effect"], + duration=normalized["duration"], + row_number=int(row.attrib.get("r", "0")), + ) + ) + + return rows + + +def load_shared_strings(archive: zipfile.ZipFile) -> list[str]: + if "xl/sharedStrings.xml" not in archive.namelist(): + return [] + + root = ET.fromstring(archive.read("xl/sharedStrings.xml")) + values: list[str] = [] + for string_item in root.findall("main:si", NS): + text_parts = [ + text_node.text or "" + for text_node in string_item.iter(f"{{{MAIN_NS}}}t") + ] + values.append("".join(text_parts)) + return values + + +def normalize_sheet_path(target: str) -> str: + if target.startswith("/"): + normalized = PurePosixPath("xl") / PurePosixPath(target).relative_to("/") + else: + normalized = PurePosixPath("xl") / PurePosixPath(target) + return str(normalized).replace("xl/xl/", "xl/") + + +def read_cell_value(cell: ET.Element, shared_strings: list[str]) -> str: + cell_type = cell.attrib.get("t") + value_node = cell.find("main:v", NS) + if cell_type == "s" and value_node is not None: + return shared_strings[int(value_node.text)] + if cell_type == "inlineStr": + inline_node = cell.find("main:is", NS) + if inline_node is None: + return "" + return "".join( + text_node.text or "" + for text_node in inline_node.iter(f"{{{MAIN_NS}}}t") + ) + return value_node.text if value_node is not None else "" + + +def normalize_text(value: str) -> str: + value = (value or "").strip() + value = value.replace("\u3000", " ") + value = re.sub(r"\s+", " ", value) + value = value.replace("(", "(").replace(")", ")") + return value + + +def normalize_rows(rows: list[Row]) -> list[Row]: + normalized: list[Row] = [] + for row in rows: + role = row.role + category = row.category + name = row.name + effect = normalize_effect(row.effect) + duration = normalize_duration(row.duration) + + if not role or not category or not name: + raise ValueError( + f"存在不完整数据行,Excel 行号 {row.row_number}: " + f"{role!r}, {category!r}, {name!r}" + ) + + normalized.append( + Row( + role=role, + category=category, + name=name, + effect=effect, + duration=duration, + row_number=row.row_number, + ) + ) + + return normalized + + +def normalize_effect(value: str) -> str: + value = normalize_text(value) + replacements = { + "、": " / ", + ",": " / ", + } + for old, new in replacements.items(): + value = value.replace(old, new) + value = value.replace("提升治疗", "提升治疗效果") + return value + + +def normalize_duration(value: str) -> str: + value = normalize_text(value) + replacements = { + "2小时1": "2小时", + "瞬发 ": "瞬发", + } + return replacements.get(value, value) + + +def build_groups(rows: list[Row]) -> list[dict]: + buckets: dict[str, list[Row]] = defaultdict(list) + for row in rows: + buckets[row.role].append(row) + + role_order = [ + role + for role in ROLE_META + if role in buckets + ] + unknown_roles = sorted(role for role in buckets if role not in ROLE_META) + role_order.extend(unknown_roles) + + category_index = {name: index for index, name in enumerate(CATEGORY_ORDER)} + groups = [] + for role in role_order: + meta = ROLE_META.get(role, {}) + role_rows = sorted( + buckets[role], + key=lambda item: ( + category_index.get(item.category, len(CATEGORY_ORDER)), + item.row_number, + item.name, + ), + ) + items = [] + for row in role_rows: + items.append( + { + "cat": row.category, + "name": row.name, + "effect": row.effect, + "duration": row.duration, + "id": ITEM_ID_OVERRIDES.get(row.name, 0), + } + ) + + groups.append( + { + "key": meta.get("key", slugify(role)), + "role": role, + "detail": meta.get("detail", role), + "color": meta.get("color", (0.85, 0.75, 0.90)), + "items": items, + } + ) + + return groups + + +def slugify(value: str) -> str: + value = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff]+", "_", value) + value = value.strip("_") + return value.lower() or "role" + + +def render_lua(groups: list[dict], source_path: Path, item_count: int) -> str: + role_order = [group["role"] for group in groups] + category_order = sorted( + {item["cat"] for group in groups for item in group["items"]}, + key=lambda name: CATEGORY_ORDER.index(name) + if name in CATEGORY_ORDER + else len(CATEGORY_ORDER), + ) + + generated_at = datetime.fromtimestamp(source_path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S") + + lines = [ + "-" * 80, + "-- Nanami-UI: ConsumableDB.lua", + "-- 食物药剂百科数据库(由导出表生成,请优先更新 Excel 后再重新生成)", + f"-- Source: {source_path}", + f"-- Stats : {len(groups)} roles / {len(category_order)} categories / {item_count} entries", + "-" * 80, + "SFrames = SFrames or {}", + "", + "SFrames.ConsumableDB = {", + f' generatedAt = "{generated_at}",', + " summary = {", + f" roleCount = {len(groups)},", + f" categoryCount = {len(category_order)},", + f" itemCount = {item_count},", + " },", + " roleOrder = {", + ] + + for role in role_order: + lines.append(f' "{lua_escape(role)}",') + lines.extend( + [ + " },", + " categoryOrder = {", + ] + ) + for category in category_order: + lines.append(f' "{lua_escape(category)}",') + lines.extend( + [ + " },", + " groups = {", + ] + ) + + for index, group in enumerate(groups, start=1): + color = ", ".join(f"{component:.2f}" for component in group["color"]) + lines.extend( + [ + "", + f" -- {index}. {group['detail']}", + " {", + f' key = "{lua_escape(group["key"])}",', + f' role = "{lua_escape(group["role"])}",', + f' detail = "{lua_escape(group["detail"])}",', + f" color = {{ {color} }},", + " items = {", + ] + ) + for item in group["items"]: + lines.append( + ' {{ cat="{cat}", name="{name}", effect="{effect}", duration="{duration}", id={item_id} }},'.format( + cat=lua_escape(item["cat"]), + name=lua_escape(item["name"]), + effect=lua_escape(item["effect"]), + duration=lua_escape(item["duration"]), + item_id=item["id"], + ) + ) + lines.extend( + [ + " },", + " },", + ] + ) + + lines.extend( + [ + " },", + "}", + "", + ] + ) + return "\n".join(lines) + + +def lua_escape(value: str) -> str: + value = value.replace("\\", "\\\\").replace('"', '\\"') + return value + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate ConsumableDB.lua from an exported WoW consumables XLSX." + ) + parser.add_argument("xlsx", type=Path, help="Path to the exported XLSX file") + parser.add_argument( + "-o", + "--output", + type=Path, + default=Path("ConsumableDB.lua"), + help="Output Lua file path", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + rows = normalize_rows(read_xlsx_rows(args.xlsx)) + groups = build_groups(rows) + lua = render_lua(groups, args.xlsx, len(rows)) + args.output.write_text(lua, encoding="utf-8", newline="\n") + print( + textwrap.dedent( + f"""\ + Generated {args.output} + source : {args.xlsx} + roles : {len(groups)} + items : {len(rows)} + categories: {len({item['cat'] for group in groups for item in group['items']})} + """ + ).strip() + ) + + +if __name__ == "__main__": + main() diff --git a/docs/MapReveal-Standalone.md b/docs/MapReveal-Standalone.md new file mode 100644 index 0000000..779f907 --- /dev/null +++ b/docs/MapReveal-Standalone.md @@ -0,0 +1,126 @@ +# MapReveal Standalone + +## 目标 + +把 `Nanami-UI` 里的地图迷雾揭示功能单独提纯出来,方便其他插件作者复用,而不依赖整套 `SFrames` / `Nanami-UI` 框架。 + +## 当前实现包含什么 + +原始实现位于 `MapReveal.lua`,核心能力有 4 部分: + +1. 基于 `LibMapOverlayData` / `MapOverlayData` 补全地图 overlay 数据。 +2. Hook `WorldMapFrame_Update`,在世界地图刷新后把未探索区域重新画出来,并降低亮度显示。 +3. 被动采集当前角色已经探索过的 overlay,持久化到 SavedVariables。 +4. 主动扫描全部大陆与区域,把当前角色已经探索到的 overlay 批量收集出来。 + +## 与 Nanami-UI 的耦合点 + +真正的强耦合不多,主要只有这些: + +1. `SFrames:Print` + 用于聊天框输出提示。 +2. `SFramesDB` / `SFramesGlobalDB` + 分别保存角色配置和账号级扫描数据。 +3. `SFrames:CallWithPreservedBattlefieldMinimap` + 用来保护战场小地图状态,避免 `SetMapZoom` / `SetMapToCurrentZone` 带来副作用。 +4. `/nui ...` + 命令入口挂在 `Nanami-UI` 的统一 Slash Command 里。 +5. `ConfigUI.lua` + 只是配置面板入口,不影响核心迷雾逻辑。 + +## 提纯后的最小依赖 + +独立插件只需要: + +1. `LibMapOverlayData` 或兼容的 overlay 数据表。 +2. `WorldMapFrame_Update` +3. `GetMapInfo` / `GetNumMapOverlays` / `GetMapOverlayInfo` +4. `SetMapZoom` / `SetMapToCurrentZone` +5. SavedVariables + +也就是说,这个功能本质上可以完全脱离 UI 框架。 + +## 推荐拆分方式 + +建议拆成一个独立插件: + +1. `Nanami-MapReveal.toc` +2. `Core.lua` +3. `MapReveal.lua` + +其中: + +1. `Core.lua` 负责初始化、打印、Slash 命令、配置默认值。 +2. `MapReveal.lua` 只保留地图迷雾逻辑、扫描逻辑、数据持久化。 +3. 独立版会在世界地图右上角放一个 `Reveal` 勾选框,方便用户直接开关迷雾揭示。 + +## 数据结构建议 + +角色级配置: + +```lua +NanamiMapRevealDB = { + enabled = true, + unexploredAlpha = 0.7, +} +``` + +账号级扫描数据: + +```lua +NanamiMapRevealGlobalDB = { + scanned = { + ["ZoneName"] = { + "OVERLAYNAME:width:height:offsetX:offsetY", + }, + }, +} +``` + +## Hook 策略 + +最稳妥的方式仍然是包裹 `WorldMapFrame_Update`: + +1. 先隐藏现有 `WorldMapOverlay1..N` +2. 调用原始 `WorldMapFrame_Update` +3. 被动收集当前可见的 explored overlays +4. 如果开关开启,再自行补画 unexplored overlays + +这样兼容原版地图逻辑,也不需要重做整张世界地图。 + +## 兼容注意点 + +1. `NUM_WORLDMAP_OVERLAYS` 不够时要动态扩容。 +2. 最后一行/列贴图可能不是 256,必须重新计算 `TexCoord`。 +3. 个别地图 overlay 偏移有 errata,需要单独修正。 +4. 扫描地图时可能影响当前地图上下文,结束后要恢复原地图层级。 +5. 如果服务端或整合包已经补过 `MapOverlayData`,合并时要按 overlay 名去重。 + +## 已提取的独立插件样板 + +仓库里已经附带一个可复用版本: + +- [standalone/Nanami-MapReveal/Nanami-MapReveal.toc](e:/Game/trutle%20wow/Interface/AddOns/Nanami-UI/standalone/Nanami-MapReveal/Nanami-MapReveal.toc) +- [standalone/Nanami-MapReveal/Core.lua](e:/Game/trutle%20wow/Interface/AddOns/Nanami-UI/standalone/Nanami-MapReveal/Core.lua) +- [standalone/Nanami-MapReveal/MapReveal.lua](e:/Game/trutle%20wow/Interface/AddOns/Nanami-UI/standalone/Nanami-MapReveal/MapReveal.lua) + +## 给其他作者的接入建议 + +如果对方已经有自己的框架: + +1. 保留 `MapReveal.lua` 主体逻辑。 +2. 把打印函数替换成自己的日志函数。 +3. 把 DB 名称换成自己的 SavedVariables。 +4. 把 Slash 命令合并进自己的命令系统。 + +如果对方只想直接用: + +1. 复制 `standalone/Nanami-MapReveal` 整个目录。 +2. 放进 `Interface/AddOns/` +3. 确保客户端有 `LibMapOverlayData` 或兼容数据源。 + +## 后续可继续提纯的方向 + +1. 把 `TurtleWoW_Zones` 再单独拆成数据文件。 +2. 增加一个纯 API 层,只暴露 `Toggle` / `Refresh` / `ScanAllMaps` / `ExportScannedData`。 +3. 为不同端做兼容层,比如 Turtle WoW、Vanilla、1.12 私服整合端分别适配。 diff --git a/img/progress.tga b/img/progress.tga index 0ab42d626587b55ca6581ed7d37096c0371bde1b..88f60bc4c7c5fdc3a22af08e1f30e16b9c79aa9d 100644 GIT binary patch literal 65554 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 Y*SkB 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 OZ(!fRzJagW4gCKR@DjfO