BulwarkFrame - compact Earthen Bulwark readout for TurtleWoW
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user