Files
BlizzNameplatesPlus/modules/castbar.lua
T
2026-09-11 16:49:37 +02:00

533 lines
16 KiB
Lua

BNP = BNP or {}
local casts = {}
local BAR_HEIGHT = 6
local BAR_GAP = 4
local MODERN_ICON_BORDER_SCALE = 23 / 14
local CLASSIC_FRAME_INSET = 3
local CLASSIC_GOLD_R, CLASSIC_GOLD_G, CLASSIC_GOLD_B = 1.0, 0.8, 0.0
local CLASSIC_BACKDROP = {
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
tile = true,
tileSize = 8,
edgeSize = 12,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
}
-- Runtime-only visual preview. This is intentionally not saved in BNP_DB so
-- a /reload always returns to normal cast detection.
local castbarTestMode = false
local testCastStart = 0
local TEST_CAST_DURATION = 4
local TEST_CAST_TEXTURE = "Interface\\Icons\\Spell_Fire_Fireball02"
local testCast = {
name = "Test Cast",
texture = TEST_CAST_TEXTURE,
startTime = 0,
endTime = 0,
duration = TEST_CAST_DURATION,
channel = false,
}
local function ResetTestCast(now)
now = now or GetTime()
testCastStart = now
testCast.startTime = now
testCast.endTime = now + TEST_CAST_DURATION
testCast.duration = TEST_CAST_DURATION
end
local function GetTestCast(now)
if testCastStart == 0 or now >= testCast.endTime then
ResetTestCast(now)
end
return testCast
end
local function GetCastbarHeight()
if BNP.GetCastbarHeight then return BNP:GetCastbarHeight() end
return BAR_HEIGHT
end
local function GetCastbarFontSize()
if BNP.GetCastbarFontSize then return BNP:GetCastbarFontSize() end
return 10
end
local function GetCastbarStyle()
if BNP.GetCastbarStyle then return BNP:GetCastbarStyle() end
return "modern"
end
local function IsClassicCastbarStyle()
return GetCastbarStyle() == "classic"
end
local function IsCastbarIconEnabled()
if BNP.IsCastbarIconEnabled then return BNP:IsCastbarIconEnabled() end
return not BNP_DB or BNP_DB.castbarIcon ~= false
end
local function GetCastbarIconSize()
if IsClassicCastbarStyle() then
-- The Classic castbar backdrop extends three pixels above and below the
-- configured bar height. Give the icon the same complete outer height.
return GetCastbarHeight() + (CLASSIC_FRAME_INSET * 2)
end
-- Modern keeps the spell icon exactly square with the configured castbar
-- height. Both frames share the same top anchor, so their visible rows stay
-- aligned at every slider value from 4 to 20.
return GetCastbarHeight()
end
local function GetCastbarStartOffset()
if not IsCastbarIconEnabled() then return 0 end
if IsClassicCastbarStyle() then return GetCastbarIconSize() end
return GetCastbarIconSize() + 1
end
local function GetCastbarXOffset()
if BNP.GetCastbarXOffset then return BNP:GetCastbarXOffset() end
return 0
end
local function GetCastbarYOffset()
if BNP.GetCastbarYOffset then return BNP:GetCastbarYOffset() end
return 0
end
local function ApplyCastbarFont(text)
if not text or not text.GetFont or not text.SetFont then return end
local font, currentSize, flags = text:GetFont()
if not font then return end
local size = GetCastbarFontSize()
if text.BNPModernFontFlags == nil then text.BNPModernFontFlags = flags or "" end
local wantedFlags = IsClassicCastbarStyle() and "THINOUTLINE" or text.BNPModernFontFlags
if currentSize ~= size or (flags or "") ~= wantedFlags then
-- Keep Blizzard's font face. Classic adds ShaguTweaks' thin outline;
-- Modern restores the original font flags.
text:SetFont(font, size, wantedFlags)
end
end
local BOTTOM_AURA_EDGE_GAP = 4
local function FrameShown(frame)
return frame and frame.IsShown and frame:IsShown() and true or false
end
local function IsBottomDebuffPosition(position)
return position == "bottom_mid" or position == "bottom_left" or position == "bottom_right"
end
local function GetBottomAuraClearance(plate)
if not plate then return 0 end
local debuffPosition = BNP.GetDebuffPosition and BNP:GetDebuffPosition() or "top"
local debuffsEnabled = not BNP.AreDebuffsEnabled or BNP:AreDebuffsEnabled()
local auraSize = BNP.GetIconSize and BNP:GetIconSize() or 18
if debuffsEnabled and IsBottomDebuffPosition(debuffPosition) and FrameShown(plate.BNPAuraContainer) then
return BOTTOM_AURA_EDGE_GAP + auraSize
end
return 0
end
local function ApplyCastbarLayout(plate, bar)
if not plate or not plate.healthbar or not bar then return end
local gap = BAR_GAP + GetBottomAuraClearance(plate)
local xOffset = GetCastbarXOffset()
local yOffset = GetCastbarYOffset()
local barStartOffset = GetCastbarStartOffset()
bar:ClearAllPoints()
bar:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", barStartOffset + xOffset, -gap + yOffset)
bar:SetPoint("TOPRIGHT", plate.healthbar, "BOTTOMRIGHT", 14 + xOffset, -gap + yOffset)
if bar.icon then
bar.icon:ClearAllPoints()
local iconYOffset = IsClassicCastbarStyle() and CLASSIC_FRAME_INSET or 0
bar.icon:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", xOffset, -gap + yOffset + iconYOffset)
end
bar.BNPBottomAuraClearance = GetBottomAuraClearance(plate)
end
local function Enabled()
if BNP.AreCastbarsEnabled then return BNP:AreCastbarsEnabled() end
return not BNP_DB or BNP_DB.castbars ~= false
end
local function GetPlateGUID(plate)
if not plate or not plate.GetName then return nil end
local token = plate:GetName(1)
if not token then return nil end
local exists, guid = UnitExists(token)
if exists and guid then return guid end
-- On SuperWoW the plate token itself can already be the GUID.
return token
end
local function SpellData(spellID)
if SpellInfo and spellID then
local name, _, texture = SpellInfo(spellID)
return name or "Casting", texture
end
return "Casting", nil
end
-- Only the presentation changes between styles. Cast state and animation stay
-- on BNP's GUID-based path instead of ShaguTweaks' name-based fallback.
local function ApplyCastbarStyle(bar)
if not bar then return end
local classic = IsClassicCastbarStyle()
if bar.classicBackdrop then
if classic then bar.classicBackdrop:Show() else bar.classicBackdrop:Hide() end
end
if bar.icon then
local iconSize = GetCastbarIconSize()
bar.icon:SetWidth(iconSize)
bar.icon:SetHeight(iconSize)
if bar.icon.texture then
bar.icon.texture:ClearAllPoints()
if classic then
bar.icon.texture:SetPoint("CENTER", bar.icon, "CENTER", 0, 0)
bar.icon.texture:SetWidth(GetCastbarHeight())
bar.icon.texture:SetHeight(GetCastbarHeight())
else
bar.icon.texture:SetAllPoints(bar.icon)
end
end
if bar.icon.border then
if classic then
bar.icon.border:Hide()
else
local borderSize = math.floor((iconSize * MODERN_ICON_BORDER_SCALE) + 0.5)
bar.icon.border:SetWidth(borderSize)
bar.icon.border:SetHeight(borderSize)
bar.icon.border:Show()
end
end
if bar.icon.classicBackdrop then
if classic then bar.icon.classicBackdrop:Show() else bar.icon.classicBackdrop:Hide() end
end
if not IsCastbarIconEnabled() then bar.icon:Hide() end
end
ApplyCastbarFont(bar.text)
end
local function CreateCastbar(plate)
if plate.BNPCastbar then return plate.BNPCastbar end
if not plate.healthbar then return nil end
local parent = plate.BNPScaleWrapper or plate
local bar = CreateFrame("StatusBar", nil, parent)
bar:SetHeight(GetCastbarHeight())
-- Center the castbar under the complete Blizzard nameplate.
-- Extend beneath the level badge instead of stopping at the healthbar edge.
-- Compact row: with the optional icon enabled, icon + castbar together span
-- the complete plate width. Without it, the bar starts at the left edge and
-- uses the freed space itself.
local gap = BAR_GAP + GetBottomAuraClearance(plate)
local xOffset = GetCastbarXOffset()
local yOffset = GetCastbarYOffset()
bar:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", GetCastbarStartOffset() + xOffset, -gap + yOffset)
bar:SetPoint("TOPRIGHT", plate.healthbar, "BOTTOMRIGHT", 14 + xOffset, -gap + yOffset)
bar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
bar:SetStatusBarColor(1.0, 0.8, 0.0)
bar:SetMinMaxValues(0, 1)
bar:SetValue(0)
bar:SetFrameLevel((plate.healthbar:GetFrameLevel() or 1) + 1)
bar:Hide()
local bg = bar:CreateTexture(nil, "BACKGROUND")
bg:SetTexture("Interface\\TargetingFrame\\UI-StatusBar")
bg:SetVertexColor(0.10, 0.10, 0.00, 0.85)
bg:SetAllPoints(bar)
local text = bar:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
ApplyCastbarFont(text)
text:SetPoint("CENTER", bar, "CENTER", 0, 0)
text:SetTextColor(1, 1, 1)
bar.text = text
-- ShaguTweaks-inspired Classic frame. It remains hidden in the default
-- Modern style and adds no second progress or animation layer.
local classicBackdrop = CreateFrame("Frame", nil, bar)
classicBackdrop:SetFrameLevel(bar:GetFrameLevel())
classicBackdrop:SetPoint("TOPLEFT", bar, "TOPLEFT", -3, 3)
classicBackdrop:SetPoint("BOTTOMRIGHT", bar, "BOTTOMRIGHT", 3, -3)
classicBackdrop:SetBackdrop(CLASSIC_BACKDROP)
classicBackdrop:SetBackdropBorderColor(CLASSIC_GOLD_R, CLASSIC_GOLD_G, CLASSIC_GOLD_B)
classicBackdrop:Hide()
bar.classicBackdrop = classicBackdrop
-- Spell icon to the left of the castbar.
local icon = CreateFrame("Frame", nil, parent)
icon:SetWidth(14)
icon:SetHeight(14)
local iconYOffset = IsClassicCastbarStyle() and CLASSIC_FRAME_INSET or 0
icon:SetPoint("TOPLEFT", plate.healthbar, "BOTTOMLEFT", xOffset, -gap + yOffset + iconYOffset)
icon:SetFrameLevel(bar:GetFrameLevel() + 1)
local texture = icon:CreateTexture(nil, "ARTWORK")
texture:SetAllPoints(icon)
icon.texture = texture
local border = icon:CreateTexture(nil, "OVERLAY")
border:SetTexture("Interface\\Buttons\\UI-Quickslot2")
border:SetPoint("CENTER", icon, "CENTER", 0, 0)
border:SetWidth(23)
border:SetHeight(23)
icon.border = border
local iconClassicBackdrop = CreateFrame("Frame", nil, icon)
iconClassicBackdrop:SetFrameLevel(icon:GetFrameLevel() + 1)
iconClassicBackdrop:SetAllPoints(icon)
iconClassicBackdrop:SetBackdrop(CLASSIC_BACKDROP)
iconClassicBackdrop:SetBackdropBorderColor(CLASSIC_GOLD_R, CLASSIC_GOLD_G, CLASSIC_GOLD_B)
iconClassicBackdrop:Hide()
icon.classicBackdrop = iconClassicBackdrop
icon:Hide()
bar.icon = icon
plate.BNPCastbar = bar
ApplyCastbarStyle(bar)
return bar
end
local function Hide(plate)
if plate and plate.BNPCastbar then
plate.BNPCastbar:Hide()
if plate.BNPCastbar.icon then
plate.BNPCastbar.icon:Hide()
end
end
end
-- Exact SuperWoW event path used by ShaguTweaks' superwow module:
-- START / CAST / CHANNEL create cast state; FAIL removes it.
local eventFrame = CreateFrame("Frame")
eventFrame:RegisterEvent("UNIT_CASTEVENT")
eventFrame:SetScript("OnEvent", function()
local guid = arg1
local eventType = arg3
local spellID = arg4
local timerMS = tonumber(arg5) or 0
if not guid then return end
if eventType == "START" or eventType == "CAST" or eventType == "CHANNEL" then
if timerMS <= 0 then
-- Instant casts do not need a visible castbar.
casts[guid] = nil
return
end
local name, texture = SpellData(spellID)
local now = GetTime()
local duration = timerMS / 1000
casts[guid] = {
name = name,
texture = texture,
startTime = now,
endTime = now + duration,
duration = duration,
channel = eventType == "CHANNEL",
}
elseif eventType == "FAIL" then
casts[guid] = nil
end
end)
local activeCastPlates = {}
local function MarkCastPlateActive(plate)
if not plate then return end
activeCastPlates[plate] = true
end
local function MarkCastPlateInactive(plate)
if not plate then return end
activeCastPlates[plate] = nil
Hide(plate)
end
-- Lightweight discovery pass. This does NOT animate bars; it only checks
-- visible plates for active cast state at 20 Hz.
local discovery = CreateFrame("Frame")
local discoveryElapsed = 0
discovery:SetScript("OnUpdate", function()
discoveryElapsed = discoveryElapsed + arg1
if discoveryElapsed < 0.05 then return end
discoveryElapsed = 0
if not Enabled() and not castbarTestMode then
local plate
for plate in pairs(activeCastPlates) do
MarkCastPlateInactive(plate)
end
return
end
local now = GetTime()
local plate
for plate in pairs(BNP.plates) do
if plate:IsShown() then
if castbarTestMode then
-- Preview every visible nameplate. No GUID/cast lookup is needed.
MarkCastPlateActive(plate)
else
local guid = GetPlateGUID(plate)
local cast = guid and casts[guid]
if cast and now < cast.endTime then
MarkCastPlateActive(plate)
elseif activeCastPlates[plate] then
MarkCastPlateInactive(plate)
end
end
elseif activeCastPlates[plate] then
MarkCastPlateInactive(plate)
end
end
end)
-- Frame-perfect animation remains unchanged visually, but only active
-- castbars are updated every rendered frame.
local updater = CreateFrame("Frame")
updater:SetScript("OnUpdate", function()
if not Enabled() and not castbarTestMode then return end
local now = GetTime()
local plate
for plate in pairs(activeCastPlates) do
if not plate:IsShown() then
MarkCastPlateInactive(plate)
else
local guid
local cast
if castbarTestMode then
cast = GetTestCast(now)
else
guid = GetPlateGUID(plate)
cast = guid and casts[guid]
end
if not cast or now >= cast.endTime then
if (not castbarTestMode) and guid and cast and now >= cast.endTime then
casts[guid] = nil
end
MarkCastPlateInactive(plate)
else
local bar = CreateCastbar(plate)
if bar then
local bottomClearance = GetBottomAuraClearance(plate)
if bar.BNPBottomAuraClearance ~= bottomClearance then
ApplyCastbarLayout(plate, bar)
end
local current = now - cast.startTime
if current < 0 then current = 0 end
if current > cast.duration then current = cast.duration end
bar:SetMinMaxValues(0, cast.duration)
if cast.channel then
bar:SetValue(cast.duration - current)
else
bar:SetValue(current)
end
bar.text:SetText(cast.name)
bar:SetAlpha(1)
if bar.icon then
if IsCastbarIconEnabled() and cast.texture then
bar.icon.texture:SetTexture(cast.texture)
bar.icon:SetAlpha(1)
bar.icon:Show()
else
bar.icon:Hide()
end
end
bar:Show()
end
end
end
end
end)
function BNP:IsCastbarTestMode()
return castbarTestMode and true or false
end
function BNP:SetCastbarTestMode(enabled)
castbarTestMode = enabled and true or false
if castbarTestMode then
ResetTestCast(GetTime())
else
-- Remove every preview immediately. Real casts are rediscovered on the
-- next lightweight discovery pass without touching their stored state.
local plate
for plate in pairs(activeCastPlates) do
MarkCastPlateInactive(plate)
end
end
if self.optionsFrame and self.optionsFrame.castbarTestCheck then
self.optionsFrame.castbarTestCheck:SetChecked(castbarTestMode)
end
end
function BNP:RefreshCastbarLayout()
local plate
local height = GetCastbarHeight()
for plate in pairs(BNP.plates or {}) do
if plate and plate.BNPCastbar then
plate.BNPCastbar:SetHeight(height)
ApplyCastbarStyle(plate.BNPCastbar)
ApplyCastbarLayout(plate, plate.BNPCastbar)
end
end
end
function BNP:RefreshCastbarStyle()
self:RefreshCastbarLayout()
end
function BNP:RefreshCastbarHeights()
self:RefreshCastbarLayout()
end
function BNP:SetCastbarsEnabled(enabled)
BNP_DB = BNP_DB or {}
BNP_DB.castbars = enabled and true or false
if not enabled then
local plate
for plate in pairs(BNP.plates) do Hide(plate) end
end
self:Print("Nameplate castbars " .. (enabled and "enabled." or "disabled."))
end