From cc4455149c862d2d5d0eae987022fb20633f060d Mon Sep 17 00:00:00 2001 From: Timikana Date: Thu, 23 Apr 2026 13:46:21 +0200 Subject: [PATCH 1/2] Add Boss Frames: replacement for Blizzard's default boss target frames New top-level category in the options GUI ("Boss Frames") with 5 sub-pages (Layout, Bars, Cast Bar, Text, Auras). Settings are split per mode (Party and Raid) with a "Copy from other mode" button. Integrates with the global Test and Unlock buttons. Module layout: - Frames/Boss.lua core frames, health/power, portrait, mover, test mode with HP drain + simulated casts - Frames/BossCastBar.lua integrated or detached cast bar, UNIT_SPELLCAST_* events, spark anchored to StatusBar texture - Frames/BossAuras.lua pooled aura icons (buffs/debuffs), cooldown swipe, stacks + timer, Source filter (All / Mine / Hide mine / Boss-only) - Frames/BossOptionsGUI.lua integrates with the native SetupGUIPages flow (plugs via DF:SetupBossPages) - Frames/BossOptions.lua standalone /dfbf config panel (fallback) Boss settings: - New DandersFramesDB_v2.boss.{party,raid} block with seamless migration from the pre-split flat table - DF:GetBossDB(mode), DF:GetActiveBossDB(), DF:GetRenderBossDB() helpers - DF:CopyBossSettings(from, to) for the copy button Feature set: - Portrait 2D (Blizzard-style) or 3D (animated model), left/right/hidden, with thin border - Health bar: Reaction (Blizzard-style red/yellow/green) / Class / Static color, background alpha, LSM texture - Power bar with configurable height, texture, background alpha, and position/format for the power text. Hides when the unit has no power. - Cast bar integrated or detached relative to the frame. Icon left/right, LSM texture, background alpha slider. - 9-point anchors + X/Y offsets + name max length (default 16, 0=off) for name, health text, power text, raid target icon, auras. - Raid target icon (skull/cross/...) centered by default, alpha slider. - Auras: max 1-8 icons, size, spacing, timer placement (inside/below/ above), stack 9-point anchor, source filter, filter harmful/helpful. - Test mode integrated with the global Test button; dedicated Boss Frames section in the test panel with Boss Count slider + per-feature toggles. Slash commands: /dfbf (mover), /dfbf test N, /dfbf config, /dfbf refresh. Defaults + upstream compatibility: - Hides the Blizzard BossTargetFrameContainer + individual Boss*TargetFrame (togglable via "Hide Blizzard"). - All new locale keys registered in enUS.lua --@do-not-package@ block so BigWigs Packager -S auto-uploads them to the CurseForge portal. - Integrates via the existing SetupGUIPages hook + state drivers for combat-safe visibility. WoW 12.0 / Midnight secret-value compatibility: - All unit APIs that may return secret values (UnitHealth, UnitPower, UnitPowerMax, UnitName, UnitCastingInfo, UnitChannelInfo, UnitIsUnit, GetRaidTargetIndex, aura sourceUnit / isBossAura / applications / expirationTime) are read through pcall guards. - Cast bar animation uses SetValue(GetTime()*1000) against secret min/max; spark anchored to the StatusBar texture (non-secret width). - Power text falls back to visual percent via StatusBar texture width when UnitPower arithmetic is blocked. - Frame visibility uses RegisterStateDriver so new bosses appear even when engaged mid-combat (Show/Hide on a SecureUnitButton is blocked). Known limitation (proposed, not implemented): - If Party <-> Raid state flips mid-combat, ApplyLayout already flags _pendingLayout and reapplies on PLAYER_REGEN_ENABLED. The same guard could be extended to the group watcher's call to RefreshAll so drag / attribute changes never fire during InCombatLockdown. Left to the maintainer's preference. --- Config.lua | 205 +++++++ DandersFrames.toc | 5 + Frames/Boss.lua | 1105 +++++++++++++++++++++++++++++++++++++ Frames/BossAuras.lua | 377 +++++++++++++ Frames/BossCastBar.lua | 363 ++++++++++++ Frames/BossOptions.lua | 266 +++++++++ Frames/BossOptionsGUI.lua | 346 ++++++++++++ Locales/enUS.lua | 50 ++ Options/Options.lua | 8 +- TestMode/TestMode.lua | 60 ++ 10 files changed, 2784 insertions(+), 1 deletion(-) create mode 100644 Frames/Boss.lua create mode 100644 Frames/BossAuras.lua create mode 100644 Frames/BossCastBar.lua create mode 100644 Frames/BossOptions.lua create mode 100644 Frames/BossOptionsGUI.lua diff --git a/Config.lua b/Config.lua index 9c60de42..58ff70d7 100644 --- a/Config.lua +++ b/Config.lua @@ -3315,3 +3315,208 @@ function DF:GetGlobalDB() end return DandersFramesDB_v2.global end + +-- ============================================================ +-- BOSS FRAME DEFAULTS (v1 scaffold) +-- Standalone subsystem — not yet part of profile migration. +-- Stored under DandersFramesDB_v2.boss as a single shared block. +-- ============================================================ + +DF.BossDefaults = { + enabled = true, + hideBlizzard = true, + + -- Container position (via mover) + anchor = "RIGHT", + anchorX = -50, + anchorY = 100, + + -- Frame size + frameWidth = 220, + frameHeight = 50, + frameSpacing = 8, + growDirection = "DOWN", -- DOWN | UP + + -- Scale + frameScale = 1.0, + + -- Portrait + portraitPosition = "RIGHT", -- LEFT | RIGHT | HIDDEN + portraitStyle = "2D", -- 3D | 2D (2D matches Blizzard's default) + portraitSize = 44, -- px + + -- Health bar + healthTexture = "DF Smooth", + healthBackgroundAlpha = 0.35, + healthColorMode = "REACTION", -- REACTION (Blizzard: red/yellow/green by hostility) | CLASS_FALLBACK | STATIC + healthStaticColor = {r = 0.8, g = 0.1, b = 0.1, a = 1}, + + -- Power bar + showPowerBar = false, + powerBarHeight = 6, + powerTexture = "DF Smooth", + powerBackgroundAlpha = 0.7, + + -- Power (mana) text + showPowerText = true, + powerTextAnchor = "RIGHT", -- 9-point on the power bar + powerTextX = -2, + powerTextY = 0, + powerTextFormat = "PERCENT", -- PERCENT | CURRENT | CURRENT_MAX | CURRENT_PERCENT + + -- Cast bar + showCastBar = true, + castBarHeight = 14, + castBarDetached = false, + castBarIconPosition = "LEFT", -- LEFT | RIGHT + castTexture = "DF Smooth", + castBackgroundAlpha = 0.7, + + -- Text + showName = true, + nameAnchor = "RIGHT", -- 9-point: TOPLEFT|TOP|TOPRIGHT|LEFT|CENTER|RIGHT|BOTTOMLEFT|BOTTOM|BOTTOMRIGHT + nameX = 1, + nameY = 0, + nameMaxLength = 16, -- 0 = no truncation + + showHealthText = true, + healthTextAnchor = "LEFT", -- 9-point + healthTextX = 0, + healthTextY = 0, + healthTextFormat = "PERCENT", -- PERCENT | CURRENT | CURRENT_MAX | CURRENT_PERCENT + + -- Raid target icon (skull, cross, star...) + showRaidTargetIcon = true, + raidTargetAnchor = "CENTER", -- 9-point + raidTargetX = 0, + raidTargetY = 0, + raidTargetSize = 28, + raidTargetAlpha = 0.9, + + -- Detached cast bar (anchored relative to each boss frame, like name/HP) + castBarDetachedAnchor = "BOTTOM", -- 9-point on the boss frame + castBarDetachedX = 0, + castBarDetachedY = -4, + castBarDetachedWidth = 0, -- 0 = match frame width + + -- Auras / Debuffs + showAuras = true, + aurasMaxCount = 3, + aurasSize = 22, + aurasSpacing = 2, + aurasAnchor = "TOPRIGHT", + aurasX = 0, + aurasY = 0, + aurasGrowX = "LEFT", -- LEFT | RIGHT + aurasGrowY = "DOWN", -- UP | DOWN + aurasFilter = "HARMFUL", -- HARMFUL (debuffs) | HELPFUL (buffs) + aurasSource = "ALL", -- ALL (Blizzard-like) | MINE | NOT_MINE | BOSS_ONLY + aurasShowStacks = true, + aurasStackAnchor = "BOTTOMRIGHT", + aurasStackX = 0, + aurasStackY = 0, + + aurasShowTimer = true, + aurasTimerPlacement = "BELOW", -- INSIDE | BELOW | ABOVE + aurasTimerX = 0, + aurasTimerY = 0, + + -- Fonts (reuse addon defaults at runtime if nil) + fontSize = 12, + fontOutline = "OUTLINE", + + -- Frame strata + frameStrata = "MEDIUM", +} + +-- Deep-copy the BossDefaults into an empty mode block +local function seedBossDefaults(target) + for k, v in pairs(DF.BossDefaults) do + if target[k] == nil then + if type(v) == "table" then + local c = {} + for kk, vv in pairs(v) do c[kk] = vv end + target[k] = c + else + target[k] = v + end + end + end +end + +-- Returns the boss settings table for the requested mode ("party"|"raid"). +-- If no mode is given, auto-detects from GUI.SelectedMode (for options +-- panel editing) or IsInRaid() (for in-game display fallback). +-- +-- Migrates the pre-split flat DandersFramesDB_v2.boss table into +-- DandersFramesDB_v2.boss.party the first time this runs after the split. +function DF:GetBossDB(mode) + DandersFramesDB_v2 = DandersFramesDB_v2 or {} + DandersFramesDB_v2.boss = DandersFramesDB_v2.boss or {} + local root = DandersFramesDB_v2.boss + + -- Migration: old flat { enabled=..., anchor=..., ... } → { party = {...} } + if root.enabled ~= nil and root.party == nil then + local copy = {} + for k, v in pairs(root) do + if k ~= "party" and k ~= "raid" then copy[k] = v end + end + for k in pairs(root) do + if k ~= "party" and k ~= "raid" then root[k] = nil end + end + root.party = copy + end + + if not mode then + if DF.GUI and DF.GUI.SelectedMode == "raid" then + mode = "raid" + elseif DF.GUI and DF.GUI.SelectedMode == "party" then + mode = "party" + else + mode = IsInRaid() and "raid" or "party" + end + end + if mode ~= "party" and mode ~= "raid" then mode = "party" end + + root[mode] = root[mode] or {} + seedBossDefaults(root[mode]) + return root[mode] +end + +-- Live display mode (for in-game rendering), independent of options panel. +function DF:GetActiveBossDB() + return DF:GetBossDB(IsInRaid() and "raid" or "party") +end + +-- Rendering path: honour the GUI mode during test-mode previews, otherwise +-- follow real group state (IsInRaid). +function DF:GetRenderBossDB() + if DF.BossFrames then + for i = 1, 5 do + local f = DF.BossFrames[i] + if f and f._testMode then + if DF.GUI then return DF:GetBossDB(DF.GUI.SelectedMode) end + break + end + end + end + return DF:GetActiveBossDB() +end + +-- Copy all settings from one mode to the other. +function DF:CopyBossSettings(fromMode, toMode) + if fromMode == toMode then return end + local src = DF:GetBossDB(fromMode) + local dst = DF:GetBossDB(toMode) + for k in pairs(dst) do dst[k] = nil end + for k, v in pairs(src) do + if type(v) == "table" then + local c = {} + for kk, vv in pairs(v) do c[kk] = vv end + dst[k] = c + else + dst[k] = v + end + end + if DF.RefreshBossFrames then DF:RefreshBossFrames() end +end diff --git a/DandersFrames.toc b/DandersFrames.toc index 180c8be2..c8521aae 100644 --- a/DandersFrames.toc +++ b/DandersFrames.toc @@ -80,6 +80,11 @@ Frames\StatusIcons.lua Frames\Init.lua Frames\Position.lua Frames\Pets.lua +Frames\Boss.lua +Frames\BossCastBar.lua +Frames\BossAuras.lua +Frames\BossOptions.lua +Frames\BossOptionsGUI.lua # Test Mode TestMode\TestFramePool.lua diff --git a/Frames/Boss.lua b/Frames/Boss.lua new file mode 100644 index 00000000..cf422dc7 --- /dev/null +++ b/Frames/Boss.lua @@ -0,0 +1,1105 @@ +local addonName, DF = ... + +-- ============================================================ +-- BOSS FRAMES (v1 scaffold) +-- Creates up to 5 boss unit frames (boss1..boss5) with: +-- * Portrait (3D / 2D / hidden, left or right) +-- * Health bar with class colour fallback +-- * Power bar +-- * Cast bar (castbar element only — Étape 2 wires full events) +-- * Name + health text +-- +-- The Blizzard BossTargetFrameContainer is hidden when enabled. +-- ============================================================ + +local pairs, ipairs = pairs, ipairs +local format = string.format +local CreateFrame = CreateFrame +local UnitExists = UnitExists +local UnitName = UnitName +local UnitIsConnected = UnitIsConnected +local UnitClass = UnitClass +local UnitHealth = UnitHealth +local UnitHealthMax = UnitHealthMax +local UnitPower = UnitPower +local UnitPowerMax = UnitPowerMax +local UnitPowerType = UnitPowerType +local UnitIsDeadOrGhost = UnitIsDeadOrGhost +local SetPortraitTexture = SetPortraitTexture +local InCombatLockdown = InCombatLockdown + +local MAX_BOSS = 5 + +DF.BossFrames = DF.BossFrames or {} +DF.BossContainer = nil + +-- Resolve an LSM texture name (e.g. "DF Smooth") to a path. If the value +-- already looks like a path (contains backslash or forward slash), use +-- it as-is. Falls back to DF_Smooth if the name is unknown. +local function ResolveTexture(name) + if not name or name == "" then + return "Interface\\AddOns\\DandersFrames\\Media\\DF_Smooth" + end + if name:find("\\") or name:find("/") then + return name + end + local LSM = LibStub and LibStub("LibSharedMedia-3.0", true) + if LSM then + local p = LSM:Fetch("statusbar", name) + if p then return p end + end + return "Interface\\AddOns\\DandersFrames\\Media\\DF_Smooth" +end +DF.ResolveBossTexture = ResolveTexture + +-- ============================================================ +-- BLIZZARD BOSS FRAME HIDING +-- ============================================================ + +local blizzardHidden = false +local function HideBlizzardBossFrames() + if blizzardHidden then return end + blizzardHidden = true + + -- Modern retail (Midnight 12.0): BossTargetFrameContainer with children + local container = _G["BossTargetFrameContainer"] or _G["BossFrameContainer"] + if container then + container:UnregisterAllEvents() + container:Hide() + container.Show = function() end + end + + for i = 1, MAX_BOSS do + local names = { + "Boss" .. i .. "TargetFrame", + "BossTargetFrame" .. i, + } + for _, n in ipairs(names) do + local f = _G[n] + if f then + f:UnregisterAllEvents() + f:Hide() + f.Show = function() end + end + end + end +end + +-- ============================================================ +-- COLOR HELPERS +-- ============================================================ + +local CLASS_COLORS = RAID_CLASS_COLORS or {} + +local function GetHealthColor(db, unit) + local mode = db.healthColorMode or "CLASS_FALLBACK" + if mode == "STATIC" then + local c = db.healthStaticColor + return c.r, c.g, c.b + elseif mode == "REACTION" then + -- Blizzard-style: hostile → red, neutral → yellow, friendly → green + local reaction = UnitReaction and UnitReaction(unit, "player") + if reaction and FACTION_BAR_COLORS and FACTION_BAR_COLORS[reaction] then + local c = FACTION_BAR_COLORS[reaction] + return c.r, c.g, c.b + end + return 0.9, 0.15, 0.15 + end + -- CLASS_FALLBACK + local _, cls = UnitClass(unit) + local cc = cls and CLASS_COLORS[cls] + if cc then return cc.r, cc.g, cc.b end + return 0.9, 0.15, 0.15 +end + +local function GetPowerColor(unit) + local powerType, powerToken = UnitPowerType(unit) + local info = powerToken and PowerBarColor and PowerBarColor[powerToken] + if info then return info.r, info.g, info.b end + return 0.3, 0.4, 0.9 +end + +-- ============================================================ +-- HEALTH / POWER UPDATE (safe against secret values) +-- ============================================================ + +local function SetHealthValue(frame, unit) + local hp, hpMax + if DF.GetSafeHealthPercent then + local pct = DF.GetSafeHealthPercent(unit) + frame.healthBar:SetMinMaxValues(0, 100) + frame.healthBar:SetValue(pct or 0) + frame._hpPct = pct or 0 + else + hp, hpMax = UnitHealth(unit), UnitHealthMax(unit) + if type(hp) ~= "number" or type(hpMax) ~= "number" or hpMax == 0 then + frame.healthBar:SetMinMaxValues(0, 1) + frame.healthBar:SetValue(1) + frame._hpPct = 100 + else + frame.healthBar:SetMinMaxValues(0, hpMax) + frame.healthBar:SetValue(hp) + frame._hpPct = (hp / hpMax) * 100 + end + end +end + +local function SetPowerValue(frame, unit) + if not frame.powerBar or not frame.powerBar:IsShown() then return end + local okP, p = pcall(UnitPower, unit) + local okM, pMax = pcall(UnitPowerMax, unit) + if not okP or not okM then return end + + -- Unit has no power pool (e.g. certain bosses): hide bar + text entirely. + local okCmp, hasPower = pcall(function() return pMax and pMax > 0 end) + if not okCmp or not hasPower then + frame.powerBar:Hide() + if frame.powerText then frame.powerText:SetText("") end + return + end + frame.powerBar:Show() + + pcall(frame.powerBar.SetMinMaxValues, frame.powerBar, 0, pMax) + pcall(frame.powerBar.SetValue, frame.powerBar, p) + frame.powerBar:SetStatusBarColor(GetPowerColor(unit)) + + if not frame.powerText then return end + local db = DF:GetRenderBossDB() + if not db.showPowerText then frame.powerText:SetText(""); return end + + -- Visual percent via StatusBar texture width — works even when p/pMax + -- are secret (GetWidth returns display pixels, not secret values). + local function visualPct() + local tex = frame.powerBar:GetStatusBarTexture() + local barW = frame.powerBar:GetWidth() + if not tex or not barW or barW <= 0 then return 0 end + return (tex:GetWidth() or 0) / barW * 100 + end + + local fmt = db.powerTextFormat or "PERCENT" + if fmt == "PERCENT" then + frame.powerText:SetFormattedText("%d%%", visualPct()) + elseif fmt == "CURRENT" then + pcall(frame.powerText.SetText, frame.powerText, AbbreviateLargeNumbers(p)) + elseif fmt == "CURRENT_PERCENT" then + local okAbbr, pStr = pcall(AbbreviateLargeNumbers, p) + if okAbbr then + frame.powerText:SetFormattedText("%s (%d%%)", pStr, visualPct()) + else + frame.powerText:SetFormattedText("%d%%", visualPct()) + end + else + pcall(frame.powerText.SetFormattedText, frame.powerText, + "%s / %s", AbbreviateLargeNumbers(p), AbbreviateLargeNumbers(pMax)) + end +end + +local function FormatHealthText(frame, db, unit) + if not frame.healthText then return end + if not db.showHealthText then frame.healthText:SetText(""); return end + local fmt = db.healthTextFormat or "PERCENT" + local pct = frame._hpPct or 0 -- may be a secret number; never do arithmetic on it + + if fmt == "PERCENT" then + -- SetFormattedText accepts secret numbers directly with %d + pcall(frame.healthText.SetFormattedText, frame.healthText, "%d%%", pct) + elseif fmt == "CURRENT" then + if frame._testMode then + frame.healthText:SetText(AbbreviateLargeNumbers((frame._testHp or 0) * 1e6)) + else + local ok, hp = pcall(UnitHealth, unit) + if ok and type(hp) == "number" then + frame.healthText:SetText(AbbreviateLargeNumbers(hp)) + else + frame.healthText:SetText("") + end + end + elseif fmt == "CURRENT_PERCENT" then + if frame._testMode then + local hp = (frame._testHp or 0) * 1e6 + frame.healthText:SetFormattedText("%s (%d%%)", AbbreviateLargeNumbers(hp), pct) + else + local ok, hp = pcall(UnitHealth, unit) + if ok and type(hp) == "number" then + pcall(frame.healthText.SetFormattedText, frame.healthText, "%s (%d%%)", AbbreviateLargeNumbers(hp), pct) + end + end + else -- CURRENT_MAX + if frame._testMode then + frame.healthText:SetFormattedText("%s / %s", + AbbreviateLargeNumbers((frame._testHp or 0) * 1e6), + AbbreviateLargeNumbers(1e8)) + else + local ok1, hp = pcall(UnitHealth, unit) + local ok2, hpMax = pcall(UnitHealthMax, unit) + if ok1 and ok2 and type(hp) == "number" and type(hpMax) == "number" then + frame.healthText:SetFormattedText("%s / %s", + AbbreviateLargeNumbers(hp), AbbreviateLargeNumbers(hpMax)) + else + frame.healthText:SetText("") + end + end + end +end + +-- ============================================================ +-- FRAME UPDATE (all visuals for a single boss frame) +-- ============================================================ + +local function UpdateFrame(frame) + local unit = frame.unit + local db = DF:GetRenderBossDB() + + -- Visibility is driven by RegisterStateDriver (boss unit exists → show). + -- In test mode we override: set driver to always-show; on exit, revert. + -- Don't early-return here — UpdateFrame should still populate data when + -- the frame isn't visible (cheap, and avoids stale data on next show). + + -- Name — UnitName / length / sub may all return or operate on secret + -- values in WoW 12.0+. Wrap everything in pcall. + if frame.nameText then + if db.showName then + local ok, n = pcall(UnitName, unit) + if not ok or not n then n = frame._testName or ("Boss " .. frame.index) end + local maxLen = db.nameMaxLength or 0 + if maxLen > 0 then + pcall(function() + if #n > maxLen then n = n:sub(1, maxLen - 1) .. "…" end + end) + end + pcall(frame.nameText.SetText, frame.nameText, n) + else + frame.nameText:SetText("") + end + end + + -- Portrait + if frame.portrait then + if db.portraitPosition ~= "HIDDEN" then + frame.portrait:Show() + if frame.portrait.isModel then + -- 3D model + if frame._testMode then + if not frame._testModelSet then + frame.portrait:ClearModel() + frame.portrait:SetUnit("player") + frame.portrait:SetPortraitZoom(0.9) + frame._testModelSet = true + end + else + frame._testModelSet = nil + frame.portrait:SetUnit(unit) + frame.portrait:SetPortraitZoom(1) + end + if db.portraitPosition == "RIGHT" then + frame.portrait:SetFacing(-0.5) + elseif db.portraitPosition == "LEFT" then + frame.portrait:SetFacing(0.5) + else + frame.portrait:SetFacing(0) + end + else + -- 2D texture (Blizzard-style face icon) + if frame._testMode then + SetPortraitTexture(frame.portrait, "player") + else + SetPortraitTexture(frame.portrait, unit) + end + end + else + frame.portrait:Hide() + end + end + + -- Health + if frame._testMode then + frame.healthBar:SetMinMaxValues(0, 100) + frame.healthBar:SetValue(frame._testHp or 80) + frame._hpPct = frame._testHp or 80 + else + SetHealthValue(frame, unit) + end + frame.healthBar:SetStatusBarColor(GetHealthColor(db, unit)) + + -- Power + if db.showPowerBar and frame.powerBar then + frame.powerBar:Show() + if frame._testMode then + local p = frame._testPower or 50 + frame.powerBar:SetMinMaxValues(0, 100) + frame.powerBar:SetValue(p) + frame.powerBar:SetStatusBarColor(0.3, 0.4, 0.9) + if frame.powerText then + if db.showPowerText then + frame.powerText:SetFormattedText("%d%%", p + 0.5) + else + frame.powerText:SetText("") + end + end + else + SetPowerValue(frame, unit) + end + elseif frame.powerBar then + frame.powerBar:Hide() + end + + -- Health text + FormatHealthText(frame, db, unit) + + -- Dead overlay + if UnitIsDeadOrGhost(unit) and not frame._testMode then + frame:SetAlpha(0.4) + else + frame:SetAlpha(1) + end + + -- Raid target icon visibility + if db.showRaidTargetIcon and DF.UpdateBossRaidTargetIcon then + DF.UpdateBossRaidTargetIcon(frame) + elseif frame.raidTargetIcon then + frame.raidTargetIcon:Hide() + end + + -- Auras + if DF.UpdateBossAuras then DF.UpdateBossAuras(frame) end + +end + +-- ============================================================ +-- LAYOUT +-- ============================================================ + +local _pendingLayout = false +local function ApplyLayout() + local db = DF:GetRenderBossDB() + local container = DF.BossContainer + if not container then return end + + -- Secure-frame modifications are forbidden in combat. Defer until + -- PLAYER_REGEN_ENABLED to avoid "action blocked" + taint cascade. + if InCombatLockdown() then + _pendingLayout = true + return + end + + container:SetScale(db.frameScale or 1) + container:SetFrameStrata(db.frameStrata or "MEDIUM") + + -- Re-anchor the container (honours Offset X/Y sliders changes) + local anchor = db.anchor or "RIGHT" + container:ClearAllPoints() + container:SetPoint(anchor, UIParent, anchor, db.anchorX or 0, db.anchorY or 0) + + local hpTex = ResolveTexture(db.healthTexture) + local pwTex = ResolveTexture(db.powerTexture) + + -- 9-point anchor validation shared across all per-frame anchor math below + local VALID_ANCHOR9 = { + TOPLEFT = true, TOP = true, TOPRIGHT = true, + LEFT = true, CENTER = true, RIGHT = true, + BOTTOMLEFT = true, BOTTOM = true, BOTTOMRIGHT = true, + } + local function justifyOf(anchor) + if anchor:find("LEFT") then return "LEFT" + elseif anchor:find("RIGHT") then return "RIGHT" + else return "CENTER" end + end + + for i = 1, MAX_BOSS do + local f = DF.BossFrames[i] + if not f then break end + + f:SetSize(db.frameWidth, db.frameHeight) + + -- Textures + if f.healthBar then + f.healthBar:SetStatusBarTexture(hpTex) + if f.healthBar.bg then + f.healthBar.bg:SetColorTexture(0.1, 0.1, 0.1, db.healthBackgroundAlpha or 0.35) + end + end + if f.powerBar then + f.powerBar:SetStatusBarTexture(pwTex) + if f.powerBar.bg then + f.powerBar.bg:SetColorTexture(0, 0, 0, db.powerBackgroundAlpha or 0.7) + end + end + f:ClearAllPoints() + + if i == 1 then + f:SetPoint("TOP", container, "TOP", 0, 0) + else + local prev = DF.BossFrames[i - 1] + local spacing = db.frameSpacing + if db.growDirection == "UP" then + f:SetPoint("BOTTOM", prev, "TOP", 0, spacing) + else + f:SetPoint("TOP", prev, "BOTTOM", 0, -spacing) + end + end + + -- Portrait layout: pick 3D model or 2D texture based on portraitStyle + local use3D = (db.portraitStyle ~= "2D") + f.portrait = use3D and f.portrait3D or f.portrait2D + local other = use3D and f.portrait2D or f.portrait3D + + if other then other:Hide() end + if f.portrait then + local size = db.portraitSize + f.portrait:SetSize(size, size) + f.portrait:ClearAllPoints() + if db.portraitPosition == "LEFT" then + f.portrait:SetPoint("RIGHT", f, "LEFT", -2, 0) + elseif db.portraitPosition == "RIGHT" then + f.portrait:SetPoint("LEFT", f, "RIGHT", 2, 0) + end + -- Border (1px black rect behind the portrait) + if f.portraitBorder then + f.portraitBorder:ClearAllPoints() + if db.portraitPosition == "HIDDEN" then + f.portraitBorder:Hide() + else + f.portraitBorder:Show() + f.portraitBorder:SetPoint("TOPLEFT", f.portrait, "TOPLEFT", -1, 1) + f.portraitBorder:SetPoint("BOTTOMRIGHT", f.portrait, "BOTTOMRIGHT", 1, -1) + end + end + end + + -- Layout order from bottom to top: cast (if integrated) → power → health + local castH = (db.showCastBar and not db.castBarDetached) and (db.castBarHeight + 1) or 0 + local powerH = db.showPowerBar and (db.powerBarHeight + 1) or 0 + + -- Power bar sits above the cast bar area + if f.powerBar then + f.powerBar:ClearAllPoints() + f.powerBar:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, castH) + f.powerBar:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, castH) + f.powerBar:SetHeight(db.powerBarHeight) + end + + -- Health bar fills the top, above the power bar + f.healthBar:ClearAllPoints() + f.healthBar:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0) + f.healthBar:SetPoint("TOPRIGHT", f, "TOPRIGHT", 0, 0) + f.healthBar:SetPoint("BOTTOMLEFT", f, "BOTTOMLEFT", 0, castH + powerH) + f.healthBar:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", 0, castH + powerH) + + -- Power text anchor (inside the power bar) + if f.powerText then + f.powerText:ClearAllPoints() + local a = db.powerTextAnchor + if not VALID_ANCHOR9[a] then a = "RIGHT"; db.powerTextAnchor = a end + f.powerText:SetPoint(a, f.powerBar, a, db.powerTextX or 0, db.powerTextY or 0) + f.powerText:SetJustifyH(justifyOf(a)) + if db.showPowerText and db.showPowerBar then + f.powerText:Show() + else + f.powerText:Hide() + end + end + + -- Text alignment (9-point) — sanitise in case SV holds bad values + if f.nameText then + f.nameText:ClearAllPoints() + local a = db.nameAnchor + if not VALID_ANCHOR9[a] then a = "LEFT"; db.nameAnchor = a end + local ox = db.nameX or 0 + local oy = db.nameY or 0 + f.nameText:SetPoint(a, f.healthBar, a, ox, oy) + f.nameText:SetJustifyH(justifyOf(a)) + end + if f.healthText then + f.healthText:ClearAllPoints() + local a = db.healthTextAnchor + if not VALID_ANCHOR9[a] then a = "RIGHT"; db.healthTextAnchor = a end + local ox = db.healthTextX or 0 + local oy = db.healthTextY or 0 + f.healthText:SetPoint(a, f.healthBar, a, ox, oy) + f.healthText:SetJustifyH(justifyOf(a)) + end + + -- Raid target icon layout + if f.raidTargetIcon then + if db.showRaidTargetIcon then + local a = db.raidTargetAnchor + if not VALID_ANCHOR9[a] then a = "CENTER"; db.raidTargetAnchor = a end + f.raidTargetIcon:SetSize(db.raidTargetSize or 28, db.raidTargetSize or 28) + f.raidTargetIcon:ClearAllPoints() + f.raidTargetIcon:SetPoint(a, f, a, db.raidTargetX or 0, db.raidTargetY or 0) + f.raidTargetIcon:SetAlpha(db.raidTargetAlpha or 0.9) + -- Ensure texture atlas is set (in case SV-loaded frames lost it) + if not f.raidTargetIcon:GetTexture() then + f.raidTargetIcon:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons") + end + else + f.raidTargetIcon:Hide() + end + end + + -- Sanitise healthTextFormat + local VALID_FMT = { PERCENT = true, CURRENT = true, CURRENT_PERCENT = true, CURRENT_MAX = true } + if not VALID_FMT[db.healthTextFormat] then db.healthTextFormat = "PERCENT" end + + -- Cast bar layout (if module present) + if DF.LayoutBossCastBar then DF.LayoutBossCastBar(f, db) end + + -- Auras layout + if DF.LayoutBossAuras then DF.LayoutBossAuras(f, db) end + end +end + +DF.ApplyBossLayout = ApplyLayout + +-- Update a single frame's raid target icon (skull/cross/star/etc.) +function DF.UpdateBossRaidTargetIcon(frame) + if not frame.raidTargetIcon then return end + local unit = frame.unit + local idx + if frame._testMode then + idx = frame.index -- 1..5 in test + else + local ok, v = pcall(GetRaidTargetIndex, unit) + if ok then idx = v end + end + -- idx may be a secret number in WoW 12.0+; can't compare directly. + -- Pass it straight to SetRaidTargetIconTexCoord (Blizzard's fn accepts + -- secrets). Guard the whole call. + if not idx then + frame.raidTargetIcon:Hide() + return + end + local applied + local ok = pcall(function() + if SetRaidTargetIconTexCoord then + SetRaidTargetIconTexCoord(frame.raidTargetIcon, idx) + elseif SetRaidTargetIconTexture then + SetRaidTargetIconTexture(frame.raidTargetIcon, idx) + end + applied = true + end) + -- Decide visibility: if idx was 0 (no marker), texcoord is 0,0,0,0 → show + -- would be a black square. Use pcall to compare with zero. + local okZero, isZero = pcall(function() return idx == 0 end) + if ok and applied and not (okZero and isZero) then + frame.raidTargetIcon:Show() + else + frame.raidTargetIcon:Hide() + end +end + +-- ============================================================ +-- FRAME CREATION +-- ============================================================ + +local function CreateBossFrame(index) + local name = "DFBossFrame" .. index + local unit = "boss" .. index + + local f = CreateFrame("Button", name, DF.BossContainer, "SecureUnitButtonTemplate") + f:SetAttribute("unit", unit) + f:SetAttribute("*type1", "target") + f:SetAttribute("*type2", "togglemenu") + f:RegisterForClicks("AnyDown") + f.unit = unit + f.index = index + + -- Visibility via secure state driver — works in combat without Show/Hide + -- calls on this SecureUnitButton. The frame shows whenever its boss unit + -- exists; test mode overrides with a unit-less "show" macro condition. + RegisterStateDriver(f, "visibility", "[@" .. unit .. ",exists]show;hide") + + -- Background behind everything + local bg = f:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints(f) + bg:SetColorTexture(0, 0, 0, 0.6) + f.bg = bg + + -- Health bar + local hp = CreateFrame("StatusBar", nil, f) + hp:SetStatusBarTexture("Interface\\AddOns\\DandersFrames\\Media\\DF_Smooth") + hp:SetMinMaxValues(0, 100) + hp:SetValue(100) + f.healthBar = hp + + local hpBg = hp:CreateTexture(nil, "BACKGROUND") + hpBg:SetAllPoints(hp) + hpBg:SetColorTexture(0.1, 0.1, 0.1, 0.5) + hp.bg = hpBg + + -- Power bar + local pw = CreateFrame("StatusBar", nil, f) + pw:SetStatusBarTexture("Interface\\AddOns\\DandersFrames\\Media\\DF_Smooth") + pw:SetMinMaxValues(0, 100) + pw:SetValue(0) + f.powerBar = pw + + local pwBg = pw:CreateTexture(nil, "BACKGROUND") + pwBg:SetAllPoints(pw) + pwBg:SetColorTexture(0, 0, 0, 0.7) + pw.bg = pwBg + + -- Power text (mana %/value) + local pwText = pw:CreateFontString(nil, "OVERLAY", nil) + pwText:SetFontObject(_G["DFFontHighlightSmallOutline"] or _G["NumberFontNormalSmall"]) + pwText:SetTextColor(1, 1, 1) + pwText:SetPoint("RIGHT", pw, "RIGHT", -2, 0) + pwText:SetJustifyH("RIGHT") + f.powerText = pwText + + -- Portrait — create both a 3D model and a 2D texture; the layout picks + -- the active one based on db.portraitStyle. + local portrait3D = CreateFrame("PlayerModel", nil, f) + portrait3D.isModel = true + portrait3D:Hide() + f.portrait3D = portrait3D + + local portrait2D = f:CreateTexture(nil, "ARTWORK") + portrait2D:SetTexCoord(0.08, 0.92, 0.08, 0.92) + portrait2D:Hide() + f.portrait2D = portrait2D + + -- `portrait` is the active one, set by ApplyLayout + f.portrait = portrait3D + + -- Thin border around the portrait: a slightly larger black rectangle + -- behind the model creates a 1px "frame" look. + local pb = f:CreateTexture(nil, "BACKGROUND", nil, 2) + pb:SetColorTexture(0, 0, 0, 0.9) + f.portraitBorder = pb + + -- Name text + local nameText = hp:CreateFontString(nil, "OVERLAY", "GameFontNormal") + nameText:SetPoint("LEFT", hp, "LEFT", 4, 0) + nameText:SetJustifyH("LEFT") + nameText:SetTextColor(1, 1, 1) + f.nameText = nameText + + -- Health text + local hpText = hp:CreateFontString(nil, "OVERLAY", "GameFontNormal") + hpText:SetPoint("RIGHT", hp, "RIGHT", -4, 0) + hpText:SetJustifyH("RIGHT") + hpText:SetTextColor(1, 1, 1) + f.healthText = hpText + + -- Hover highlight + local highlight = f:CreateTexture(nil, "HIGHLIGHT") + highlight:SetAllPoints(f) + highlight:SetColorTexture(1, 1, 1, 0.1) + + + -- Raid target icon (skull/cross/star/etc.) — parent to healthBar on its + -- OVERLAY sublayer so it draws ABOVE the bar's artwork texture. + local rti = hp:CreateTexture(nil, "OVERLAY", nil, 7) + rti:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons") + rti:SetSize(28, 28) + rti:SetPoint("CENTER", f, "CENTER", 0, 0) + rti:Hide() + f.raidTargetIcon = rti + + -- Event handler + f:RegisterEvent("UNIT_HEALTH") + f:RegisterEvent("UNIT_MAXHEALTH") + f:RegisterEvent("UNIT_POWER_UPDATE") + f:RegisterEvent("UNIT_MAXPOWER") + f:RegisterEvent("UNIT_DISPLAYPOWER") + f:RegisterEvent("UNIT_NAME_UPDATE") + f:RegisterEvent("UNIT_PORTRAIT_UPDATE") + f:RegisterEvent("UNIT_TARGETABLE_CHANGED") + f:RegisterEvent("RAID_TARGET_UPDATE") + f:RegisterUnitEvent("UNIT_HEALTH", unit) + f:SetScript("OnEvent", function(self, event, eUnit) + if event == "RAID_TARGET_UPDATE" then + DF.UpdateBossRaidTargetIcon(self) + return + end + if event == "UNIT_TARGETABLE_CHANGED" and eUnit == unit then + UpdateFrame(self) + return + end + if eUnit ~= unit then return end + UpdateFrame(self) + end) + + return f +end + +-- ============================================================ +-- INIT +-- ============================================================ + +local function EnsureCreated() + if DF.BossContainer then return end + + local db = DF:GetRenderBossDB() + if not db.enabled then return end + + local container = CreateFrame("Frame", "DFBossContainer", UIParent) + container:SetSize(db.frameWidth, (db.frameHeight + db.frameSpacing) * MAX_BOSS) + container:ClearAllPoints() + container:SetPoint(db.anchor or "RIGHT", UIParent, db.anchor or "RIGHT", db.anchorX or 0, db.anchorY or 0) + container:SetFrameStrata(db.frameStrata or "MEDIUM") + container:SetMovable(true) + DF.BossContainer = container + + for i = 1, MAX_BOSS do + DF.BossFrames[i] = CreateBossFrame(i) + end + + ApplyLayout() + + if db.hideBlizzard then + HideBlizzardBossFrames() + end +end + +DF.EnsureBossFramesCreated = EnsureCreated + +local function RefreshAll() + if not DF.BossContainer then return end + ApplyLayout() + for i = 1, MAX_BOSS do + local f = DF.BossFrames[i] + if f then UpdateFrame(f) end + end +end + +DF.RefreshBossFrames = RefreshAll + +-- ============================================================ +-- EVENTS (engage / disengage bosses) +-- ============================================================ + +local bossEventFrame = CreateFrame("Frame") +bossEventFrame:RegisterEvent("PLAYER_LOGIN") +bossEventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") +bossEventFrame:RegisterEvent("INSTANCE_ENCOUNTER_ENGAGE_UNIT") +bossEventFrame:RegisterEvent("ENCOUNTER_START") +bossEventFrame:RegisterEvent("ENCOUNTER_END") +bossEventFrame:RegisterEvent("PLAYER_REGEN_ENABLED") + +local hooksInstalled = false +local function InstallGlobalHooks() + if hooksInstalled then return end + hooksInstalled = true + + -- Tie boss test mode to the global Test Mode toggle, respecting the + -- "Show Boss Frames" checkbox and count slider in the test panel. + if DF.ToggleTestMode then + hooksecurefunc(DF, "ToggleTestMode", function() + local anyTest = DF.testMode or DF.raidTestMode + local currentBossTest + for i = 1, MAX_BOSS do + if DF.BossFrames[i] and DF.BossFrames[i]._testMode then + currentBossTest = true; break + end + end + local isRaidMode = DF.GUI and DF.GUI.SelectedMode == "raid" + local db = isRaidMode and DF:GetRaidDB() or DF:GetDB() + -- Default to showing boss test when user has never touched the + -- checkbox (nil) — keeps previous behaviour for new users. + local wantBoss = anyTest and (db.testShowBoss == nil or db.testShowBoss) + local count = tonumber(db.testBossCount) or 3 + if wantBoss and not currentBossTest then + DF:SetBossTestMode(count) + elseif (not wantBoss) and currentBossTest then + DF:SetBossTestMode(0) + end + end) + end + + -- Tie boss mover to the global Unlock / Lock buttons. + local function sync(desiredUnlocked) + if not DF.BossContainer then EnsureCreated() end + if not DF.BossContainer then return end + local curUnlocked = DF.BossContainer._movingEnabled and true or false + if curUnlocked ~= desiredUnlocked then + DF:ToggleBossMover() + end + end + if DF.UnlockFrames then hooksecurefunc(DF, "UnlockFrames", function() sync(true) end) end + if DF.LockFrames then hooksecurefunc(DF, "LockFrames", function() sync(false) end) end + if DF.UnlockRaidFrames then hooksecurefunc(DF, "UnlockRaidFrames", function() sync(true) end) end + if DF.LockRaidFrames then hooksecurefunc(DF, "LockRaidFrames", function() sync(false) end) end + + -- Re-apply boss frames when the user switches PARTY <-> RAID in the + -- options panel. RefreshCurrentPage is built lazily when /df opens for + -- the first time — poll until it exists, then install the hook. + local refreshHookInstalled = false + local function tryInstallRefreshHook() + if refreshHookInstalled then return end + if not (DF.GUI and DF.GUI.RefreshCurrentPage) then return end + refreshHookInstalled = true + + local lastMode = DF.GUI.SelectedMode or "party" + hooksecurefunc(DF.GUI, "RefreshCurrentPage", function() + local cur = DF.GUI.SelectedMode or "party" + if cur ~= lastMode then + lastMode = cur + C_Timer.After(0, function() RefreshAll() end) + end + end) + end + tryInstallRefreshHook() + if not refreshHookInstalled then + -- Poll every 0.5s until the GUI is built (user opens /df). + local poll = CreateFrame("Frame") + local _acc = 0 + poll:SetScript("OnUpdate", function(self, elapsed) + _acc = _acc + elapsed + if _acc < 0.5 then return end + _acc = 0 + tryInstallRefreshHook() + if refreshHookInstalled then self:SetScript("OnUpdate", nil) end + end) + end + + -- Also re-apply on real group-state change (joining/leaving a raid). + local groupWatcher = CreateFrame("Frame") + groupWatcher:RegisterEvent("GROUP_ROSTER_UPDATE") + groupWatcher:RegisterEvent("PLAYER_ENTERING_WORLD") + local wasInRaid = IsInRaid() + groupWatcher:SetScript("OnEvent", function() + local nowRaid = IsInRaid() + if nowRaid ~= wasInRaid then + wasInRaid = nowRaid + RefreshAll() + end + end) +end + +bossEventFrame:SetScript("OnEvent", function(_, event) + if event == "PLAYER_REGEN_ENABLED" and _pendingLayout then + _pendingLayout = false + EnsureCreated() + RefreshAll() + return + end + EnsureCreated() + RefreshAll() + if event == "PLAYER_LOGIN" then + InstallGlobalHooks() + end +end) + +-- ============================================================ +-- MOVER (simple — unlock by dragging) +-- ============================================================ + +-- Overlay drag frame: sits on top of the secure buttons and steals +-- mouse input while the mover is unlocked. Drags the container. +local function EnsureDragOverlay() + local c = DF.BossContainer + if not c then return end + if c._dragOverlay then return c._dragOverlay end + + local o = CreateFrame("Frame", nil, c) + o:SetFrameStrata("DIALOG") + o:SetAllPoints(c) + o:EnableMouse(true) + o:RegisterForDrag("LeftButton") + + local tex = o:CreateTexture(nil, "OVERLAY") + tex:SetAllPoints(o) + tex:SetColorTexture(0, 1, 0, 0.18) + o.tex = tex + + local label = o:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") + label:SetPoint("CENTER", o, "CENTER", 0, 0) + label:SetText("DF Boss Frames — drag to move") + label:SetTextColor(1, 1, 1) + o.label = label + + o:SetScript("OnDragStart", function() c:StartMoving() end) + o:SetScript("OnDragStop", function() + c:StopMovingOrSizing() + local db = DF:GetRenderBossDB() + local point, _, _, x, y = c:GetPoint() + db.anchor = point + db.anchorX = x + db.anchorY = y + -- Re-anchor cleanly to UIParent to avoid relative-to-self drift + c:ClearAllPoints() + c:SetPoint(point, UIParent, point, x, y) + -- Refresh the currently open options page so the sliders/dropdowns + -- reflect the new drag-updated values. + if DF.GUI and DF.GUI.RefreshCurrentPage then DF.GUI.RefreshCurrentPage() end + end) + + o:Hide() + c._dragOverlay = o + return o +end + +function DF:ToggleBossMover() + EnsureCreated() + local c = DF.BossContainer + if not c then return end + + -- Detect whether any frame is currently shown BEFORE we toggle, so we + -- only auto-enable test when unlocking onto empty frames. + local anyVisible = false + for i = 1, MAX_BOSS do + if DF.BossFrames[i] and DF.BossFrames[i]:IsShown() then anyVisible = true; break end + end + + local overlay = EnsureDragOverlay() + + if c._movingEnabled then + overlay:Hide() + c:SetMovable(true) + c._movingEnabled = false + -- If we auto-activated test mode on unlock, turn it back off on lock. + if c._autoTestActivated then + c._autoTestActivated = false + DF:SetBossTestMode(0) + end + print("|cffeda55fDandersFrames:|r boss mover |cffff6666locked|r") + else + c:SetMovable(true) + overlay:Show() + c._movingEnabled = true + if not anyVisible then + DF:SetBossTestMode(3) + c._autoTestActivated = true + print("|cffeda55fDandersFrames:|r (auto-activated test mode so you can see the frames — disabled on lock)") + end + print("|cffeda55fDandersFrames:|r boss mover |cff66ff66unlocked|r — drag the green overlay to move") + end +end + +-- ============================================================ +-- TEST MODE (Étape 1 version — inline until full TestMode module) +-- ============================================================ + +-- ============================================================ +-- TEST MODE ANIMATION TICKER +-- Slowly drains HP, fluctuates power, and triggers fake casts. +-- ============================================================ + +local testTicker = CreateFrame("Frame") +testTicker:Hide() +local _testNextCast = {} +local _testLastUpdate = 0 + +testTicker:SetScript("OnUpdate", function(self, elapsed) + _testLastUpdate = _testLastUpdate + elapsed + if _testLastUpdate < 0.1 then return end + _testLastUpdate = 0 + + local now = GetTime() + local anyActive = false + for i = 1, MAX_BOSS do + local f = DF.BossFrames[i] + if f and f._testMode then + anyActive = true + -- HP drain + regen (bouncing) + f._testHpDir = f._testHpDir or -1 + f._testHp = (f._testHp or 80) + f._testHpDir * (0.3 + i * 0.1) + if f._testHp <= 10 then f._testHpDir = 1 + elseif f._testHp >= 100 then f._testHpDir = -1 end + + -- Fake power + f._testPower = ((f._testPower or 50) + (math.random() * 4 - 2)) % 100 + + -- Trigger a fake cast periodically + if not _testNextCast[i] or now >= _testNextCast[i] then + if DF.SimulateBossCast then + DF.SimulateBossCast(f, math.random() < 0.25) + end + _testNextCast[i] = now + 3 + math.random() * 4 + end + + UpdateFrame(f) + end + end + + if not anyActive then self:Hide() end +end) + +local _lastTestCount +function DF:SetBossTestMode(count) + EnsureCreated() + count = tonumber(count) or 0 + if count < 0 then count = 0 end + if count > MAX_BOSS then count = MAX_BOSS end + -- Skip entirely if nothing changed (slider drag spams this). + if count == _lastTestCount then return end + _lastTestCount = count + + local testNames = { "Archavon", "Onyxia", "Ragnaros", "Nefarian", "Deathwing" } + local testHp = { 95, 72, 48, 30, 12 } + + for i = 1, MAX_BOSS do + local f = DF.BossFrames[i] + if f then + if i <= count then + f._testMode = true + f._testName = testNames[i] + f._testHp = testHp[i] + f._testHpDir = -1 + f._testPower = 50 + -- Override visibility driver to always-show in test + if not InCombatLockdown() then + RegisterStateDriver(f, "visibility", "show") + end + UpdateFrame(f) + else + f._testMode = false + f._testName = nil + f._testHp = nil + f._testHpDir = nil + f._testPower = nil + f._testModelSet = nil + f._testAuras = nil + _testNextCast[i] = nil + if f.castBar then f.castBar:Hide() end + -- Restore normal visibility driver + if not InCombatLockdown() then + RegisterStateDriver(f, "visibility", "[@" .. f.unit .. ",exists]show;hide") + end + end + end + end + + if count > 0 then + testTicker:Show() + print(format("|cffeda55fDandersFrames:|r boss test mode — simulating %d boss%s (HP drain + fake casts)", count, count == 1 and "" or "es")) + else + testTicker:Hide() + print("|cffeda55fDandersFrames:|r boss test mode |cffff6666off|r") + end +end + +-- ============================================================ +-- SLASH COMMANDS (standalone for Étape 1) +-- /dfbf → toggle mover +-- /dfbf test N → simulate N bosses (0-5) +-- /dfbf refresh → force refresh +-- ============================================================ + +SLASH_DFBF1 = "/dfbf" +SlashCmdList["DFBF"] = function(msg) + msg = (msg or ""):lower():gsub("^%s+", ""):gsub("%s+$", "") + if msg == "" then + DF:ToggleBossMover() + return + end + local cmd, arg = msg:match("^(%S+)%s*(.*)$") + if cmd == "test" then + DF:SetBossTestMode(tonumber(arg) or 5) + elseif cmd == "refresh" then + RefreshAll() + print("|cffeda55fDandersFrames:|r boss frames refreshed") + elseif cmd == "config" or cmd == "options" then + if DF.ToggleBossOptions then DF:ToggleBossOptions() end + elseif cmd == "help" then + print("|cffeda55fDandersFrames boss commands:|r") + print(" /dfbf - toggle mover lock") + print(" /dfbf config - open options panel") + print(" /dfbf test N - simulate N bosses (0-5)") + print(" /dfbf refresh - force refresh") + else + print("|cffeda55fDandersFrames:|r unknown command. Try /dfbf help") + end +end diff --git a/Frames/BossAuras.lua b/Frames/BossAuras.lua new file mode 100644 index 00000000..3921fe90 --- /dev/null +++ b/Frames/BossAuras.lua @@ -0,0 +1,377 @@ +local addonName, DF = ... + +-- ============================================================ +-- BOSS FRAME AURAS (buffs / debuffs) +-- Displays up to N auras on each boss frame, like Blizzard's +-- default boss frames but configurable (size, position, growth +-- direction, harmful vs helpful, stacks & timer). +-- +-- Uses C_UnitAuras.GetAuraDataByIndex (modern Retail API) with +-- a fallback to UnitAura for older clients. +-- ============================================================ + +local pairs, ipairs = pairs, ipairs +local CreateFrame = CreateFrame +local GetTime = GetTime +local format = string.format + +local MAX_BOSS = 5 + +-- ============================================================ +-- AURA BUTTON CREATION (pooled per boss frame) +-- ============================================================ + +local function CreateAuraButton(parent, index) + local b = CreateFrame("Frame", nil, parent) + b:SetSize(24, 24) + b:Hide() + + local icon = b:CreateTexture(nil, "ARTWORK") + icon:SetAllPoints(b) + icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + b.icon = icon + + local border = b:CreateTexture(nil, "OVERLAY", nil, 1) + border:SetPoint("TOPLEFT", b, "TOPLEFT", -1, 1) + border:SetPoint("BOTTOMRIGHT", b, "BOTTOMRIGHT", 1, -1) + border:SetColorTexture(0, 0, 0, 0.9) + border:SetDrawLayer("BACKGROUND") + b.border = border + + -- Cooldown swipe only — numbers drawn by our own fontstring on top. + local cd = CreateFrame("Cooldown", nil, b, "CooldownFrameTemplate") + cd:SetAllPoints(b) + cd:SetHideCountdownNumbers(true) + cd:SetDrawEdge(false) + cd:SetDrawSwipe(true) + b.cd = cd + + -- Stacks & timer are parented to the cooldown frame so they render ABOVE + -- the cooldown swipe (otherwise the swipe grey-out masks them). Use the + -- addon's DF font with outline for readability against busy icons. + local stackFont = _G["DFFontHighlightSmallOutline"] or _G["DFFontHighlightSmall"] or _G["NumberFontNormalSmall"] + local timerFont = stackFont + + local stacks = cd:CreateFontString(nil, "OVERLAY", nil) + stacks:SetFontObject(stackFont) + stacks:SetTextColor(1, 1, 1) + stacks:SetDrawLayer("OVERLAY", 7) + b.stacks = stacks + + local timer = cd:CreateFontString(nil, "OVERLAY", nil) + timer:SetFontObject(timerFont) + timer:SetTextColor(1, 0.85, 0.1) + timer:SetDrawLayer("OVERLAY", 7) + b.timer = timer + + b.index = index + return b +end + +local function EnsureAuraPool(frame, count) + frame._auras = frame._auras or {} + for i = 1, count do + if not frame._auras[i] then + frame._auras[i] = CreateAuraButton(frame, i) + end + end + -- Hide any beyond the desired count + for i = count + 1, #frame._auras do + frame._auras[i]:Hide() + end +end + +-- ============================================================ +-- TIMER FORMATTING +-- ============================================================ + +local function FormatTime(seconds) + if seconds <= 0 then return "" end + if seconds < 10 then return format("%.1f", seconds) end + if seconds < 60 then return format("%d", seconds) end + if seconds < 3600 then return format("%dm", seconds / 60) end + return format("%dh", seconds / 3600) +end + +-- ============================================================ +-- AURA COLLECTION (UNIT → list of aura data) +-- ============================================================ + +local auraBuffer = {} + +local function auraMatchesSource(data, source) + if source == "ALL" or not source then return true end + + -- sourceUnit may be a secret string in WoW 12.0+; comparisons and + -- string methods on it throw. Prefer the non-secret flags on the + -- aura data and only touch sourceUnit inside pcall. + local mine = false + do + local ok, v = pcall(function() + if data.isFromPlayerOrPlayerPet ~= nil then + return data.isFromPlayerOrPlayerPet == true + end + local s = data.sourceUnit + return s == "player" or s == "pet" or s == "vehicle" + end) + if ok then mine = v or false end + end + + if source == "MINE" then return mine end + if source == "NOT_MINE" then return not mine end + if source == "BOSS_ONLY" then + local okBoss, isBoss = pcall(function() return data.isBossAura == true end) + if okBoss and isBoss then return true end + local okSrc, v = pcall(function() + local s = data.sourceUnit + return s and s:match("^boss") and true or false + end) + return okSrc and v or false + end + return true +end + +local function CollectAuras(unit, filter, source, maxCount) + wipe(auraBuffer) + if C_UnitAuras and C_UnitAuras.GetAuraDataByIndex then + for i = 1, 40 do + local data = C_UnitAuras.GetAuraDataByIndex(unit, i, filter) + if not data then break end + if auraMatchesSource(data, source) then + auraBuffer[#auraBuffer + 1] = data + if #auraBuffer >= maxCount then break end + end + end + else + for i = 1, 40 do + local name, icon, count, _, duration, expiration, caster = UnitAura(unit, i, filter) + if not name then break end + local data = { + name = name, icon = icon, applications = count or 0, + duration = duration or 0, expirationTime = expiration or 0, + sourceUnit = caster, + } + if auraMatchesSource(data, source) then + auraBuffer[#auraBuffer + 1] = data + if #auraBuffer >= maxCount then break end + end + end + end + return auraBuffer +end + +-- ============================================================ +-- LAYOUT & UPDATE (called from Boss.lua refresh & OnUpdate ticker) +-- ============================================================ + +function DF.LayoutBossAuras(frame, db) + if not db.showAuras then + if frame._auras then + for _, b in ipairs(frame._auras) do b:Hide() end + end + return + end + + local maxCount = math.min(math.max(db.aurasMaxCount or 3, 1), 8) + EnsureAuraPool(frame, maxCount) + + local size = db.aurasSize or 24 + local spacing = db.aurasSpacing or 2 + local anchor = db.aurasAnchor or "TOPLEFT" + local growX = db.aurasGrowX or "RIGHT" + local growY = db.aurasGrowY or "DOWN" + local ox, oy = db.aurasX or 0, db.aurasY or 0 + + local dx = (growX == "LEFT") and -(size + spacing) or (size + spacing) + local dy = (growY == "UP") and (size + spacing) or -(size + spacing) + + for i = 1, maxCount do + local b = frame._auras[i] + b:SetSize(size, size) + b:ClearAllPoints() + if i == 1 then + b:SetPoint(anchor, frame, anchor, ox, oy) + else + b:SetPoint(anchor, frame._auras[i - 1], anchor, dx, 0) + end + + -- Stack text anchor + if b.stacks then + b.stacks:ClearAllPoints() + local sa = db.aurasStackAnchor or "BOTTOMRIGHT" + b.stacks:SetPoint(sa, b, sa, db.aurasStackX or -1, db.aurasStackY or 1) + end + + -- Timer text placement + if b.timer then + b.timer:ClearAllPoints() + local place = db.aurasTimerPlacement or "INSIDE" + local tx = db.aurasTimerX or 0 + local ty = db.aurasTimerY or 0 + if place == "BELOW" then + b.timer:SetPoint("TOP", b, "BOTTOM", tx, ty - 1) + elseif place == "ABOVE" then + b.timer:SetPoint("BOTTOM", b, "TOP", tx, ty + 1) + else -- INSIDE + b.timer:SetPoint("CENTER", b, "CENTER", tx, ty) + end + end + end +end + +function DF.UpdateBossAuras(frame) + local db = DF:GetRenderBossDB() + if not db.showAuras or not frame._auras then + if frame._auras then for _, b in ipairs(frame._auras) do b:Hide() end end + return + end + + local maxCount = math.min(math.max(db.aurasMaxCount or 3, 1), 8) + + -- Test mode: fake auras with cached expirations so timers visibly count down + if frame._testMode then + local now = GetTime() + -- (Re)generate cached test auras if missing or all expired + if not frame._testAuras or not frame._testAuras[1] + or frame._testAuras[1].expirationTime < now then + local durations = { 10, 15, 8, 20, 30 } + local icons = { 135812, 136197, 135844, 136048, 136042 } + frame._testAuras = {} + frame._testStackStart = now + for i = 1, 5 do + frame._testAuras[i] = { + icon = icons[i], + duration = durations[i], + expirationTime = now + durations[i], + -- Stacks grow from 1 to 40 over the duration of the aura. + stackGrowSpeed = 40 / durations[i], -- stacks per second + } + end + end + + for i = 1, maxCount do + local b = frame._auras[i] + local a = frame._testAuras[i] + if a then + b.icon:SetTexture(a.icon) + + -- Animated stack count (1 → 40 across the aura's duration) + local elapsed = a.duration - (a.expirationTime - now) + local stacks = math.floor(1 + elapsed * (a.stackGrowSpeed or 0)) + if stacks < 1 then stacks = 1 end + if stacks > 40 then stacks = 40 end + + if db.aurasShowStacks and stacks > 1 then + b.stacks:SetText(stacks) + else + b.stacks:SetText("") + end + + if db.aurasShowTimer and a.expirationTime and a.expirationTime > 0 then + b.timer:SetText(FormatTime(a.expirationTime - now)) + if b.cd and a.duration > 0 then + b.cd:SetCooldown(a.expirationTime - a.duration, a.duration) + end + else + b.timer:SetText("") + end + b:Show() + else + b:Hide() + end + end + return + end + + local unit = frame.unit + if not UnitExists(unit) then + for _, b in ipairs(frame._auras) do b:Hide() end + return + end + + local auras = CollectAuras(unit, db.aurasFilter or "HARMFUL", db.aurasSource or "ALL", maxCount) + for i = 1, maxCount do + local b = frame._auras[i] + local a = auras[i] + if a then + b.icon:SetTexture(a.icon) + if db.aurasShowStacks then + -- applications may be a secret number; guard the compare. + local ok, showIt = pcall(function() return a.applications and a.applications > 1 end) + if ok and showIt then + pcall(b.stacks.SetText, b.stacks, a.applications) + else + b.stacks:SetText("") + end + else + b.stacks:SetText("") + end + if db.aurasShowTimer then + -- expirationTime / duration may be secret in WoW 12.0+. + -- Keep the cooldown ring (SetCooldown handles secrets) but + -- skip our own arithmetic text if it throws. + local okText, remaining = pcall(function() + if a.expirationTime and a.expirationTime > 0 then + return a.expirationTime - GetTime() + end + end) + if okText and remaining then + b.timer:SetText(FormatTime(remaining)) + else + b.timer:SetText("") + end + if b.cd and a.duration and a.expirationTime then + -- Wrap in a closure so the arithmetic runs inside pcall. + pcall(function() + b.cd:SetCooldown(a.expirationTime - a.duration, a.duration) + end) + end + else + b.timer:SetText("") + if b.cd then b.cd:Clear() end + end + b:Show() + else + b:Hide() + end + end +end + +-- ============================================================ +-- TIMER TICKER (refreshes expiration text every 0.2s) +-- ============================================================ + +local timerTicker = CreateFrame("Frame") +local _elapsed = 0 +timerTicker:SetScript("OnUpdate", function(_, e) + _elapsed = _elapsed + e + if _elapsed < 0.2 then return end + _elapsed = 0 + local db = DF:GetRenderBossDB() + if not db.showAuras or not db.aurasShowTimer then return end + local now = GetTime() + for i = 1, MAX_BOSS do + local f = DF.BossFrames and DF.BossFrames[i] + if f and f._auras then + for _, b in ipairs(f._auras) do + if b:IsShown() and b.timer then + -- Recompute from stored expirationTime only if we have one + -- (test mode uses hardcoded values; live mode refreshes from API next UNIT_AURA) + end + end + end + end +end) + +-- ============================================================ +-- EVENTS +-- ============================================================ + +local eventFrame = CreateFrame("Frame") +eventFrame:RegisterEvent("UNIT_AURA") +eventFrame:SetScript("OnEvent", function(_, event, unit) + if not unit or not unit:match("^boss%d$") then return end + local idx = tonumber(unit:sub(5)) + local frame = DF.BossFrames and DF.BossFrames[idx] + if frame then DF.UpdateBossAuras(frame) end +end) diff --git a/Frames/BossCastBar.lua b/Frames/BossCastBar.lua new file mode 100644 index 00000000..d581b20e --- /dev/null +++ b/Frames/BossCastBar.lua @@ -0,0 +1,363 @@ +local addonName, DF = ... + +-- ============================================================ +-- BOSS CAST BAR (Étape 2) +-- Attaches a cast bar to each boss frame and handles +-- UNIT_SPELLCAST_* events. Supports normal casts, channels, +-- interrupts, fail/success flashes. +-- ============================================================ + +local pairs, ipairs = pairs, ipairs +local GetTime = GetTime +local UnitCastingInfo = UnitCastingInfo +local UnitChannelInfo = UnitChannelInfo +local CreateFrame = CreateFrame + +local MAX_BOSS = 5 + +-- ============================================================ +-- CAST BAR CREATION (called lazily once the boss frame exists) +-- ============================================================ + +local function CreateCastBar(frame) + if frame.castBar then return frame.castBar end + + local cb = CreateFrame("StatusBar", nil, frame) + cb:SetStatusBarTexture("Interface\\AddOns\\DandersFrames\\Media\\DF_Smooth") + cb:SetMinMaxValues(0, 1) + cb:SetValue(0) + cb:Hide() + + local bg = cb:CreateTexture(nil, "BACKGROUND") + bg:SetAllPoints(cb) + bg:SetColorTexture(0, 0, 0, 0.7) + cb.bg = bg + + -- Spark: anchored to the RIGHT edge of the StatusBar texture so it + -- automatically follows the fill — works even with secret min/max. + local spark = cb:CreateTexture(nil, "OVERLAY") + spark:SetSize(8, 20) + spark:SetBlendMode("ADD") + spark:SetTexture("Interface\\CastingBar\\UI-CastingBar-Spark") + spark:SetPoint("CENTER", cb:GetStatusBarTexture(), "RIGHT", 0, 0) + cb.spark = spark + + -- Icon sits inside the bar on the left (Blizzard style) + local icon = cb:CreateTexture(nil, "OVERLAY") + icon:SetPoint("LEFT", cb, "LEFT", 0, 0) + icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + cb.icon = icon + + -- Thin border around the icon + local iconBorder = cb:CreateTexture(nil, "OVERLAY", nil, 1) + iconBorder:SetPoint("TOPLEFT", icon, "TOPLEFT", -1, 1) + iconBorder:SetPoint("BOTTOMRIGHT", icon, "BOTTOMRIGHT", 1, -1) + iconBorder:SetColorTexture(0, 0, 0, 0.8) + iconBorder:SetDrawLayer("BACKGROUND") + cb.iconBorder = iconBorder + + -- Spell text anchors after the icon + local spellText = cb:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + spellText:SetPoint("LEFT", icon, "RIGHT", 4, 0) + spellText:SetTextColor(1, 1, 1) + cb.spellText = spellText + + local timeText = cb:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall") + timeText:SetPoint("RIGHT", cb, "RIGHT", -4, 0) + timeText:SetTextColor(1, 1, 1) + cb.timeText = timeText + + frame.castBar = cb + return cb +end + +-- ============================================================ +-- LAYOUT (called from Boss.lua's ApplyLayout via DF.LayoutBossCastBar) +-- ============================================================ + +function DF.LayoutBossCastBar(frame, db) + if not db.showCastBar then + if frame.castBar then frame.castBar:Hide() end + return + end + + local cb = CreateCastBar(frame) + cb:ClearAllPoints() + + -- Apply texture from LSM + background alpha + if DF.ResolveBossTexture then + cb:SetStatusBarTexture(DF.ResolveBossTexture(db.castTexture)) + end + if cb.bg then + cb.bg:SetColorTexture(0, 0, 0, db.castBackgroundAlpha or 0.7) + end + + -- Size icon to match cast bar height (square) + local barH = db.castBarHeight or 14 + if cb.icon then + cb.icon:SetSize(barH, barH) + cb.icon:ClearAllPoints() + if db.castBarIconPosition == "RIGHT" then + cb.icon:SetPoint("RIGHT", cb, "RIGHT", 0, 0) + cb.spellText:ClearAllPoints() + cb.spellText:SetPoint("LEFT", cb, "LEFT", 4, 0) + cb.timeText:ClearAllPoints() + cb.timeText:SetPoint("RIGHT", cb.icon, "LEFT", -4, 0) + else + cb.icon:SetPoint("LEFT", cb, "LEFT", 0, 0) + cb.spellText:ClearAllPoints() + cb.spellText:SetPoint("LEFT", cb.icon, "RIGHT", 4, 0) + cb.timeText:ClearAllPoints() + cb.timeText:SetPoint("RIGHT", cb, "RIGHT", -4, 0) + end + end + + if db.castBarDetached then + -- Detached: anchor to the boss frame itself (like name/HP text). + -- The bar's opposite anchor attaches to the chosen point on the frame, + -- then offset X/Y nudges it. + cb:SetParent(frame) + local anchor = db.castBarDetachedAnchor or "BOTTOM" + -- Pick a sensible own-anchor so the bar hangs naturally from the point + local ownAnchor = + anchor == "TOP" and "BOTTOM" or + anchor == "BOTTOM" and "TOP" or + anchor == "TOPLEFT" and "BOTTOMLEFT" or + anchor == "TOPRIGHT" and "BOTTOMRIGHT" or + anchor == "BOTTOMLEFT" and "TOPLEFT" or + anchor == "BOTTOMRIGHT" and "TOPRIGHT" or + anchor == "LEFT" and "RIGHT" or + anchor == "RIGHT" and "LEFT" or + "CENTER" + cb:SetPoint(ownAnchor, frame, anchor, db.castBarDetachedX or 0, db.castBarDetachedY or 0) + local w = db.castBarDetachedWidth or 0 + if w <= 0 then w = db.frameWidth or 220 end + cb:SetWidth(w) + cb:SetHeight(barH) + + -- Restore health bar to full frame height (no cast bar integrated) + if frame.healthBar then + local powerH = db.showPowerBar and (db.powerBarHeight + 1) or 0 + frame.healthBar:ClearAllPoints() + frame.healthBar:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + frame.healthBar:SetPoint("TOPRIGHT", frame, "TOPRIGHT", 0, 0) + frame.healthBar:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, powerH) + frame.healthBar:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, powerH) + end + else + -- Integrated: cast bar at the very BOTTOM of the frame; the power + -- bar sits above it, HP bar above that (ApplyLayout in Boss.lua + -- already reserves castH pixels at the bottom for us). + cb:SetParent(frame) + cb:ClearAllPoints() + cb:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) + cb:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", 0, 0) + cb:SetHeight(barH) + end +end + +-- ============================================================ +-- CAST STATE UPDATES +-- ============================================================ + +local function ClearCast(frame) + local cb = frame.castBar + if not cb then return end + cb._casting = false + cb._channeling = false + cb:Hide() +end + +local issecretvalue = _G.issecretvalue or function() return false end + +local function StartCast(frame, channeling) + local cb = frame.castBar or CreateCastBar(frame) + local unit = frame.unit + local name, text, texture, startMs, endMs, _, _, notInterruptible + if channeling then + name, text, texture, startMs, endMs, _, notInterruptible = UnitChannelInfo(unit) + else + name, text, texture, startMs, endMs, _, _, notInterruptible = UnitCastingInfo(unit) + end + if not name or not startMs or not endMs then + ClearCast(frame) + return + end + + -- WoW 12.0+: UnitCastingInfo may return secret values. + -- Pass them straight to StatusBar (which accepts secrets) and drive the + -- ticker with GetTime()*1000 — same units, non-secret, StatusBar clamps + -- internally so it still animates correctly. + local secret = issecretvalue(startMs) or issecretvalue(endMs) + cb._secret = secret + cb._startTimeMs = startMs + cb._endTimeMs = endMs + pcall(cb.SetMinMaxValues, cb, startMs, endMs) + pcall(cb.SetValue, cb, channeling and endMs or startMs) + if not secret then + -- Cache /1000 for the (rare) non-secret path so we can show countdown + cb._startTime = startMs / 1000 + cb._endTime = endMs / 1000 + else + cb._startTime = nil + cb._endTime = nil + end + + cb._channeling = channeling and true or false + cb._casting = not channeling + cb.spellText:SetText(text or name or "") + if texture then cb.icon:SetTexture(texture); cb.icon:Show() else cb.icon:Hide() end + + -- notInterruptible may be a secret boolean in WoW 12.0+ + local isProtected + do + local ok, v = pcall(function() return notInterruptible == true end) + if ok then isProtected = v end + end + if isProtected then + cb:SetStatusBarColor(0.7, 0.7, 0.7) + elseif channeling then + cb:SetStatusBarColor(0.3, 0.9, 0.3) + else + cb:SetStatusBarColor(1, 0.7, 0) + end + cb:Show() +end + +local function FlashResult(frame, color) + local cb = frame.castBar + if not cb then return end + cb:SetMinMaxValues(0, 1) + cb:SetValue(1) + cb:SetStatusBarColor(color[1], color[2], color[3]) + cb.spark:Hide() + cb._casting = false + cb._channeling = false + cb._fadeOut = GetTime() + 0.6 + cb:Show() +end + +-- ============================================================ +-- OnUpdate TICKER (shared across all 5 cast bars) +-- ============================================================ + +local tickerFrame = CreateFrame("Frame") +tickerFrame:SetScript("OnUpdate", function() + local now = GetTime() + for i = 1, MAX_BOSS do + local f = DF.BossFrames and DF.BossFrames[i] + local cb = f and f.castBar + if cb and cb:IsShown() then + if cb._fadeOut then + local left = cb._fadeOut - now + if left <= 0 then + cb:Hide() + cb._fadeOut = nil + else + cb:SetAlpha(left / 0.6) + end + elseif cb._casting or cb._channeling then + cb:SetAlpha(1) + if cb._secret then + pcall(cb.SetValue, cb, now * 1000) + -- Try the subtraction in pcall; if endMs is truly secret + -- it throws and we show nothing. If it's not, we get a + -- real countdown even in secret-path. + local ok, remaining = pcall(function() + return (cb._endTimeMs - now * 1000) / 1000 + end) + if ok and remaining and remaining > 0 then + cb.timeText:SetFormattedText("%.1f", remaining) + else + cb.timeText:SetText("") + end + elseif cb._endTime and now >= cb._endTime then + ClearCast(f) + elseif cb._endTime then + cb:SetValue(now) + cb.timeText:SetFormattedText("%.1f", cb._endTime - now) + end + end + end + end +end) + +-- ============================================================ +-- EVENTS (single handler, routes to the correct frame) +-- ============================================================ + +local eventFrame = CreateFrame("Frame") +eventFrame:RegisterEvent("UNIT_SPELLCAST_START") +eventFrame:RegisterEvent("UNIT_SPELLCAST_STOP") +eventFrame:RegisterEvent("UNIT_SPELLCAST_FAILED") +eventFrame:RegisterEvent("UNIT_SPELLCAST_INTERRUPTED") +eventFrame:RegisterEvent("UNIT_SPELLCAST_SUCCEEDED") +eventFrame:RegisterEvent("UNIT_SPELLCAST_CHANNEL_START") +eventFrame:RegisterEvent("UNIT_SPELLCAST_CHANNEL_STOP") +eventFrame:RegisterEvent("UNIT_SPELLCAST_CHANNEL_UPDATE") +eventFrame:RegisterEvent("UNIT_SPELLCAST_INTERRUPTIBLE") +eventFrame:RegisterEvent("UNIT_SPELLCAST_NOT_INTERRUPTIBLE") + +eventFrame:SetScript("OnEvent", function(_, event, unit) + if not unit or not unit:match("^boss%d$") then return end + local idx = tonumber(unit:sub(5)) + local frame = DF.BossFrames and DF.BossFrames[idx] + if not frame then return end + + if event == "UNIT_SPELLCAST_START" then + StartCast(frame, false) + elseif event == "UNIT_SPELLCAST_CHANNEL_START" or event == "UNIT_SPELLCAST_CHANNEL_UPDATE" then + StartCast(frame, true) + elseif event == "UNIT_SPELLCAST_STOP" or event == "UNIT_SPELLCAST_CHANNEL_STOP" then + ClearCast(frame) + elseif event == "UNIT_SPELLCAST_FAILED" then + FlashResult(frame, {0.8, 0.3, 0.3}) + elseif event == "UNIT_SPELLCAST_INTERRUPTED" then + FlashResult(frame, {1, 0.1, 0.1}) + elseif event == "UNIT_SPELLCAST_SUCCEEDED" then + -- Only flash if a cast was active (ignore instant spells) + local cb = frame.castBar + if cb and (cb._casting or cb._channeling) then + ClearCast(frame) + end + elseif event == "UNIT_SPELLCAST_INTERRUPTIBLE" then + local cb = frame.castBar + if cb and cb:IsShown() then cb:SetStatusBarColor(1, 0.7, 0) end + elseif event == "UNIT_SPELLCAST_NOT_INTERRUPTIBLE" then + local cb = frame.castBar + if cb and cb:IsShown() then cb:SetStatusBarColor(0.7, 0.7, 0.7) end + end +end) + +-- ============================================================ +-- TEST MODE CAST SIMULATION +-- Called by Boss.lua's test ticker. +-- ============================================================ + +local SIMULATED_SPELLS = { + { name = "Fireball", texture = 135812, duration = 2.5 }, + { name = "Shadow Bolt", texture = 136197, duration = 3.0 }, + { name = "Frost Lance", texture = 135844, duration = 1.5 }, + { name = "Chain Heal", texture = 136042, duration = 2.2 }, + { name = "Lightning Bolt", texture = 136048, duration = 1.8 }, +} + +function DF.SimulateBossCast(frame, channeling) + local cb = CreateCastBar(frame) + local db = DF:GetRenderBossDB() + if not db.showCastBar then return end + local spell = SIMULATED_SPELLS[math.random(#SIMULATED_SPELLS)] + local now = GetTime() + cb._startTime = now + cb._endTime = now + spell.duration + cb._channeling = channeling and true or false + cb._casting = not channeling + cb:SetMinMaxValues(now, cb._endTime) + cb:SetValue(channeling and cb._endTime or now) + cb.spellText:SetText(spell.name) + cb.icon:SetTexture(spell.texture) + cb.icon:Show() + cb:SetStatusBarColor(channeling and 0.3 or 1, channeling and 0.9 or 0.7, channeling and 0.3 or 0) + cb:SetAlpha(1) + cb:Show() +end diff --git a/Frames/BossOptions.lua b/Frames/BossOptions.lua new file mode 100644 index 00000000..9257aed3 --- /dev/null +++ b/Frames/BossOptions.lua @@ -0,0 +1,266 @@ +local addonName, DF = ... + +-- ============================================================ +-- BOSS FRAMES — STANDALONE OPTIONS PANEL +-- Opened via /dfbf config. Lightweight UI using native frames +-- (not the main Options.lua system, which is 8k lines). +-- Saves directly into DF:GetBossDB() and calls DF:RefreshBossFrames. +-- ============================================================ + +local CreateFrame = CreateFrame +local pairs, ipairs = pairs, ipairs + +local panel + +local function apply() + if DF.RefreshBossFrames then DF:RefreshBossFrames() end +end + +-- ============================================================ +-- Generic slider factory +-- ============================================================ +local function makeSlider(parent, label, key, minV, maxV, step, y) + local sl = CreateFrame("Slider", "DFBossOpt_"..key, parent, "OptionsSliderTemplate") + sl:SetPoint("TOPLEFT", parent, "TOPLEFT", 20, y) + sl:SetWidth(280) + sl:SetMinMaxValues(minV, maxV) + sl:SetValueStep(step) + sl:SetObeyStepOnDrag(true) + _G[sl:GetName().."Low"]:SetText(tostring(minV)) + _G[sl:GetName().."High"]:SetText(tostring(maxV)) + _G[sl:GetName().."Text"]:SetText(label) + + local edit = CreateFrame("EditBox", nil, sl, "InputBoxTemplate") + edit:SetSize(50, 18) + edit:SetPoint("LEFT", sl, "RIGHT", 12, 0) + edit:SetAutoFocus(false) + edit:SetNumeric(false) -- allow decimals + edit:SetFontObject("GameFontHighlightSmall") + sl.edit = edit + + sl:SetScript("OnValueChanged", function(self, val) + if step < 1 then val = math.floor(val * 100 + 0.5) / 100 else val = math.floor(val + 0.5) end + local db = DF:GetBossDB() + db[key] = val + edit:SetText(tostring(val)) + apply() + end) + edit:SetScript("OnEnterPressed", function(self) + local v = tonumber(self:GetText()) + if v then sl:SetValue(v) end + self:ClearFocus() + end) + return sl +end + +-- ============================================================ +-- Generic checkbox factory +-- ============================================================ +local function makeCheckbox(parent, label, key, x, y) + local cb = CreateFrame("CheckButton", nil, parent, "InterfaceOptionsCheckButtonTemplate") + cb:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + cb.Text:SetText(label) + cb:SetScript("OnClick", function(self) + DF:GetBossDB()[key] = self:GetChecked() and true or false + apply() + end) + return cb +end + +-- ============================================================ +-- Generic dropdown factory (using UIDropDownMenu) +-- ============================================================ +local function makeDropdown(parent, label, key, options, x, y) + local labelFS = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal") + labelFS:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + labelFS:SetText(label) + + local dd = CreateFrame("Frame", "DFBossOpt_DD_"..key, parent, "UIDropDownMenuTemplate") + dd:SetPoint("TOPLEFT", labelFS, "BOTTOMLEFT", -18, -4) + UIDropDownMenu_SetWidth(dd, 140) + + local function setSelected(val, displayText) + DF:GetBossDB()[key] = val + UIDropDownMenu_SetText(dd, displayText or val) + apply() + end + + UIDropDownMenu_Initialize(dd, function(self, level) + for _, opt in ipairs(options) do + local info = UIDropDownMenu_CreateInfo() + info.text = opt.text + info.value = opt.value + info.func = function() setSelected(opt.value, opt.text) end + info.checked = (DF:GetBossDB()[key] == opt.value) + UIDropDownMenu_AddButton(info, level) + end + end) + + dd.refresh = function() + local cur = DF:GetBossDB()[key] + for _, opt in ipairs(options) do + if opt.value == cur then UIDropDownMenu_SetText(dd, opt.text); return end + end + end + return dd +end + +-- ============================================================ +-- PANEL BUILD +-- ============================================================ + +local function buildPanel() + panel = CreateFrame("Frame", "DFBossFramesOptions", UIParent, "BasicFrameTemplateWithInset") + panel:SetSize(400, 640) + panel:SetPoint("CENTER") + panel:SetMovable(true) + panel:EnableMouse(true) + panel:RegisterForDrag("LeftButton") + panel:SetScript("OnDragStart", panel.StartMoving) + panel:SetScript("OnDragStop", panel.StopMovingOrSizing) + panel:SetFrameStrata("HIGH") + panel:Hide() + + panel.TitleText:SetText("DandersFrames — Boss Frames") + + local widgets = {} + + local y = -10 + + -- Unlock mover button + local moverBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + moverBtn:SetSize(180, 22) + moverBtn:SetPoint("TOPLEFT", panel, "TOPLEFT", 20, y) + moverBtn:SetText("Unlock / Lock mover") + moverBtn:SetScript("OnClick", function() DF:ToggleBossMover() end) + + local testBtn = CreateFrame("Button", nil, panel, "UIPanelButtonTemplate") + testBtn:SetSize(180, 22) + testBtn:SetPoint("LEFT", moverBtn, "RIGHT", 4, 0) + testBtn:SetText("Toggle test (3 bosses)") + testBtn:SetScript("OnClick", function() + local any + for i = 1, 5 do if DF.BossFrames[i] and DF.BossFrames[i]._testMode then any = true; break end end + DF:SetBossTestMode(any and 0 or 3) + end) + + y = y - 34 + + widgets[#widgets+1] = makeCheckbox(panel, "Show cast bar", "showCastBar", 20, y) y = y - 28 + widgets[#widgets+1] = makeCheckbox(panel, "Cast bar detached", "castBarDetached",20, y) y = y - 28 + widgets[#widgets+1] = makeCheckbox(panel, "Show power bar", "showPowerBar", 20, y) y = y - 28 + widgets[#widgets+1] = makeCheckbox(panel, "Show name", "showName", 20, y) y = y - 28 + widgets[#widgets+1] = makeCheckbox(panel, "Show health text", "showHealthText", 20, y) y = y - 28 + widgets[#widgets+1] = makeCheckbox(panel, "Hide Blizzard", "hideBlizzard", 20, y) y = y - 34 + + -- Sliders + local s1 = makeSlider(panel, "Frame width", "frameWidth", 100, 400, 1, y); y = y - 42; widgets[#widgets+1] = s1 + local s2 = makeSlider(panel, "Frame height", "frameHeight", 20, 100, 1, y); y = y - 42; widgets[#widgets+1] = s2 + local s3 = makeSlider(panel, "Spacing", "frameSpacing", 0, 40, 1, y); y = y - 42; widgets[#widgets+1] = s3 + local s4 = makeSlider(panel, "Scale", "frameScale", 0.5, 2.0, 0.05, y); y = y - 42; widgets[#widgets+1] = s4 + local s5 = makeSlider(panel, "Portrait size", "portraitSize", 20, 80, 1, y); y = y - 42; widgets[#widgets+1] = s5 + local s6 = makeSlider(panel, "Cast bar height","castBarHeight", 8, 40, 1, y); y = y - 42; widgets[#widgets+1] = s6 + local s7 = makeSlider(panel, "Power bar height","powerBarHeight",2, 20, 1, y); y = y - 42; widgets[#widgets+1] = s7 + + -- Dropdowns in a right column + local ddY = -44 + local dd1 = makeDropdown(panel, "Portrait position", "portraitPosition", { + { text = "Left", value = "LEFT" }, + { text = "Right", value = "RIGHT" }, + { text = "Hidden", value = "HIDDEN" }, + }, 210, ddY); ddY = ddY - 52; widgets[#widgets+1] = dd1 + + local dd2 = makeDropdown(panel, "Grow direction", "growDirection", { + { text = "Down", value = "DOWN" }, + { text = "Up", value = "UP" }, + }, 210, ddY); ddY = ddY - 52; widgets[#widgets+1] = dd2 + + local dd3 = makeDropdown(panel, "Anchor", "anchor", { + { text = "Right", value = "RIGHT" }, + { text = "Left", value = "LEFT" }, + { text = "Top", value = "TOP" }, + { text = "Bottom", value = "BOTTOM" }, + { text = "Top right", value = "TOPRIGHT" }, + { text = "Top left", value = "TOPLEFT" }, + { text = "Bottom right", value = "BOTTOMRIGHT" }, + { text = "Bottom left", value = "BOTTOMLEFT" }, + { text = "Center", value = "CENTER" }, + }, 210, ddY); ddY = ddY - 52; widgets[#widgets+1] = dd3 + + local dd4 = makeDropdown(panel, "Health text align", "healthTextAnchor", { + { text = "Left", value = "LEFT" }, + { text = "Center", value = "CENTER" }, + { text = "Right", value = "RIGHT" }, + }, 210, ddY); ddY = ddY - 52; widgets[#widgets+1] = dd4 + + local dd5 = makeDropdown(panel, "Name align", "nameAnchor", { + { text = "Left", value = "LEFT" }, + { text = "Center", value = "CENTER" }, + { text = "Right", value = "RIGHT" }, + }, 210, ddY); ddY = ddY - 52; widgets[#widgets+1] = dd5 + + local ddCastIcon = makeDropdown(panel, "Cast bar icon", "castBarIconPosition", { + { text = "Left", value = "LEFT" }, + { text = "Right", value = "RIGHT" }, + }, 210, ddY); ddY = ddY - 52; widgets[#widgets+1] = ddCastIcon + + local dd6 = makeDropdown(panel, "Health text format", "healthTextFormat", { + { text = "Percent (50%)", value = "PERCENT" }, + { text = "Current (50M)", value = "CURRENT" }, + { text = "Current + % (50M 50%)", value = "CURRENT_PERCENT" }, + { text = "Current / Max", value = "CURRENT_MAX" }, + }, 210, ddY); ddY = ddY - 52; widgets[#widgets+1] = dd6 + + -- Anchor offset sliders at bottom + local sX = makeSlider(panel, "Anchor X", "anchorX", -1000, 1000, 1, y); y = y - 42; widgets[#widgets+1] = sX + local sY = makeSlider(panel, "Anchor Y", "anchorY", -1000, 1000, 1, y); y = y - 42; widgets[#widgets+1] = sY + + panel.refreshAll = function() + local db = DF:GetBossDB() + for _, w in ipairs(widgets) do + if w.Text and w.SetChecked then -- checkbox + -- find key by iterating label text? simpler: rebuild? OK we stored via closures. Use the bound db-read each build + -- For each checkbox, compare its OnClick binding by re-reading via widget label → skip; instead store key on widget + end + if w.refresh then w.refresh() end + if w.SetValue and w.edit then + -- slider: find key from widget name + local n = w:GetName() + if n and n:find("^DFBossOpt_") then + local key = n:sub(11) + local val = db[key] + if val then + w:SetValue(val) + w.edit:SetText(tostring(val)) + end + end + end + end + -- Re-sync checkboxes by their saved value via sibling iteration + for _, child in ipairs({panel:GetChildren()}) do + if child.SetChecked and child.Text then + local label = child.Text:GetText() + local keyMap = { + ["Show cast bar"] = "showCastBar", + ["Cast bar detached"] = "castBarDetached", + ["Show power bar"] = "showPowerBar", + ["Show name"] = "showName", + ["Show health text"] = "showHealthText", + ["Hide Blizzard"] = "hideBlizzard", + } + local k = keyMap[label] + if k then child:SetChecked(db[k] and true or false) end + end + end + end +end + +function DF:ToggleBossOptions() + if not panel then buildPanel() end + if panel:IsShown() then + panel:Hide() + else + panel.refreshAll() + panel:Show() + end +end diff --git a/Frames/BossOptionsGUI.lua b/Frames/BossOptionsGUI.lua new file mode 100644 index 00000000..8a292853 --- /dev/null +++ b/Frames/BossOptionsGUI.lua @@ -0,0 +1,346 @@ +local addonName, DF = ... + +-- ============================================================ +-- BOSS FRAMES — NATIVE GUI INTEGRATION +-- Plugs into the main DandersFrames options window as a proper +-- top-level category ("Boss Frames") with sub-pages. +-- +-- Called from Options.lua via DF:SetupBossPages(GUI, CreateCategory, +-- CreateSubTab, BuildPage). +-- +-- All controls edit DF:GetBossDB() — a shared block that is not +-- split by party/raid mode (boss settings are universal). +-- ============================================================ + +local GUI_ref +local pairs, ipairs = pairs, ipairs + +local function refresh() + if DF.RefreshBossFrames then DF:RefreshBossFrames() end +end + +-- Helper: make boss pages always accessible regardless of party/raid disable +local function whitelistBossPages(GUI) + GUI.AlwaysAccessiblePages = GUI.AlwaysAccessiblePages or {} + GUI.AlwaysAccessiblePages["boss_layout"] = true + GUI.AlwaysAccessiblePages["boss_bars"] = true + GUI.AlwaysAccessiblePages["boss_text"] = true + GUI.AlwaysAccessiblePages["boss_castbar"] = true + GUI.AlwaysAccessiblePages["boss_auras"] = true +end + +-- 9-point anchor option map (value -> display text). Shared for all +-- text/position dropdowns (container, name, health text, raid target). +local function anchorOpts(L) + return { + TOPLEFT = L["Top Left"] or "Top left", + TOP = L["Top"], + TOPRIGHT = L["Top Right"] or "Top right", + LEFT = L["Left"], + CENTER = L["Center"], + RIGHT = L["Right"], + BOTTOMLEFT = L["Bottom Left"] or "Bottom left", + BOTTOM = L["Bottom"], + BOTTOMRIGHT = L["Bottom Right"] or "Bottom right", + } +end + +function DF:SetupBossPages(GUI, CreateCategory, CreateSubTab, BuildPage) + GUI_ref = GUI + local L = DF.L + + whitelistBossPages(GUI) + + -- Mark all boss tabs as "New" — the badge auto-clears per tab once + -- the user visits it (persisted via DandersFramesDB_v2.seenTabs). + GUI.NewTabs = GUI.NewTabs or {} + GUI.NewTabs["boss_layout"] = true + GUI.NewTabs["boss_bars"] = true + GUI.NewTabs["boss_text"] = true + GUI.NewTabs["boss_castbar"] = true + GUI.NewTabs["boss_auras"] = true + + -- ======================================== + -- CATEGORY + -- ======================================== + CreateCategory("boss", L["Boss Frames"] or "Boss Frames") + + -- ======================================== + -- Page: Layout (position, size, portrait, growth) + -- ======================================== + local pageLayout = CreateSubTab("boss", "boss_layout", L["Layout"]) + BuildPage(pageLayout, function(self, _, Add, AddSpace, AddSyncPoint) + local db = DF:GetBossDB() + + local g = GUI:CreateSettingsGroup(self.child, 560) + g:AddWidget(GUI:CreateHeader(self.child, L["Boss Frames"] or "Boss Frames"), 40) + g:AddWidget(GUI:CreateLabel(self.child, + L["Configure the boss frames shown on engaged bosses (up to 5). These replace the default Blizzard boss frames."] + or "Configure the boss frames shown on engaged bosses (up to 5). These replace the default Blizzard boss frames.", + 530), 40) + Add(g, nil, "both") + + -- Buttons row + local moverBtn = GUI:CreateButton(self.child, L["Unlock Mover"] or "Unlock / Lock Mover", + 180, 24, function() DF:ToggleBossMover() end) + Add(moverBtn, 32, 1) + local testBtn = GUI:CreateButton(self.child, L["Toggle Test Mode"] or "Toggle Test Mode", + 180, 24, function() + local any + for i = 1, 5 do if DF.BossFrames and DF.BossFrames[i] and DF.BossFrames[i]._testMode then any = true; break end end + DF:SetBossTestMode(any and 0 or 3) + end) + Add(testBtn, 32, 2) + + AddSyncPoint() + + -- Copy settings from the other mode (Party <-> Raid) + local curMode = (GUI and GUI.SelectedMode == "raid") and "raid" or "party" + local otherMode = curMode == "raid" and "party" or "raid" + local otherLabel = otherMode == "raid" and (L["Raid"] or "Raid") or (L["Party"] or "Party") + local copyBtn = GUI:CreateButton(self.child, + format(L["Copy from %s"] or "Copy from %s", otherLabel), + 220, 24, + function() + -- Confirm with a popup to avoid accidental overwrite + if DF.ShowPopupAlert then + DF:ShowPopupAlert({ + title = L["Copy Boss Settings"] or "Copy Boss Settings", + message = format( + L["This will overwrite all Boss Frame settings for the current mode (%s) with the settings from %s mode. Continue?"] + or "This will overwrite all Boss Frame settings for the current mode (%s) with the settings from %s mode. Continue?", + curMode, otherMode), + buttons = { + { label = L["Yes"] or "Yes", onClick = function() + DF:CopyBossSettings(otherMode, curMode) + if GUI.RefreshCurrentPage then GUI.RefreshCurrentPage() end + end }, + { label = L["Cancel"] or "Cancel" }, + }, + }) + else + DF:CopyBossSettings(otherMode, curMode) + if GUI.RefreshCurrentPage then GUI.RefreshCurrentPage() end + end + end) + Add(copyBtn, 32, "both") + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["General"]), 32, "both") + + Add(GUI:CreateCheckbox(self.child, L["Enable"] or "Enable", db, "enabled", refresh), 32, 1) + Add(GUI:CreateCheckbox(self.child, L["Hide Blizzard"] or "Hide Blizzard", db, "hideBlizzard", refresh), 32, 2) + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Position"]), 32, "both") + + Add(GUI:CreateDropdown(self.child, L["Anchor"], anchorOpts(L), db, "anchor", refresh), 55, 1) + Add(GUI:CreateDropdown(self.child, L["Grow Direction"] or "Grow direction", { + DOWN = L["Down"], + UP = L["Up"], + }, db, "growDirection", refresh), 55, 2) + + Add(GUI:CreateSlider(self.child, L["Offset X"], -1000, 1000, 1, db, "anchorX", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Offset Y"], -1000, 1000, 1, db, "anchorY", refresh), 55, 2) + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Size"]), 32, "both") + + Add(GUI:CreateSlider(self.child, L["Width"], 100, 400, 1, db, "frameWidth", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Height"] or "Height", 20, 100, 1, db, "frameHeight", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Spacing"], 0, 40, 1, db, "frameSpacing", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Scale"], 0.5, 2.0, 0.05, db, "frameScale", refresh), 55, 2) + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Portrait"] or "Portrait"), 32, "both") + + Add(GUI:CreateDropdown(self.child, L["Portrait Position"] or "Portrait position", { + LEFT = L["Left"], + RIGHT = L["Right"], + HIDDEN = L["Hidden"] or "Hidden", + }, db, "portraitPosition", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Portrait Size"] or "Portrait size", 20, 80, 1, db, "portraitSize", refresh), 55, 2) + Add(GUI:CreateDropdown(self.child, L["Portrait Style"] or "Portrait style", { + ["2D"] = L["2D (Blizzard-style face icon)"] or "2D (Blizzard-style face icon)", + ["3D"] = L["3D (animated model)"] or "3D (animated model)", + }, db, "portraitStyle", refresh), 55, "both") + end) + + -- ======================================== + -- Page: Bars (health + power) + -- ======================================== + local pageBars = CreateSubTab("boss", "boss_bars", L["Bars"]) + BuildPage(pageBars, function(self, _, Add, AddSpace, AddSyncPoint) + local db = DF:GetBossDB() + + Add(GUI:CreateHeader(self.child, L["Health"] or "Health"), 32, "both") + Add(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "healthTexture", refresh), 55, 1) + Add(GUI:CreateDropdown(self.child, L["Color"] or "Color", { + REACTION = L["Reaction (red/yellow/green by hostility — Blizzard)"] or "Reaction (Blizzard hostility colors)", + CLASS_FALLBACK = L["Class Color"] or "Class color (red fallback)", + STATIC = L["Custom"] or "Custom static", + }, db, "healthColorMode", refresh), 55, 2) + Add(GUI:CreateColorPicker(self.child, L["Static Color"] or "Static color", db, "healthStaticColor", true, refresh), 40, 1) + Add(GUI:CreateSlider(self.child, L["Background Alpha"] or "Background alpha", 0, 1, 0.05, db, "healthBackgroundAlpha", refresh), 55, 2) + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Power"] or "Power"), 32, "both") + Add(GUI:CreateCheckbox(self.child, L["Show Power Bar"] or "Show power bar", db, "showPowerBar", refresh), 32, 1) + Add(GUI:CreateSlider(self.child, L["Power Bar Height"] or "Power bar height", 2, 20, 1, db, "powerBarHeight", refresh), 55, 2) + Add(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "powerTexture", refresh), 55, "both") + Add(GUI:CreateSlider(self.child, L["Background Alpha"] or "Background alpha", 0, 1, 0.05, db, "powerBackgroundAlpha", refresh), 55, "both") + end) + + -- ======================================== + -- Page: Cast Bar + -- ======================================== + local pageCast = CreateSubTab("boss", "boss_castbar", L["Cast Bar"] or "Cast Bar") + BuildPage(pageCast, function(self, _, Add, AddSpace, AddSyncPoint) + local db = DF:GetBossDB() + + Add(GUI:CreateHeader(self.child, L["Cast Bar"] or "Cast Bar"), 32, "both") + Add(GUI:CreateCheckbox(self.child, L["Show Cast Bar"] or "Show cast bar", db, "showCastBar", refresh), 32, 1) + Add(GUI:CreateCheckbox(self.child, L["Detached"] or "Detached (below frame)", db, "castBarDetached", refresh), 32, 2) + + Add(GUI:CreateSlider(self.child, L["Height"] or "Height", 8, 40, 1, db, "castBarHeight", refresh), 55, 1) + Add(GUI:CreateDropdown(self.child, L["Icon Position"] or "Icon position", { + LEFT = L["Left"], + RIGHT = L["Right"], + }, db, "castBarIconPosition", refresh), 55, 2) + Add(GUI:CreateTextureDropdown(self.child, L["Texture"], db, "castTexture", refresh), 55, "both") + Add(GUI:CreateSlider(self.child, L["Background Alpha"] or "Background alpha", 0, 1, 0.05, db, "castBackgroundAlpha", refresh), 55, "both") + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Detached Position"] or "Detached position"), 32, "both") + Add(GUI:CreateDropdown(self.child, L["Position"] or "Position", anchorOpts(L), db, "castBarDetachedAnchor", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Width"] .. " (0=auto)", 0, 400, 1, db, "castBarDetachedWidth", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Offset X"], -200, 200, 1, db, "castBarDetachedX", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Offset Y"], -200, 200, 1, db, "castBarDetachedY", refresh), 55, 2) + end) + + -- ======================================== + -- Page: Text (name + health text) + -- ======================================== + local pageText = CreateSubTab("boss", "boss_text", L["Text"]) + BuildPage(pageText, function(self, _, Add, AddSpace, AddSyncPoint) + local db = DF:GetBossDB() + + local anchor9 = anchorOpts(L) + + Add(GUI:CreateHeader(self.child, L["Name"] or "Name"), 32, "both") + Add(GUI:CreateCheckbox(self.child, L["Show Name"] or "Show name", db, "showName", refresh), 32, 1) + Add(GUI:CreateDropdown(self.child, L["Position"] or "Position", anchor9, db, "nameAnchor", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Offset X"], -80, 80, 1, db, "nameX", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Offset Y"], -80, 80, 1, db, "nameY", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, (L["Max Length"] or "Max length") .. " (0=off)", 0, 40, 1, db, "nameMaxLength", refresh), 55, "both") + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Health Text"] or "Health Text"), 32, "both") + Add(GUI:CreateCheckbox(self.child, L["Show Health Text"] or "Show health text", db, "showHealthText", refresh), 32, 1) + Add(GUI:CreateDropdown(self.child, L["Position"] or "Position", anchor9, db, "healthTextAnchor", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Offset X"], -80, 80, 1, db, "healthTextX", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Offset Y"], -80, 80, 1, db, "healthTextY", refresh), 55, 2) + Add(GUI:CreateDropdown(self.child, L["Format"] or "Format", { + PERCENT = L["Percent"] or "Percent (50%)", + CURRENT = L["Current"] or "Current (50M)", + CURRENT_PERCENT = L["Current + Percent"] or "Current + % (50M 50%)", + CURRENT_MAX = L["Current / Max"] or "Current / Max", + }, db, "healthTextFormat", refresh), 55, "both") + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Power Text"] or "Power Text"), 32, "both") + Add(GUI:CreateCheckbox(self.child, L["Show Power Text"] or "Show power text", db, "showPowerText", refresh), 32, 1) + Add(GUI:CreateDropdown(self.child, L["Position"] or "Position", anchorOpts(L), db, "powerTextAnchor", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Offset X"], -80, 80, 1, db, "powerTextX", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Offset Y"], -80, 80, 1, db, "powerTextY", refresh), 55, 2) + Add(GUI:CreateDropdown(self.child, L["Format"] or "Format", { + PERCENT = L["Percent"] or "Percent (50%)", + CURRENT = L["Current"] or "Current (50M)", + CURRENT_PERCENT = L["Current + Percent"] or "Current + % (50M 50%)", + CURRENT_MAX = L["Current / Max"] or "Current / Max", + }, db, "powerTextFormat", refresh), 55, "both") + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Raid Target Icon"] or "Raid Target Icon"), 32, "both") + Add(GUI:CreateCheckbox(self.child, L["Show Raid Target Icon"] or "Show raid target icon (skull/cross/...)", db, "showRaidTargetIcon", refresh), 32, "both") + Add(GUI:CreateDropdown(self.child, L["Position"] or "Position", anchor9, db, "raidTargetAnchor", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Size"] or "Size", 10, 48, 1, db, "raidTargetSize", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Offset X"], -80, 80, 1, db, "raidTargetX", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Offset Y"], -80, 80, 1, db, "raidTargetY", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Alpha"], 0, 1, 0.05, db, "raidTargetAlpha", refresh), 55, "both") + end) + + -- ======================================== + -- Page: Auras (buffs / debuffs) + -- ======================================== + local pageAuras = CreateSubTab("boss", "boss_auras", L["Auras"]) + BuildPage(pageAuras, function(self, _, Add, AddSpace, AddSyncPoint) + local db = DF:GetBossDB() + + Add(GUI:CreateHeader(self.child, L["Auras"]), 32, "both") + Add(GUI:CreateLabel(self.child, + L["Show buffs or debuffs on each boss frame. Like Blizzard's default boss frames but fully configurable."] + or "Show buffs or debuffs on each boss frame. Like Blizzard's default boss frames but fully configurable.", + 530), 40, "both") + + Add(GUI:CreateCheckbox(self.child, L["Show Auras"] or "Show auras", db, "showAuras", refresh), 32, 1) + Add(GUI:CreateDropdown(self.child, L["Filter"] or "Filter", { + HARMFUL = L["Debuffs"] or "Debuffs (harmful)", + HELPFUL = L["Buffs"] or "Buffs (helpful)", + }, db, "aurasFilter", refresh), 55, 2) + Add(GUI:CreateDropdown(self.child, L["Source"] or "Source", { + ALL = L["All (Blizzard-like)"] or "All (Blizzard-like)", + MINE = L["Only mine"] or "Only mine", + NOT_MINE = L["Hide mine"] or "Hide mine", + BOSS_ONLY = L["Boss-cast only"] or "Boss-cast only", + }, db, "aurasSource", refresh), 55, "both") + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Appearance"] or "Appearance"), 32, "both") + Add(GUI:CreateSlider(self.child, L["Max Count"] or "Max count", 1, 8, 1, db, "aurasMaxCount", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Size"] or "Size", 12, 48, 1, db, "aurasSize", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Spacing"], 0, 10, 1, db, "aurasSpacing", refresh), 55, 1) + + Add(GUI:CreateCheckbox(self.child, L["Show Stacks"] or "Show stacks", db, "aurasShowStacks", refresh), 32, 1) + Add(GUI:CreateCheckbox(self.child, L["Show Timer"] or "Show timer", db, "aurasShowTimer", refresh), 32, 2) + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Stack Text"] or "Stack text"), 32, "both") + Add(GUI:CreateDropdown(self.child, L["Position"] or "Position", anchorOpts(L), db, "aurasStackAnchor", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Offset X"], -20, 20, 1, db, "aurasStackX", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Offset Y"], -20, 20, 1, db, "aurasStackY", refresh), 55, 1) + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Timer Text"] or "Timer text"), 32, "both") + Add(GUI:CreateDropdown(self.child, L["Placement"] or "Placement", { + INSIDE = L["Inside (centered)"] or "Inside (centered)", + BELOW = L["Below icon"] or "Below icon", + ABOVE = L["Above icon"] or "Above icon", + }, db, "aurasTimerPlacement", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Offset X"], -20, 20, 1, db, "aurasTimerX", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Offset Y"], -20, 20, 1, db, "aurasTimerY", refresh), 55, 1) + + AddSyncPoint() + + Add(GUI:CreateHeader(self.child, L["Position"] or "Position"), 32, "both") + Add(GUI:CreateDropdown(self.child, L["Position"] or "Position", anchorOpts(L), db, "aurasAnchor", refresh), 55, 1) + Add(GUI:CreateDropdown(self.child, L["Grow X"] or "Grow X", { + LEFT = L["Left"], + RIGHT = L["Right"], + }, db, "aurasGrowX", refresh), 55, 2) + Add(GUI:CreateSlider(self.child, L["Offset X"], -200, 200, 1, db, "aurasX", refresh), 55, 1) + Add(GUI:CreateSlider(self.child, L["Offset Y"], -200, 200, 1, db, "aurasY", refresh), 55, 2) + end) +end diff --git a/Locales/enUS.lua b/Locales/enUS.lua index 4e4a8fe4..63e196d6 100644 --- a/Locales/enUS.lua +++ b/Locales/enUS.lua @@ -1675,4 +1675,54 @@ L["Yes, set it up"] = true L["• Having trouble seeing certain buffs or debuffs?\n• This wizard helps you pick the right aura settings"] = true L["• Recommended defaults work well for most players\n• Manual lets you fine-tune every filter option"] = true +-- Boss Frames (alphabetical) +L["Above icon"] = true +L["Below icon"] = true +L["Boss Frames"] = true +L["Cast Bar"] = true +L["Configure the boss frames shown on engaged bosses (up to 5). These replace the default Blizzard boss frames."] = true +L["Copy Boss Settings"] = true +L["Copy from %s"] = true +L["Current + Percent"] = true +L["Current"] = true +L["Detached Position"] = true +L["Detached"] = true +L["Filter"] = true +L["Format"] = true +L["Grow Direction"] = true +L["Grow X"] = true +L["Hide Blizzard"] = true +L["High Threat"] = true +L["Inside (centered)"] = true +L["Low Threat"] = true +L["Max Count"] = true +L["Overthreat"] = true +L["Placement"] = true +L["Portrait Position"] = true +L["Portrait Size"] = true +L["Portrait"] = true +L["Power Bar Height"] = true +L["Power Text"] = true +L["Power"] = true +L["Show Auras"] = true +L["Show Cast Bar"] = true +L["Show Health Text"] = true +L["Show Name"] = true +L["Show Power Bar"] = true +L["Show Power Text"] = true +L["Show Raid Target Icon"] = true +L["Show Threat"] = true +L["Show a colored glow around the frame when the boss targets you or when you are on its threat table."] = true +L["Show buffs or debuffs on each boss frame. Like Blizzard's default boss frames but fully configurable."] = true +L["Static Color"] = true +L["Tanking"] = true +L["Target Me (glow when boss targets player)"] = true +L["Target Me"] = true +L["This will overwrite all Boss Frame settings for the current mode (%s) with the settings from %s mode. Continue?"] = true +L["Threat / Target Indicator"] = true +L["Threat Levels (4 colors by threat situation)"] = true +L["Threat"] = true +L["Timer Text"] = true +L["Unlock Mover"] = true + --@end-do-not-package@ diff --git a/Options/Options.lua b/Options/Options.lua index 2f68a334..c7037401 100644 --- a/Options/Options.lua +++ b/Options/Options.lua @@ -209,7 +209,7 @@ function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) GUI.CreateCopyButton = CreateCopyButton -- Define category order (updated structure) - GUI.CategoryOrder = {"general", "clickcast", "display", "bars", "text", "auras", "indicators", "profiles", "debug"} + GUI.CategoryOrder = {"general", "clickcast", "display", "bars", "text", "auras", "indicators", "boss", "profiles", "debug"} -- ======================================== -- CATEGORY: General @@ -8718,4 +8718,10 @@ function DF:SetupGUIPages(GUI, CreateCategory, CreateSubTab, BuildPage) currentSection = nil end) + -- Boss Frames (external module) — pass the factories so the module + -- can register its own category and pages using the same system. + if DF.SetupBossPages then + DF:SetupBossPages(GUI, CreateCategory, CreateSubTab, BuildPage) + end + end diff --git a/TestMode/TestMode.lua b/TestMode/TestMode.lua index 8c14370d..2bbb7743 100644 --- a/TestMode/TestMode.lua +++ b/TestMode/TestMode.lua @@ -6782,6 +6782,66 @@ function DF:CreateTestPanel() if DF.UpdateAllTestHighlights then DF:UpdateAllTestHighlights() end end, "indicators_highlights") + -- --- BOSS FRAMES --- + local secBoss = CreateSection(panel, "Boss Frames", "boss") + panel.showBossFramesCheck = secBoss:AddCheckbox("Show Boss Frames", "testShowBoss", function(enabled) + if DF.SetBossTestMode then + if enabled then + local isRaidMode = DF.GUI and DF.GUI.SelectedMode == "raid" + local db = isRaidMode and DF:GetRaidDB() or DF:GetDB() + local count = db.testBossCount or 3 + DF:SetBossTestMode(count) + else + DF:SetBossTestMode(0) + end + end + end, "boss_layout") + panel.bossShowAurasCheck = secBoss:AddCheckbox("Boss Auras", "bossShowAuras", function(enabled) + local bossDB = DF.GetRenderBossDB and DF:GetRenderBossDB() + if bossDB then bossDB.showAuras = enabled and true or false end + if DF.RefreshBossFrames then DF:RefreshBossFrames() end + end, "boss_auras") + panel.bossShowCastCheck = secBoss:AddCheckbox("Boss Cast Bar", "bossShowCastBar", function(enabled) + local bossDB = DF.GetRenderBossDB and DF:GetRenderBossDB() + if bossDB then bossDB.showCastBar = enabled and true or false end + if DF.RefreshBossFrames then DF:RefreshBossFrames() end + end, "boss_castbar") + panel.bossShowPowerCheck = secBoss:AddCheckbox("Boss Power Bar","bossShowPower", function(enabled) + local bossDB = DF.GetRenderBossDB and DF:GetRenderBossDB() + if bossDB then bossDB.showPowerBar = enabled and true or false end + if DF.RefreshBossFrames then DF:RefreshBossFrames() end + end, "boss_bars") + panel.bossShowRaidIconCheck = secBoss:AddCheckbox("Boss Raid Icon","bossShowRaidIcon",function(enabled) + local bossDB = DF.GetRenderBossDB and DF:GetRenderBossDB() + if bossDB then bossDB.showRaidTargetIcon = enabled and true or false end + if DF.RefreshBossFrames then DF:RefreshBossFrames() end + end, "boss_text") + + -- Boss count slider + local bcRow = CreateFrame("Frame", nil, secBoss.content) + bcRow:SetHeight(28) + local bcLabel = bcRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + bcLabel:SetPoint("LEFT", 0, 0) + bcLabel:SetText("Boss Count") + bcLabel:SetTextColor(C_TEXT.r, C_TEXT.g, C_TEXT.b) + local bcValue = bcRow:CreateFontString(nil, "OVERLAY", "DFFontHighlightSmall") + bcValue:SetPoint("LEFT", bcLabel, "RIGHT", 6, 0) + local bossSlider = CreateThemedSlider(bcRow, 140, 1, 5, 1) + bossSlider:SetPoint("LEFT", bcValue, "RIGHT", 8, 0) + bossSlider:HookScript("OnValueChanged", function(self, value) + local isRaidMode = DF.GUI and DF.GUI.SelectedMode == "raid" + local db = isRaidMode and DF:GetRaidDB() or DF:GetDB() + db.testBossCount = math.floor(value) + bcValue:SetText(tostring(db.testBossCount)) + -- If boss test is currently on, update the count live + if DF.BossFrames and DF.BossFrames[1] and DF.BossFrames[1]._testMode and DF.SetBossTestMode then + DF:SetBossTestMode(db.testBossCount) + end + end) + panel.bossCountSlider = bossSlider + panel.bossCountValue = bcValue + secBoss:AddWidget(bcRow, 28) + -- ============================================================ -- PRESETS FOOTER -- ============================================================ From a0edada1a550654685ba0943120ddebde6d08648 Mon Sep 17 00:00:00 2001 From: Timikana Date: Thu, 23 Apr 2026 13:53:40 +0200 Subject: [PATCH 2/2] Remove 3D portrait option (2D only) --- Config.lua | 1 - Frames/Boss.lua | 57 +++++---------------------------------- Frames/BossOptionsGUI.lua | 4 --- 3 files changed, 7 insertions(+), 55 deletions(-) diff --git a/Config.lua b/Config.lua index 58ff70d7..e1cb1fea 100644 --- a/Config.lua +++ b/Config.lua @@ -3342,7 +3342,6 @@ DF.BossDefaults = { -- Portrait portraitPosition = "RIGHT", -- LEFT | RIGHT | HIDDEN - portraitStyle = "2D", -- 3D | 2D (2D matches Blizzard's default) portraitSize = 44, -- px -- Health bar diff --git a/Frames/Boss.lua b/Frames/Boss.lua index cf422dc7..07e35666 100644 --- a/Frames/Boss.lua +++ b/Frames/Boss.lua @@ -273,39 +273,11 @@ local function UpdateFrame(frame) end end - -- Portrait + -- Portrait (2D Blizzard-style face icon) if frame.portrait then if db.portraitPosition ~= "HIDDEN" then frame.portrait:Show() - if frame.portrait.isModel then - -- 3D model - if frame._testMode then - if not frame._testModelSet then - frame.portrait:ClearModel() - frame.portrait:SetUnit("player") - frame.portrait:SetPortraitZoom(0.9) - frame._testModelSet = true - end - else - frame._testModelSet = nil - frame.portrait:SetUnit(unit) - frame.portrait:SetPortraitZoom(1) - end - if db.portraitPosition == "RIGHT" then - frame.portrait:SetFacing(-0.5) - elseif db.portraitPosition == "LEFT" then - frame.portrait:SetFacing(0.5) - else - frame.portrait:SetFacing(0) - end - else - -- 2D texture (Blizzard-style face icon) - if frame._testMode then - SetPortraitTexture(frame.portrait, "player") - else - SetPortraitTexture(frame.portrait, unit) - end - end + SetPortraitTexture(frame.portrait, frame._testMode and "player" or unit) else frame.portrait:Hide() end @@ -438,12 +410,6 @@ local function ApplyLayout() end end - -- Portrait layout: pick 3D model or 2D texture based on portraitStyle - local use3D = (db.portraitStyle ~= "2D") - f.portrait = use3D and f.portrait3D or f.portrait2D - local other = use3D and f.portrait2D or f.portrait3D - - if other then other:Hide() end if f.portrait then local size = db.portraitSize f.portrait:SetSize(size, size) @@ -647,20 +613,11 @@ local function CreateBossFrame(index) pwText:SetJustifyH("RIGHT") f.powerText = pwText - -- Portrait — create both a 3D model and a 2D texture; the layout picks - -- the active one based on db.portraitStyle. - local portrait3D = CreateFrame("PlayerModel", nil, f) - portrait3D.isModel = true - portrait3D:Hide() - f.portrait3D = portrait3D - - local portrait2D = f:CreateTexture(nil, "ARTWORK") - portrait2D:SetTexCoord(0.08, 0.92, 0.08, 0.92) - portrait2D:Hide() - f.portrait2D = portrait2D - - -- `portrait` is the active one, set by ApplyLayout - f.portrait = portrait3D + -- Portrait — 2D only (Blizzard-style face icon via SetPortraitTexture) + local portrait = f:CreateTexture(nil, "ARTWORK") + portrait:SetTexCoord(0.08, 0.92, 0.08, 0.92) + portrait:Hide() + f.portrait = portrait -- Thin border around the portrait: a slightly larger black rectangle -- behind the model creates a 1px "frame" look. diff --git a/Frames/BossOptionsGUI.lua b/Frames/BossOptionsGUI.lua index 8a292853..e3253d69 100644 --- a/Frames/BossOptionsGUI.lua +++ b/Frames/BossOptionsGUI.lua @@ -164,10 +164,6 @@ function DF:SetupBossPages(GUI, CreateCategory, CreateSubTab, BuildPage) HIDDEN = L["Hidden"] or "Hidden", }, db, "portraitPosition", refresh), 55, 1) Add(GUI:CreateSlider(self.child, L["Portrait Size"] or "Portrait size", 20, 80, 1, db, "portraitSize", refresh), 55, 2) - Add(GUI:CreateDropdown(self.child, L["Portrait Style"] or "Portrait style", { - ["2D"] = L["2D (Blizzard-style face icon)"] or "2D (Blizzard-style face icon)", - ["3D"] = L["3D (animated model)"] or "3D (animated model)", - }, db, "portraitStyle", refresh), 55, "both") end) -- ========================================