Files
BulwarkFrame/ui.lua
T

433 lines
17 KiB
Lua

-- BulwarkFrame - ui.lua
-- The frame: hit-threshold bar, expiry bar, swing line. WoW-API file (parse-checked only).
--
-- Three rules this file follows, all from hard-won ecosystem experience:
-- * every frame gets a name with the addon prefix, so pfDebug's profiler can attribute its
-- cost instead of lumping it under <unnamed>;
-- * 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.
BulwarkFrame = BulwarkFrame or {}
local BF = BulwarkFrame
local C = BulwarkFrameCalc
local S = BulwarkFrameSwing
local frame, poolBar, poolText, timeBar, timeText, speedText, swingLine, swingMark, hintText
local demoTag
-- Which widget the swing marker rides on: its own line, or the expiry bar (pfUI mana-tick style).
local swingHost
local lastPoolText, lastTimeText, lastSpeedText = nil, nil, nil
local demo = nil -- demo mode state, nil when off
local UPDATE_INTERVAL = 0.05
local sinceUpdate = 0
-- ---- helpers ----------------------------------------------------------------------------
local function db() return BulwarkFrameDB end
-- Colour lookup that honours the user's configured colours. calc.lua decides WHICH bucket
-- (red/yellow/green); the RGB values come from the saved config, not from calc's constants.
local function bucketRGB(name)
local d = db()
local c = (name == "green" and d.colorGreen) or (name == "yellow" and d.colorYellow) or d.colorRed
if type(c) ~= "table" then return 0.7, 0.7, 0.7 end
return c[1] or 0.7, c[2] or 0.7, c[3] or 0.7
end
local function applyBackdrop(f, d)
local bg = d.colorBg or { 0, 0, 0, 0.8 }
f:SetBackdrop({
bgFile = "Interface\\Buttons\\WHITE8X8",
edgeFile = "Interface\\Buttons\\WHITE8X8",
tile = false, edgeSize = 1,
insets = { left = 1, right = 1, top = 1, bottom = 1 },
})
f:SetBackdropColor(bg[1] or 0, bg[2] or 0, bg[3] or 0, bg[4] or 0.8)
f:SetBackdropBorderColor(0, 0, 0, 1)
end
local function applyFont(fs, size)
if not fs then return end
if pfUI and pfUI.font_default then
fs:SetFont(pfUI.font_default, size or 10, "OUTLINE")
else
fs:SetFont("Fonts\\FRIZQT__.TTF", size or 10, "OUTLINE")
end
-- Outline plus a hard shadow: the number sits ON the bar, so half of it is over the fill and
-- half over the empty track. Either alone leaves one of the two halves muddy.
fs:SetShadowColor(0, 0, 0, 1)
fs:SetShadowOffset(1, -1)
end
-- ---- construction -----------------------------------------------------------------------
local function BuildFrame()
if frame then return end
local d = db()
frame = CreateFrame("Frame", "BulwarkFrameMain", UIParent)
frame:SetWidth(d.width)
frame:SetHeight(60)
frame:SetPoint(d.point, UIParent, d.relPoint, d.x, d.y)
frame:SetScale(d.scale)
frame:SetMovable(true)
frame:EnableMouse(not d.locked)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", function() if not db().locked then this:StartMoving() end end)
frame:SetScript("OnDragStop", function()
this:StopMovingOrSizing()
local point, _, relPoint, x, y = this:GetPoint()
local dd = db()
dd.point, dd.relPoint, dd.x, dd.y = point, relPoint, x, y
end)
-- Threshold bar: the primary readout. Fill = how full the buffer is.
poolBar = CreateFrame("StatusBar", "BulwarkFramePoolBar", frame)
poolBar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
poolBar:SetMinMaxValues(0, 1)
poolBar:SetValue(0)
applyBackdrop(poolBar, d)
poolText = poolBar:CreateFontString("BulwarkFramePoolText", "OVERLAY")
poolText:SetPoint("CENTER", poolBar, "CENTER", 0, 0)
applyFont(poolText, 10)
-- Expiry bar: deliberately flatter than the threshold bar -- it is secondary information.
timeBar = CreateFrame("StatusBar", "BulwarkFrameTimeBar", frame)
timeBar:SetStatusBarTexture("Interface\\TargetingFrame\\UI-StatusBar")
timeBar:SetMinMaxValues(0, 1)
timeBar:SetValue(0)
applyBackdrop(timeBar, d)
timeText = timeBar:CreateFontString("BulwarkFrameTimeText", "OVERLAY")
timeText:SetPoint("RIGHT", timeBar, "RIGHT", -3, 0)
applyFont(timeText, 9)
-- Current attack speed, on the left of the same bar the swing marker rides. It is the number
-- the marker's travel time is derived from, so having it visible makes the animation checkable
-- instead of something you have to trust -- and it shows haste changing live (Flurry drops it
-- by a fifth, which is exactly what desynced the bar before).
speedText = timeBar:CreateFontString("BulwarkFrameSpeedText", "OVERLAY")
speedText:SetPoint("LEFT", timeBar, "LEFT", 3, 0)
applyFont(speedText, 9)
-- Swing line: a 1-2 px rule with a moving marker. Subordinate by construction, not by
-- colour alone -- it must never compete with the threshold for attention.
swingLine = CreateFrame("Frame", "BulwarkFrameSwingLine", frame)
local lineTex = swingLine:CreateTexture("BulwarkFrameSwingLineTex", "BACKGROUND")
lineTex:SetTexture("Interface\\Buttons\\WHITE8X8")
lineTex:SetVertexColor(0.25, 0.25, 0.28, 0.9)
lineTex:SetAllPoints(swingLine)
swingMark = swingLine:CreateTexture("BulwarkFrameSwingMark", "OVERLAY")
swingMark:SetTexture("Interface\\Buttons\\WHITE8X8")
-- One-line status, shown only when there is nothing else to show (no SuperWoW, buffer never
-- seen, aura ids unconfirmed). An empty frame with no explanation reads as "addon broken".
demoTag = frame:CreateFontString("BulwarkFrameDemoTag", "OVERLAY")
demoTag:SetPoint("BOTTOMLEFT", frame, "TOPLEFT", 0, 1)
applyFont(demoTag, 9)
demoTag:SetTextColor(1, 0.5, 0.2)
demoTag:SetText("DEMO - synthetic values")
demoTag:Hide()
hintText = frame:CreateFontString("BulwarkFrameHintText", "OVERLAY")
hintText:SetPoint("TOPLEFT", frame, "TOPLEFT", 2, -2)
applyFont(hintText, 9)
hintText:SetTextColor(0.7, 0.7, 0.75)
hintText:Hide()
BF.ApplyLayout()
end
-- Recomputes every size/position from the config. Called on load and after any options change,
-- so the panel needs no knowledge of the frame internals.
function BF.ApplyLayout()
if not frame then return end
local d = db()
local w = d.width
local y = 0
frame:SetWidth(w)
frame:SetScale(d.scale)
frame:EnableMouse(not d.locked)
if d.showThreshold then
poolBar:ClearAllPoints()
poolBar:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, y)
poolBar:SetWidth(w)
poolBar:SetHeight(d.barHeight)
poolBar:Show()
y = y - d.barHeight - d.spacing
else
poolBar:Hide()
end
if d.showExpiry then
timeBar:ClearAllPoints()
timeBar:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, y)
timeBar:SetWidth(w)
timeBar:SetHeight(d.timeBarHeight)
timeBar:Show()
y = y - d.timeBarHeight - d.spacing
else
timeBar:Hide()
end
-- Two ways to show the swing. Riding the expiry bar is pfUI's mana-tick idiom: no extra row,
-- and the marker sits where the eye already is. Its own thin line stays available for anyone
-- who wants the swing readable independently of whether a buffer is up.
local rideExpiry = d.swingOnExpiryBar and d.showExpiry
if d.showSwing then
local sc = d.colorSwing or { 0.9, 0.9, 0.95 }
swingMark:SetVertexColor(sc[1], sc[2], sc[3])
swingMark:SetWidth(2)
if rideExpiry then
swingLine:Hide()
swingHost = timeBar
swingMark:SetParent(timeBar)
swingMark:SetHeight(d.timeBarHeight)
else
swingLine:ClearAllPoints()
swingLine:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, y)
swingLine:SetWidth(w)
swingLine:SetHeight(d.swingHeight)
swingLine:Show()
swingHost = swingLine
swingMark:SetParent(swingLine)
swingMark:SetHeight(d.swingHeight + 2)
y = y - d.swingHeight - d.spacing
end
else
swingLine:Hide()
swingMark:Hide()
end
if poolText then if d.showThresholdText then poolText:Show() else poolText:Hide() end end
if timeText then if d.showExpiryText then timeText:Show() else timeText:Hide() end end
if speedText then if d.showSpeedText then speedText:Show() else speedText:Hide() end end
frame:SetHeight(math.max(8, -y))
lastPoolText, lastTimeText, lastSpeedText = nil, nil, nil -- force a text refresh next tick
end
-- ---- update loop ------------------------------------------------------------------------
-- Reads either the live state or the demo generator. Returns stacks, timeLeft, maxHP, active.
local function currentInputs()
if demo then
local t = GetTime() - demo.start
local cycle = math.mod(t, 12)
local stacks, timeLeft
if cycle < 8 then
-- drain the pool over 8 s, expiry ticking down alongside
stacks = math.floor(100 - (cycle / 8) * 100)
timeLeft = 8 - cycle
else
stacks, timeLeft = 0, 0
end
return stacks, timeLeft, demo.maxHP, cycle < 8
end
local st = BF.state
-- Interpolated remaining time: the aura is only re-read when the client fires an aura event,
-- which is far coarser than the display. Counting down from the last reading makes the bar
-- glide instead of stepping, and it re-syncs on every event, so it cannot drift.
local remaining = st.timeLeft
if remaining and remaining > 0 and st.timeLeftAt and st.timeLeftAt > 0 then
remaining = remaining - (GetTime() - st.timeLeftAt)
if remaining < 0 then remaining = 0 end
end
return st.stacks, remaining, st.maxHP, st.active
end
local sinceTimeScan = 0
local function updateDisplay()
local d = db()
-- Re-read the remaining time on a timer, not only when the client fires an aura event: a
-- refresh of an aura that is already up does not reliably produce one, and the interpolation
-- would then keep counting down through a buffer that was reset. Five times a second is far
-- below what the eye resolves on an 8 second bar and costs one walk of the buff list.
if not demo then
sinceTimeScan = sinceTimeScan + UPDATE_INTERVAL
if sinceTimeScan >= 0.2 then
sinceTimeScan = 0
if BF.ScanTime then BF.ScanTime() end
end
end
local stacks, timeLeft, maxHP, active = currentInputs()
-- Weapon speed is re-read on the throttled tick, NOT only when a swing lands. Reading it once
-- per swing freezes whatever haste happened to be up at that moment: measured in game with
-- Flurry rank 5 active the state held 2.24 s while the character had gone back to 2.69 s, so
-- the marker finished a fifth of a second early and drifted out of sync with the real hits.
-- This is the "rescales" reading that core/swing.lua deliberately leaves to the caller -- for a
-- live readout it is the honest one: the bar should describe the swing you are in now.
if not demo then
local main = UnitAttackSpeed("player")
if type(main) == "number" and main > 0 then BF.state.swingSpeed = main end
end
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 }))
poolBar:SetStatusBarColor(r, g, b)
if d.showThresholdText then
local points = C.poolPoints(stacks, maxHP)
local maxPoints = C.maxPool(maxHP)
local txt
if d.displayMode == "shield" then
txt = C.fmtShieldPair(points, maxPoints)
else
txt = C.fmtThresholdPair(points, maxPoints, BF.currentRate(d))
end
if txt ~= lastPoolText then poolText:SetText(txt); lastPoolText = txt end
end
end
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 }))
-- 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)
if d.showExpiryText then
local txt = C.fmtTime(timeLeft)
if txt ~= lastTimeText then timeText:SetText(txt); lastTimeText = txt end
end
-- Attack speed on the left of the same bar. Only pushed when the string actually changes,
-- so a steady speed costs nothing per tick.
if d.showSpeedText then
local sp = demo and 2.6 or BF.state.swingSpeed
local txt = (type(sp) == "number" and sp > 0) and (string.format("%.2f", sp)) or ""
if txt ~= lastSpeedText then speedText:SetText(txt); lastSpeedText = txt end
end
end
return active
end
-- The swing marker is updated on EVERY frame, not on the throttled tick: at 20 Hz a marker that
-- travels the whole bar in ~2.7 s visibly steps instead of gliding. It is cheap enough to justify
-- -- one SetPoint, no string building, no table allocation, and nothing at all while idle because
-- the OnUpdate script is detached when the frame has nothing to show.
local function updateSwingMarker()
local d = db()
if not d.showSwing then return end
local st = BF.state
local speed = demo and 2.6 or st.swingSpeed
local lastAt = demo and (GetTime() - math.mod(GetTime() - demo.start, 2.6)) or st.lastSwingAt
if not (S.hasSwung(lastAt) and speed and speed > 0) then
swingMark:Hide()
return
end
local p = S.progress(lastAt, GetTime(), speed, st.swingOffset)
-- SetPoint replaces the existing anchor of the same type, so ClearAllPoints per frame is
-- unnecessary work.
swingMark:SetPoint("LEFT", swingHost, "LEFT", p * (d.width - 2), 0)
swingMark:Show()
end
-- Decides whether the frame should be on screen at all, and keeps the OnUpdate script attached
-- only while it has something to do.
function BF.RequestUpdate()
if not frame then return end
local d = db()
local st = BF.state
if demo then
frame:Show()
if not frame:GetScript("OnUpdate") then frame:SetScript("OnUpdate", BF.OnUpdate) end
return
end
if not BF.envOk then
frame:Show()
hintText:Show()
hintText:SetText("SuperWoW required")
frame:SetScript("OnUpdate", nil)
return
end
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
else
frame:Hide()
frame:SetScript("OnUpdate", nil) -- idle costs nothing at all
end
end
function BF.OnUpdate()
updateSwingMarker()
sinceUpdate = sinceUpdate + arg1
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
BF.RequestUpdate()
end
end
-- ---- demo mode --------------------------------------------------------------------------
-- Runs the display off a synthetic 12 second cycle so the layout and colours can be judged
-- without a shaman, a fight, or the aura question being settled. This is the difference between
-- "looks about right in my head" and actually seeing the red/yellow/green transitions.
function BF.ToggleDemo(on)
if on == nil then on = (demo == nil) end
if on then
demo = { start = GetTime(), maxHP = (UnitHealthMax("player") or 4588) }
if demo.maxHP <= 0 then demo.maxHP = 4588 end
-- 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.
if demoTag then demoTag:Show() end
else
demo = nil
if demoTag then demoTag:Hide() end
lastPoolText, lastTimeText, lastSpeedText = nil, nil, nil
end
BF.RequestUpdate()
return demo ~= nil
end
function BF.IsDemo() return demo ~= nil end
-- Deliberate show/hide, saved. Separate from the automatic hiding: this is the user saying "get
-- out of the way", not the addon deciding there is nothing to show.
function BF.ToggleFrame(on)
local d = db()
if on == nil then on = d.hidden and true or false end
d.hidden = not on
BF.RequestUpdate()
return not d.hidden
end
-- ---- lifecycle --------------------------------------------------------------------------
function BF.InitUI()
BF.ensureDefaults()
BuildFrame()
local ok = BulwarkFrameEnv.check(SUPERWOW_VERSION)
BF.envOk = ok
BF.state.maxHP = UnitHealthMax("player") or 0
BF.CapturePlayerGuid()
if ok then
BF.RefreshSwingInputs()
BF.ScanAuras()
end
BF.RequestUpdate()
end
function BF.GetFrame() return frame end