BulwarkFrame v0.2.0

This commit is contained in:
2026-08-17 15:19:45 +02:00
parent 7cd508e2a3
commit 2b6de41d9a
8 changed files with 253 additions and 35 deletions
+74 -7
View File
@@ -7,7 +7,7 @@
-- * nothing is allocated per OnUpdate frame -- no string building, no tables. Text is only
-- pushed when it actually changed, and the loop returns early when there is nothing to draw;
-- * when the buffer is down and we are out of combat the OnUpdate script is detached entirely
-- rather than left spinning on a hidden frame.
-- rather than left spinning -- whether the frame is hidden or merely idle on screen.
BulwarkFrame = BulwarkFrame or {}
local BF = BulwarkFrame
@@ -23,6 +23,13 @@ local demo = nil -- demo mode state, nil when off
local UPDATE_INTERVAL = 0.05
local sinceUpdate = 0
-- Colour-threshold configs, allocated once and refilled in place. Built as table literals inside
-- the update loop they were the only per-tick allocation left in this file: two tables every
-- 50 ms, created purely to be read twice and thrown away. calc.lua reads .red/.yellow and never
-- retains the table, so reusing one instance is safe.
local poolCfg = { red = 0, yellow = 0 }
local timeCfg = { red = 0, yellow = 0 }
-- ---- helpers ----------------------------------------------------------------------------
local function db() return BulwarkFrameDB end
@@ -70,9 +77,14 @@ local function BuildFrame()
frame = CreateFrame("Frame", "BulwarkFrameMain", UIParent)
frame:SetWidth(d.width)
frame:SetHeight(60)
frame:SetPoint(d.point, UIParent, d.relPoint, d.x, d.y)
-- The anchor is applied by ApplyLayout (called at the end of this function), so that reset and
-- the options panel go through the SAME code path a fresh login does. Anchoring here as well
-- would leave two placement sites and let them disagree.
frame:SetScale(d.scale)
frame:SetMovable(true)
-- Without this the frame can be dragged until only a sliver is left on screen, and the
-- documented rescue ('/bulwark reset') then has to be able to fetch it back.
frame:SetClampedToScreen(true)
frame:EnableMouse(not d.locked)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", function() if not db().locked then this:StartMoving() end end)
@@ -153,6 +165,12 @@ function BF.ApplyLayout()
frame:SetWidth(w)
frame:SetScale(d.scale)
frame:EnableMouse(not d.locked)
-- Re-anchor from the saved placement. Without this, '/bulwark reset' (and the panel's Reset
-- button) put CENTER/0/-120 back into the config while the frame itself stayed wherever it was
-- dragged -- so the documented way to rescue a frame from the screen edge did nothing until
-- the next login.
frame:ClearAllPoints()
frame:SetPoint(d.point or "CENTER", UIParent, d.relPoint or "CENTER", d.x or 0, d.y or 0)
if d.showThreshold then
poolBar:ClearAllPoints()
@@ -273,7 +291,8 @@ local function updateDisplay()
if d.showThreshold then
local frac = C.barFraction(stacks)
poolBar:SetValue(frac)
local r, g, b = bucketRGB(C.poolColorName(frac, { red = d.poolRed, yellow = d.poolYellow }))
poolCfg.red, poolCfg.yellow = d.poolRed, d.poolYellow
local r, g, b = bucketRGB(C.poolColorName(frac, poolCfg))
poolBar:SetStatusBarColor(r, g, b)
if d.showThresholdText then
local points = C.poolPoints(stacks, maxHP)
@@ -291,7 +310,8 @@ local function updateDisplay()
if d.showExpiry then
local tf = C.timeFraction(timeLeft, BF.BUFFER_DURATION)
timeBar:SetValue(tf)
local r, g, b = bucketRGB(C.timeColorName(timeLeft, { red = d.timeRed, yellow = d.timeYellow }))
timeCfg.red, timeCfg.yellow = d.timeRed, d.timeYellow
local r, g, b = bucketRGB(C.timeColorName(timeLeft, timeCfg))
-- Dimmed on purpose: same buckets, lower alpha, so the threshold bar stays the primary
-- readout instead of two equally loud bars sitting on top of each other.
timeBar:SetStatusBarColor(r, g, b, d.expiryAlpha or 0.65)
@@ -339,6 +359,16 @@ function BF.RequestUpdate()
local d = db()
local st = BF.state
-- The deliberate hide is honoured FIRST, ahead of demo mode and ahead of the environment
-- hint. Checked further down it was unreachable whenever SuperWoW was missing, so the
-- "SuperWoW required" frame could not be dismissed at all: right-clicking the minimap button
-- flipped the flag and announced "hidden" while the frame stayed on screen.
if d.hidden then
frame:Hide()
frame:SetScript("OnUpdate", nil)
return
end
if demo then
frame:Show()
if not frame:GetScript("OnUpdate") then frame:SetScript("OnUpdate", BF.OnUpdate) end
@@ -355,12 +385,34 @@ function BF.RequestUpdate()
local shouldShow = st.active or st.inCombat or not d.hideWhenInactive
if d.hideOutOfCombat and not st.inCombat then shouldShow = false end
if d.hidden then shouldShow = false end -- explicitly hidden via the minimap button
if shouldShow then
hintText:Hide()
frame:Show()
if not frame:GetScript("OnUpdate") then frame:SetScript("OnUpdate", BF.OnUpdate) end
-- "On screen" and "has work to do" are different questions. With hideWhenInactive off
-- (the default) the frame stays visible while idle -- but once the buffer is down and
-- combat has ended there is nothing left to animate, so the OnUpdate script is still
-- detached: it was otherwise left spinning from login to logout, redrawing a saturated
-- swing marker every rendered frame and walking the whole buff list five times a second
-- for a readout that cannot change. The aura, combat and swing handlers in data.lua each
-- end in a RequestUpdate call and re-attach it, so nothing has to poll for the wakeup.
--
-- A swing still in flight counts as work even with the buffer down and combat over:
-- auto-attacking out of combat is ordinary play, and parking on it would freeze the
-- marker where it stood. Once progress saturates this stops being true and the loop's
-- own RequestUpdate call parks it.
local swinging = d.showSwing and st.swingSpeed and st.swingSpeed > 0
and S.hasSwung(st.lastSwingAt)
and S.progress(st.lastSwingAt, GetTime(), st.swingSpeed, st.swingOffset) < 1
if st.active or st.inCombat or swinging then
if not frame:GetScript("OnUpdate") then frame:SetScript("OnUpdate", BF.OnUpdate) end
else
frame:SetScript("OnUpdate", nil)
-- One last pass, unconditionally: the frame is visible and nothing will tick it, so
-- it has to be left parked on the correct final state rather than on empty text.
updateDisplay()
updateSwingMarker()
end
else
frame:Hide()
frame:SetScript("OnUpdate", nil) -- idle costs nothing at all
@@ -373,7 +425,10 @@ function BF.OnUpdate()
if sinceUpdate < UPDATE_INTERVAL then return end
sinceUpdate = 0
local active = updateDisplay()
if not demo and not active and not BF.state.inCombat and db().hideWhenInactive then
-- Re-evaluated whatever hideWhenInactive says: with it off the frame stays on screen, but the
-- loop still has nothing left to do, and RequestUpdate is what parks it. Gating this on the
-- setting is why the handler used to run for the whole session in the default configuration.
if not demo and not active and not BF.state.inCombat then
BF.RequestUpdate()
end
end
@@ -388,6 +443,13 @@ function BF.ToggleDemo(on)
if on then
demo = { start = GetTime(), maxHP = (UnitHealthMax("player") or 4588) }
if demo.maxHP <= 0 then demo.maxHP = 4588 end
-- Turning demo on is an explicit "show me the frame", so it clears the manual hide.
-- RequestUpdate honours that hide FIRST (it has to -- otherwise the "SuperWoW required"
-- frame cannot be dismissed), which meant '/bulwark demo' over a minimap-hidden frame
-- reported "demo on", drew nothing, ticked nothing, and left the user silently parked in
-- demo mode until they un-hid the frame. Clearing the flag here keeps the precedence
-- where it belongs and still makes the command deliver what it announces.
db().hidden = false
-- Synthetic numbers MUST be labelled as such. Unlabelled demo data is indistinguishable
-- from a live readout that happens to be wrong, and that is the one failure this addon
-- exists to avoid.
@@ -421,6 +483,11 @@ function BF.InitUI()
local ok = BulwarkFrameEnv.check(SUPERWOW_VERSION)
BF.envOk = ok
BF.state.maxHP = UnitHealthMax("player") or 0
-- Seeded here as well as in the PLAYER_ENTERING_WORLD handler: this runs off PLAYER_LOGIN and
-- the order of the two is not guaranteed. PLAYER_REGEN_DISABLED only fires on ENTERING combat,
-- so without a live read a /reload taken mid-fight leaves "Combat only" users with no frame
-- for the rest of the encounter.
BF.state.inCombat = UnitAffectingCombat("player") and true or false
BF.CapturePlayerGuid()
if ok then
BF.RefreshSwingInputs()