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 0ab42d6..88f60bc 100644 Binary files a/img/progress.tga and b/img/progress.tga differ