BulwarkFrame v0.2.0
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
## Title: BulwarkFrame
|
## Title: BulwarkFrame
|
||||||
## Notes: Compact real-time readout for the shaman Earthen Bulwark. Requires SuperWoW.
|
## Notes: Compact real-time readout for the shaman Earthen Bulwark. Requires SuperWoW.
|
||||||
## Author: ShempError
|
## Author: ShempError
|
||||||
## Version: 0.2.0-dev
|
## Version: 0.2.0
|
||||||
## SavedVariables: BulwarkFrameDB
|
## SavedVariables: BulwarkFrameDB
|
||||||
|
|
||||||
core\env.lua
|
core\env.lua
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to BulwarkFrame are documented here.
|
||||||
|
|
||||||
|
## v0.2.0 — 2026-08-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Max health readout stays accurate through stamina buffs and level-ups.**
|
||||||
|
It was previously only re-read on zone-in and gear changes, so a Fortitude,
|
||||||
|
Gift of the Wild, food buff, or world buff picked up mid-raid left every
|
||||||
|
number on the readout roughly 10% low until the next zone change. It now
|
||||||
|
refreshes on any player max-health change and on level-up.
|
||||||
|
- **"Combat only" no longer gets stuck off after a mid-fight reload.** The
|
||||||
|
combat flag was only ever set by entering or leaving combat, so a `/reload`
|
||||||
|
taken during a fight left it false for the rest of the encounter and hid the
|
||||||
|
frame outright for anyone using the "Combat only" option. It is now seeded
|
||||||
|
from the live combat state on login and on entering the world.
|
||||||
|
- **The frame can no longer get stranded off-screen.** `/bulwark reset` and
|
||||||
|
the options panel's Reset button wrote the default position into the saved
|
||||||
|
settings but never moved the on-screen frame, so a frame dragged to the
|
||||||
|
edge stayed there until the next login. Reset now actually re-anchors the
|
||||||
|
frame, which is also clamped so it cannot be dragged off-screen in the
|
||||||
|
first place.
|
||||||
|
- **Right-clicking the minimap button now reliably hides the "SuperWoW
|
||||||
|
required" hint.** The manual-hide flag was checked after that hint, so
|
||||||
|
without SuperWoW installed the hint frame could never be dismissed, even
|
||||||
|
though the minimap toggle reported it as hidden.
|
||||||
|
- **`/bulwark show` and `/bulwark demo` now actually bring the frame back.**
|
||||||
|
Both commands cleared "hide when idle" and "combat only" but left a manual
|
||||||
|
minimap-toggle hide in place, so the frame stayed off-screen and demo mode
|
||||||
|
ran invisibly. Both commands now clear the manual hide as well, and say so
|
||||||
|
in their chat confirmation.
|
||||||
|
- **The options panel no longer shows stale values after a slash command.**
|
||||||
|
`/bulwark reset`, `/bulwark lock`, `/bulwark show`, and `/bulwark demo`
|
||||||
|
changed settings without refreshing an already-open options panel, so its
|
||||||
|
checkboxes, sliders, and buttons kept showing the pre-command state —
|
||||||
|
nudging a stale slider could even silently undo what the command had just
|
||||||
|
done. The panel now re-syncs itself after every one of these commands.
|
||||||
|
- **The absorb-rate readout no longer shows a number for an unlearned
|
||||||
|
talent.** A talent rank of zero was treated as "no value set" rather than
|
||||||
|
"rank 0", so an additive set-bonus reading could produce a plausible-looking
|
||||||
|
absorb rate for a talent the character never trained. It now correctly
|
||||||
|
shows `--` in that case.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Lower CPU usage while the buffer is inactive.** The update loop now
|
||||||
|
detaches itself once there is nothing left to animate — no active buffer,
|
||||||
|
no swing in progress — instead of redrawing five times a second regardless,
|
||||||
|
and re-attaches automatically the moment a swing lands or the buffer
|
||||||
|
refills.
|
||||||
|
- **Removed the addon's remaining per-tick and per-swing memory
|
||||||
|
allocation.** The colour-threshold lookup and the aura scan that runs on
|
||||||
|
every incoming swing no longer build and discard temporary tables and
|
||||||
|
closures, avoiding needless garbage-collector churn during combat.
|
||||||
@@ -81,10 +81,10 @@ a `git pull`.
|
|||||||
| Command | Does |
|
| Command | Does |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `/bulwark` or `/bulwark options` | opens the options panel (also: left-click the minimap button) |
|
| `/bulwark` or `/bulwark options` | opens the options panel (also: left-click the minimap button) |
|
||||||
| `/bulwark demo` | runs the display off a synthetic 12 s cycle — judge colours and sizes without a fight |
|
| `/bulwark demo` | runs the display off a synthetic 12 s cycle — judge colours and sizes without a fight (clears the manual hide, so the frame is actually on screen) |
|
||||||
| `/bulwark probe` | dumps the live aura / weapon / talent data to chat **and**, with SuperWoW, to `imports\bulwark_probe.txt` |
|
| `/bulwark probe` | dumps the live aura / weapon / talent data to chat **and**, with SuperWoW, to `imports\bulwark_probe.txt` |
|
||||||
| `/bulwark lock` | locks or unlocks dragging |
|
| `/bulwark lock` | locks or unlocks dragging |
|
||||||
| `/bulwark show` | pins the frame visible (turns off hide-when-idle and combat-only) |
|
| `/bulwark show` | pins the frame visible (clears the manual hide and turns off hide-when-idle and combat-only) |
|
||||||
| `/bulwark reset` | restores every setting to its default |
|
| `/bulwark reset` | restores every setting to its default |
|
||||||
|
|
||||||
`/bf` works as a short form of all of them. Right-clicking the minimap button shows or hides the
|
`/bf` works as a short form of all of them. Right-clicking the minimap button shows or hides the
|
||||||
@@ -139,8 +139,15 @@ lua50 tools/luatests/test_calc.lua
|
|||||||
lua50 tools/luatests/test_swing.lua
|
lua50 tools/luatests/test_swing.lua
|
||||||
lua50 tools/luatests/test_env.lua
|
lua50 tools/luatests/test_env.lua
|
||||||
lua50 tools/luatests/test_config.lua
|
lua50 tools/luatests/test_config.lua
|
||||||
|
lua50 tools/luatests/test_slash.lua
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`test_slash.lua` is the exception to "no WoW API offline": it drives the real slash commands and
|
||||||
|
the real options panel against a small widget stub. The questions it asks — which of the three
|
||||||
|
suppressors wins, and whether a command re-syncs an open panel — are pure control flow, and that
|
||||||
|
is reproducible without a client. Rendering, templates and event ordering are not, and stay
|
||||||
|
in-game work.
|
||||||
|
|
||||||
**Looking at the UI without the game:** `node tools/uipreview/render.js` draws the frame as a PNG
|
**Looking at the UI without the game:** `node tools/uipreview/render.js` draws the frame as a PNG
|
||||||
across six states (full → empty, including both colour steps) at 3× into `tools/uipreview/out/`.
|
across six states (full → empty, including both colour steps) at 3× into `tools/uipreview/out/`.
|
||||||
Same geometry and colour buckets as the addon, so the look can be iterated without a client
|
Same geometry and colour buckets as the addon, so the look can be iterated without a client
|
||||||
|
|||||||
+4
-1
@@ -168,7 +168,10 @@ C.RANK_RATE = { [0] = 0, [1] = 0.05, [2] = 0.10, [3] = 0.15 }
|
|||||||
-- neither is assumed; the caller decides once measurement settles it.
|
-- neither is assumed; the caller decides once measurement settles it.
|
||||||
function C.rateForRank(rank, addBonus, multBonus)
|
function C.rateForRank(rank, addBonus, multBonus)
|
||||||
local base = C.RANK_RATE[rank or -1]
|
local base = C.RANK_RATE[rank or -1]
|
||||||
if not base then return 0 end
|
-- `<= 0` and not just `not base`: rank 0 maps to a rate of 0, and 0 is truthy in Lua, so a
|
||||||
|
-- bare nil check would let an additive set bonus resurrect a rate for a talent the character
|
||||||
|
-- has not learned -- exactly the confidently wrong number this guard exists to prevent.
|
||||||
|
if not base or base <= 0 then return 0 end
|
||||||
if type(addBonus) == "number" then base = base + addBonus end
|
if type(addBonus) == "number" then base = base + addBonus end
|
||||||
if type(multBonus) == "number" then base = base * multBonus end
|
if type(multBonus) == "number" then base = base * multBonus end
|
||||||
return base
|
return base
|
||||||
|
|||||||
@@ -32,6 +32,43 @@ BF.state = {
|
|||||||
|
|
||||||
-- ---- buff scan --------------------------------------------------------------------------
|
-- ---- buff scan --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Walks one of the 64-slot unit aura enumerations looking for the buffer, and returns the
|
||||||
|
-- (possibly raised) stack count plus the found flag.
|
||||||
|
--
|
||||||
|
-- Deliberately a file-scope function taking its inputs as arguments rather than a closure built
|
||||||
|
-- inside ScanAuras: as a closure it allocated one object per CALL plus one more per aura SLOT (the
|
||||||
|
-- per-slot pcall wrapper), and ScanAuras is the PLAYER_AURAS_CHANGED handler -- an event that
|
||||||
|
-- fires on every stack change of the buffer, i.e. on every absorbed hit. Roughly two dozen
|
||||||
|
-- throwaway closures per incoming melee swing is exactly the GC churn the rest of this addon
|
||||||
|
-- takes care to avoid.
|
||||||
|
--
|
||||||
|
-- The per-slot pcall went with it. It was never per-slot error recovery either: the old code
|
||||||
|
-- aborted the WHOLE walk as soon as one slot failed, so a single pcall around the walk (see the
|
||||||
|
-- call sites) covers the same ground -- and UnitBuff/UnitDebuff on "player" with an integer index
|
||||||
|
-- does not throw to begin with. The one behavioural difference is that a walk which errors part
|
||||||
|
-- way through now contributes nothing instead of its partial count, which is the safer of the two.
|
||||||
|
--
|
||||||
|
-- Note the id position differs between the two calls -- an easy trap: reading return 4 from
|
||||||
|
-- UnitBuff (or 3 from UnitDebuff) silently yields nil and the aura is never matched.
|
||||||
|
local function scanStacks(st, fn, idPos, poolId, timeId, stacks, found)
|
||||||
|
local i = 1
|
||||||
|
while i <= 64 do
|
||||||
|
local r1, r2, r3, r4 = fn("player", i)
|
||||||
|
if r1 == nil then break end
|
||||||
|
local id = (idPos == 4) and tonumber(r4) or tonumber(r3)
|
||||||
|
if id == poolId or id == timeId then
|
||||||
|
local n = tonumber(r2)
|
||||||
|
if n and n > stacks then
|
||||||
|
stacks = n
|
||||||
|
found = true
|
||||||
|
st.hasPool = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
return stacks, found
|
||||||
|
end
|
||||||
|
|
||||||
-- Scans the player's buffs once and writes pool/time into BF.state.
|
-- Scans the player's buffs once and writes pool/time into BF.state.
|
||||||
-- GetPlayerBuff walks HELPFUL slots; GetPlayerBuffID (SuperWoW) gives the spell id, which is the
|
-- GetPlayerBuff walks HELPFUL slots; GetPlayerBuffID (SuperWoW) gives the spell id, which is the
|
||||||
-- whole reason SuperWoW is a hard requirement -- matching the icon texture instead would collide
|
-- whole reason SuperWoW is a hard requirement -- matching the icon texture instead would collide
|
||||||
@@ -83,28 +120,17 @@ function BF.ScanAuras()
|
|||||||
-- The durability aura runs as a DEBUFF, and the HELPFUL player-buff enumeration above never
|
-- The durability aura runs as a DEBUFF, and the HELPFUL player-buff enumeration above never
|
||||||
-- lists it. Reading only that API is why a full buffer reported a single stack -- and one stack
|
-- lists it. Reading only that API is why a full buffer reported a single stack -- and one stack
|
||||||
-- renders as 61 against a maximum of 6117: a plausible number, wrong by a factor of a hundred.
|
-- renders as 61 against a maximum of 6117: a plausible number, wrong by a factor of a hundred.
|
||||||
-- Note the id position differs between the two calls -- an easy trap: reading return 4 from
|
--
|
||||||
-- UnitBuff (or 3 from UnitDebuff) silently yields nil and the aura is never matched.
|
-- The walk itself lives at file scope (see scanStacks above). pcall is handed the arguments
|
||||||
local scanStacks = function(fn, idPos)
|
-- directly instead of a wrapper closure, so this path allocates nothing at all.
|
||||||
local i = 1
|
if UnitDebuff then
|
||||||
while i <= 64 do
|
local ok, s, f = pcall(scanStacks, st, UnitDebuff, 4, poolId, timeId, stacks, found)
|
||||||
local r1, r2, r3, r4
|
if ok then stacks, found = s, f end
|
||||||
local ok = pcall(function() r1, r2, r3, r4 = fn("player", i) end)
|
end
|
||||||
if not ok or r1 == nil then break end
|
if UnitBuff then
|
||||||
local id = (idPos == 4) and tonumber(r4) or tonumber(r3)
|
local ok, s, f = pcall(scanStacks, st, UnitBuff, 3, poolId, timeId, stacks, found)
|
||||||
if id == poolId or id == timeId then
|
if ok then stacks, found = s, f end
|
||||||
local n = tonumber(r2)
|
|
||||||
if n and n > stacks then
|
|
||||||
stacks = n
|
|
||||||
found = true
|
|
||||||
st.hasPool = true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
i = i + 1
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
if UnitDebuff then pcall(function() scanStacks(UnitDebuff, 4) end) end
|
|
||||||
if UnitBuff then pcall(function() scanStacks(UnitBuff, 3) end) end
|
|
||||||
|
|
||||||
-- A buffer aura with no applications at all still means "buffer up": 1.12 reports a
|
-- A buffer aura with no applications at all still means "buffer up": 1.12 reports a
|
||||||
-- single-stack aura without a count, and treating that as 0 would blank the bar while it is
|
-- single-stack aura without a count, and treating that as 0 would blank the bar while it is
|
||||||
@@ -179,6 +205,15 @@ driver:RegisterEvent("PLAYER_REGEN_DISABLED")
|
|||||||
driver:RegisterEvent("PLAYER_REGEN_ENABLED")
|
driver:RegisterEvent("PLAYER_REGEN_ENABLED")
|
||||||
driver:RegisterEvent("UNIT_INVENTORY_CHANGED")
|
driver:RegisterEvent("UNIT_INVENTORY_CHANGED")
|
||||||
|
|
||||||
|
-- Max health is the base of EVERY number on screen (maxPool = maxHP/5), so it must not be read
|
||||||
|
-- only on zone-in and gear swaps. A stamina buff landing at the raid entrance -- Fortitude, Gift
|
||||||
|
-- of the Wild, food, a world buff -- moves max health without either of those events, and the
|
||||||
|
-- readout would then be ~10% low for the whole encounter. UNIT_MAXHEALTH carries a unit token in
|
||||||
|
-- arg1 and fires for every group member, so it has to be filtered; PLAYER_LEVEL_UP carries the
|
||||||
|
-- new level in arg1 and must NOT go through that filter.
|
||||||
|
driver:RegisterEvent("UNIT_MAXHEALTH")
|
||||||
|
driver:RegisterEvent("PLAYER_LEVEL_UP")
|
||||||
|
|
||||||
-- SuperWoW's UNIT_CASTEVENT is the swing source: arg3 is "MAINHAND"/"OFFHAND" for auto-attacks.
|
-- SuperWoW's UNIT_CASTEVENT is the swing source: arg3 is "MAINHAND"/"OFFHAND" for auto-attacks.
|
||||||
-- It is language independent and fires on misses too, unlike parsing combat-log text -- which is
|
-- It is language independent and fires on misses too, unlike parsing combat-log text -- which is
|
||||||
-- how the third-party swing timers get it wrong on a dodge.
|
-- how the third-party swing timers get it wrong on a dodge.
|
||||||
@@ -192,15 +227,32 @@ driver:SetScript("OnEvent", function()
|
|||||||
if arg3 == "MAINHAND" and arg1 == BF.playerGuid then
|
if arg3 == "MAINHAND" and arg1 == BF.playerGuid then
|
||||||
BF.state.lastSwingAt = GetTime()
|
BF.state.lastSwingAt = GetTime()
|
||||||
BF.RefreshSwingInputs()
|
BF.RefreshSwingInputs()
|
||||||
|
-- Redraw explicitly, because this branch returns before the shared RequestUpdate at
|
||||||
|
-- the end of the handler. Without it a swing landing while idle and out of combat
|
||||||
|
-- never re-attaches the update loop and the marker simply does not move -- the one
|
||||||
|
-- wakeup path that is neither an aura nor a combat event. The call stays INSIDE the
|
||||||
|
-- main-hand test on purpose: UNIT_CASTEVENT fires for every cast of every unit in
|
||||||
|
-- range, and redrawing on all of them is exactly the churn this addon avoids.
|
||||||
|
if BF.RequestUpdate then BF.RequestUpdate() end
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
if event == "PLAYER_REGEN_DISABLED" then
|
if event == "UNIT_MAXHEALTH" then
|
||||||
|
-- Health only: no GUID re-capture, no swing re-read. This fires for party and raid
|
||||||
|
-- members too, hence the filter.
|
||||||
|
if arg1 == "player" then BF.state.maxHP = UnitHealthMax("player") or 0 end
|
||||||
|
elseif event == "PLAYER_LEVEL_UP" then
|
||||||
|
BF.state.maxHP = UnitHealthMax("player") or 0
|
||||||
|
elseif event == "PLAYER_REGEN_DISABLED" then
|
||||||
BF.state.inCombat = true
|
BF.state.inCombat = true
|
||||||
elseif event == "PLAYER_REGEN_ENABLED" then
|
elseif event == "PLAYER_REGEN_ENABLED" then
|
||||||
BF.state.inCombat = false
|
BF.state.inCombat = false
|
||||||
elseif event == "PLAYER_ENTERING_WORLD" or event == "UNIT_INVENTORY_CHANGED" then
|
elseif event == "PLAYER_ENTERING_WORLD" or event == "UNIT_INVENTORY_CHANGED" then
|
||||||
BF.state.maxHP = UnitHealthMax("player") or 0
|
BF.state.maxHP = UnitHealthMax("player") or 0
|
||||||
|
-- Seeded from the live API, not only from the REGEN transitions: those fire on ENTERING
|
||||||
|
-- combat, never on login, so a /reload taken mid-fight would leave the flag false for the
|
||||||
|
-- rest of the encounter and strand anyone running "Combat only".
|
||||||
|
BF.state.inCombat = UnitAffectingCombat("player") and true or false
|
||||||
BF.CapturePlayerGuid()
|
BF.CapturePlayerGuid()
|
||||||
BF.RefreshSwingInputs()
|
BF.RefreshSwingInputs()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,29 +23,58 @@ SLASH_BULWARKFRAME2 = "/bf"
|
|||||||
SlashCmdList["BULWARKFRAME"] = function(input)
|
SlashCmdList["BULWARKFRAME"] = function(input)
|
||||||
local cmd = string.lower(input or "")
|
local cmd = string.lower(input or "")
|
||||||
-- No string.match / no ':' string methods -- Lua 5.0.
|
-- No string.match / no ':' string methods -- Lua 5.0.
|
||||||
|
--
|
||||||
|
-- Set by every branch that writes BulwarkFrameDB. The options panel is built once and its
|
||||||
|
-- widgets then hold their own state, so a slash command that changes a setting behind an open
|
||||||
|
-- panel left every checkbox, slider and cycle button showing the pre-command values -- and the
|
||||||
|
-- first nudge of a stale slider writes the discarded value straight back over the change. The
|
||||||
|
-- panel's own Reset button already calls BF.RefreshOptions for exactly this reason; collecting
|
||||||
|
-- it here gives the slash side the same treatment, and keeps a future command from silently
|
||||||
|
-- drifting out of sync again.
|
||||||
|
local dirty = false
|
||||||
if cmd == "" or cmd == "options" or cmd == "config" then
|
if cmd == "" or cmd == "options" or cmd == "config" then
|
||||||
BF.ToggleOptions()
|
BF.ToggleOptions()
|
||||||
elseif cmd == "demo" then
|
elseif cmd == "demo" then
|
||||||
|
-- Read before the toggle: BF.ToggleDemo clears the manual hide so the frame it promises
|
||||||
|
-- actually appears, and that is worth saying rather than leaving the user to wonder why
|
||||||
|
-- an unrelated setting moved.
|
||||||
|
local wasHidden = BulwarkFrameDB.hidden and true or false
|
||||||
local on = BF.ToggleDemo()
|
local on = BF.ToggleDemo()
|
||||||
msg("demo " .. (on and "on" or "off"))
|
if on and wasHidden then
|
||||||
|
msg("demo on (manual hide cleared)")
|
||||||
|
else
|
||||||
|
msg("demo " .. (on and "on" or "off"))
|
||||||
|
end
|
||||||
|
dirty = true
|
||||||
elseif cmd == "probe" then
|
elseif cmd == "probe" then
|
||||||
BF.RunProbe()
|
BF.RunProbe()
|
||||||
elseif cmd == "lock" then
|
elseif cmd == "lock" then
|
||||||
BulwarkFrameDB.locked = not BulwarkFrameDB.locked
|
BulwarkFrameDB.locked = not BulwarkFrameDB.locked
|
||||||
BF.ApplyLayout()
|
BF.ApplyLayout()
|
||||||
msg(BulwarkFrameDB.locked and "frame locked" or "frame unlocked")
|
msg(BulwarkFrameDB.locked and "frame locked" or "frame unlocked")
|
||||||
|
dirty = true
|
||||||
elseif cmd == "reset" then
|
elseif cmd == "reset" then
|
||||||
BF.resetConfig()
|
BF.resetConfig()
|
||||||
BF.ApplyLayout()
|
BF.ApplyLayout()
|
||||||
BF.RequestUpdate()
|
BF.RequestUpdate()
|
||||||
BF.UpdateMinimapButton()
|
BF.UpdateMinimapButton()
|
||||||
msg("settings reset to defaults")
|
msg("settings reset to defaults")
|
||||||
|
dirty = true
|
||||||
elseif cmd == "show" then
|
elseif cmd == "show" then
|
||||||
|
-- All THREE suppressors, not just the two automatic ones. The manual hide (minimap
|
||||||
|
-- right-click) is a SavedVariable and overrides both of them in RequestUpdate, so leaving
|
||||||
|
-- it set meant '/bulwark show' reported success while the frame stayed off screen -- and
|
||||||
|
-- no other command except a full reset could clear it.
|
||||||
|
BulwarkFrameDB.hidden = false
|
||||||
BulwarkFrameDB.hideWhenInactive = false
|
BulwarkFrameDB.hideWhenInactive = false
|
||||||
BulwarkFrameDB.hideOutOfCombat = false
|
BulwarkFrameDB.hideOutOfCombat = false
|
||||||
BF.RequestUpdate()
|
BF.RequestUpdate()
|
||||||
msg("frame pinned visible (hide-when-idle and combat-only turned off)")
|
msg("frame pinned visible (manual hide, hide-when-idle and combat-only turned off)")
|
||||||
|
dirty = true
|
||||||
else
|
else
|
||||||
msg("commands: options | demo | probe | lock | show | reset")
|
msg("commands: options | demo | probe | lock | show | reset")
|
||||||
end
|
end
|
||||||
|
-- Safe unconditionally: the refresher list is empty until the panel has been built, so this is
|
||||||
|
-- a no-op for anyone who never opened it.
|
||||||
|
if dirty and BF.RefreshOptions then BF.RefreshOptions() end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -434,6 +434,12 @@ local function BuildPanel()
|
|||||||
this:SetText(on and "Demo: on" or "Demo")
|
this:SetText(on and "Demo: on" or "Demo")
|
||||||
end)
|
end)
|
||||||
demoBtn:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 24, 20)
|
demoBtn:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 24, 20)
|
||||||
|
-- The one stateful widget that had no refresher, unlike modeBtn and setBtn above: demo mode can
|
||||||
|
-- also be toggled from '/bulwark demo', and without this the button kept whatever label it last
|
||||||
|
-- set itself -- reading "Demo" over a running demo, or "Demo: on" over a live readout.
|
||||||
|
table.insert(refreshers, function()
|
||||||
|
demoBtn:SetText(BF.IsDemo() and "Demo: on" or "Demo")
|
||||||
|
end)
|
||||||
|
|
||||||
local probeBtn = CreateButton(panel, "Probe", "Dump the live aura/weapon data we still need "
|
local probeBtn = CreateButton(panel, "Probe", "Dump the live aura/weapon data we still need "
|
||||||
.. "(also /bulwark probe).", function() BF.RunProbe() end)
|
.. "(also /bulwark probe).", function() BF.RunProbe() end)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
-- * nothing is allocated per OnUpdate frame -- no string building, no tables. Text is only
|
-- * 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;
|
-- 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
|
-- * 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 {}
|
BulwarkFrame = BulwarkFrame or {}
|
||||||
local BF = BulwarkFrame
|
local BF = BulwarkFrame
|
||||||
@@ -23,6 +23,13 @@ local demo = nil -- demo mode state, nil when off
|
|||||||
local UPDATE_INTERVAL = 0.05
|
local UPDATE_INTERVAL = 0.05
|
||||||
local sinceUpdate = 0
|
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 ----------------------------------------------------------------------------
|
-- ---- helpers ----------------------------------------------------------------------------
|
||||||
|
|
||||||
local function db() return BulwarkFrameDB end
|
local function db() return BulwarkFrameDB end
|
||||||
@@ -70,9 +77,14 @@ local function BuildFrame()
|
|||||||
frame = CreateFrame("Frame", "BulwarkFrameMain", UIParent)
|
frame = CreateFrame("Frame", "BulwarkFrameMain", UIParent)
|
||||||
frame:SetWidth(d.width)
|
frame:SetWidth(d.width)
|
||||||
frame:SetHeight(60)
|
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:SetScale(d.scale)
|
||||||
frame:SetMovable(true)
|
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:EnableMouse(not d.locked)
|
||||||
frame:RegisterForDrag("LeftButton")
|
frame:RegisterForDrag("LeftButton")
|
||||||
frame:SetScript("OnDragStart", function() if not db().locked then this:StartMoving() end end)
|
frame:SetScript("OnDragStart", function() if not db().locked then this:StartMoving() end end)
|
||||||
@@ -153,6 +165,12 @@ function BF.ApplyLayout()
|
|||||||
frame:SetWidth(w)
|
frame:SetWidth(w)
|
||||||
frame:SetScale(d.scale)
|
frame:SetScale(d.scale)
|
||||||
frame:EnableMouse(not d.locked)
|
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
|
if d.showThreshold then
|
||||||
poolBar:ClearAllPoints()
|
poolBar:ClearAllPoints()
|
||||||
@@ -273,7 +291,8 @@ local function updateDisplay()
|
|||||||
if d.showThreshold then
|
if d.showThreshold then
|
||||||
local frac = C.barFraction(stacks)
|
local frac = C.barFraction(stacks)
|
||||||
poolBar:SetValue(frac)
|
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)
|
poolBar:SetStatusBarColor(r, g, b)
|
||||||
if d.showThresholdText then
|
if d.showThresholdText then
|
||||||
local points = C.poolPoints(stacks, maxHP)
|
local points = C.poolPoints(stacks, maxHP)
|
||||||
@@ -291,7 +310,8 @@ local function updateDisplay()
|
|||||||
if d.showExpiry then
|
if d.showExpiry then
|
||||||
local tf = C.timeFraction(timeLeft, BF.BUFFER_DURATION)
|
local tf = C.timeFraction(timeLeft, BF.BUFFER_DURATION)
|
||||||
timeBar:SetValue(tf)
|
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
|
-- 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.
|
-- readout instead of two equally loud bars sitting on top of each other.
|
||||||
timeBar:SetStatusBarColor(r, g, b, d.expiryAlpha or 0.65)
|
timeBar:SetStatusBarColor(r, g, b, d.expiryAlpha or 0.65)
|
||||||
@@ -339,6 +359,16 @@ function BF.RequestUpdate()
|
|||||||
local d = db()
|
local d = db()
|
||||||
local st = BF.state
|
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
|
if demo then
|
||||||
frame:Show()
|
frame:Show()
|
||||||
if not frame:GetScript("OnUpdate") then frame:SetScript("OnUpdate", BF.OnUpdate) end
|
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
|
local shouldShow = st.active or st.inCombat or not d.hideWhenInactive
|
||||||
if d.hideOutOfCombat and not st.inCombat then shouldShow = false end
|
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
|
if shouldShow then
|
||||||
hintText:Hide()
|
hintText:Hide()
|
||||||
frame:Show()
|
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
|
else
|
||||||
frame:Hide()
|
frame:Hide()
|
||||||
frame:SetScript("OnUpdate", nil) -- idle costs nothing at all
|
frame:SetScript("OnUpdate", nil) -- idle costs nothing at all
|
||||||
@@ -373,7 +425,10 @@ function BF.OnUpdate()
|
|||||||
if sinceUpdate < UPDATE_INTERVAL then return end
|
if sinceUpdate < UPDATE_INTERVAL then return end
|
||||||
sinceUpdate = 0
|
sinceUpdate = 0
|
||||||
local active = updateDisplay()
|
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()
|
BF.RequestUpdate()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -388,6 +443,13 @@ function BF.ToggleDemo(on)
|
|||||||
if on then
|
if on then
|
||||||
demo = { start = GetTime(), maxHP = (UnitHealthMax("player") or 4588) }
|
demo = { start = GetTime(), maxHP = (UnitHealthMax("player") or 4588) }
|
||||||
if demo.maxHP <= 0 then demo.maxHP = 4588 end
|
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
|
-- 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
|
-- from a live readout that happens to be wrong, and that is the one failure this addon
|
||||||
-- exists to avoid.
|
-- exists to avoid.
|
||||||
@@ -421,6 +483,11 @@ function BF.InitUI()
|
|||||||
local ok = BulwarkFrameEnv.check(SUPERWOW_VERSION)
|
local ok = BulwarkFrameEnv.check(SUPERWOW_VERSION)
|
||||||
BF.envOk = ok
|
BF.envOk = ok
|
||||||
BF.state.maxHP = UnitHealthMax("player") or 0
|
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()
|
BF.CapturePlayerGuid()
|
||||||
if ok then
|
if ok then
|
||||||
BF.RefreshSwingInputs()
|
BF.RefreshSwingInputs()
|
||||||
|
|||||||
Reference in New Issue
Block a user