BulwarkFrame - compact Earthen Bulwark readout for TurtleWoW
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
## Interface: 11200
|
||||||
|
## Title: BulwarkFrame
|
||||||
|
## Notes: Compact real-time readout for the shaman Earthen Bulwark. Requires SuperWoW.
|
||||||
|
## Author: ShempError
|
||||||
|
## Version: 0.2.0-dev
|
||||||
|
## SavedVariables: BulwarkFrameDB
|
||||||
|
|
||||||
|
core\env.lua
|
||||||
|
core\calc.lua
|
||||||
|
core\swing.lua
|
||||||
|
core\config.lua
|
||||||
|
data.lua
|
||||||
|
ui.lua
|
||||||
|
options.lua
|
||||||
|
minimap.lua
|
||||||
|
probe.lua
|
||||||
|
main.lua
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
# BulwarkFrame
|
||||||
|
|
||||||
|
A compact real-time readout for the shaman **Earthen Bulwark** on **TurtleWoW / WoW 1.12.1
|
||||||
|
(Vanilla)**.
|
||||||
|
|
||||||
|
It answers one question at a glance: *how big would a single incoming hit have to be to use up my
|
||||||
|
buffer right now?* — alongside how long the buffer still lasts and where the auto-attack swing
|
||||||
|
stands. Three thin bars, nothing else. No dependencies beyond SuperWoW; adopts the
|
||||||
|
[pfUI](https://github.com/shagu/pfUI) look automatically when present.
|
||||||
|
|
||||||
|
<img src="screenshots/readout-hit.png" alt="The readout: hit threshold, expiry bar with the swing marker riding on it" width="100%">
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| The readout | Options panel |
|
||||||
|
| :---: | :---: |
|
||||||
|
| <img src="screenshots/readout-hit.png" alt="Max hit: how large a single hit may be" width="100%"><br><img src="screenshots/readout-shield.png" alt="Max shield: how much damage the buffer still absorbs" width="100%"> | <img src="screenshots/options.png" alt="Options panel" width="70%"> |
|
||||||
|
|
||||||
|
The two readouts show the same buffer answering different questions: **2875 / 6117** is the
|
||||||
|
largest single hit it would still swallow, **807 / 918** is how much damage it absorbs. On the
|
||||||
|
lower bar, `2.24` is the current attack speed and the white marker is the swing, riding the expiry
|
||||||
|
bar the way pfUI's mana tick rides the mana bar.
|
||||||
|
|
||||||
|
*(Click any image for full size.)*
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
**The readout** — three elements, no portrait, no name, no health or mana, no title:
|
||||||
|
|
||||||
|
1. **Hit threshold** — `current / maximum`, e.g. `2875 / 6117`. The bar's fill is the buffer's
|
||||||
|
fill. One flat colour by threshold (red / yellow / green), no gradient. Switchable to
|
||||||
|
**max shield** (`807 / 918`), which is the same buffer expressed as damage absorbed rather
|
||||||
|
than as the hit that would drain it.
|
||||||
|
2. **Expiry** — remaining seconds as a number *and* as the bar's width, over the buffer's
|
||||||
|
8 second lifetime. Re-read five times a second, so it glides rather than steps.
|
||||||
|
3. **Swing timer** — a marker riding the expiry bar (or its own 1–2 px line, if you prefer),
|
||||||
|
with the current attack speed printed on the left. Deliberately subordinate.
|
||||||
|
|
||||||
|
**How the buffer works** (from the Elemental Weapons tooltip, verbatim): *"Rockbiter: Physical
|
||||||
|
damage builds up an earthen bulwark equal to 20% of damage dealt, tripled while wearing a shield.
|
||||||
|
The bulwark absorbs 15% of incoming damage for 8 sec or until it mitigates enough damage. Its
|
||||||
|
durability cannot exceed 20% of maximum health."*
|
||||||
|
|
||||||
|
Note the asymmetry, because it is easy to get backwards: the pool is built **only** by physical
|
||||||
|
damage you deal, but it absorbs **incoming damage of any kind** — spell damage included.
|
||||||
|
|
||||||
|
**Measured, not guessed.** The numbers the addon computes with were verified in game rather than
|
||||||
|
taken from a wiki: the fill level is the stack count of aura 58127, a full pool is 20 % of max
|
||||||
|
health, and the absorb rate at rank 3 is 15 % (median over 477 hits) — matching what the tooltip
|
||||||
|
claims.
|
||||||
|
|
||||||
|
**Honest about what it does not know.** Where a value cannot be established — an unknown talent
|
||||||
|
rank, a missing aura — the frame shows `--` rather than a number derived from a guess.
|
||||||
|
|
||||||
|
**What it deliberately is not:** no statistics, no combat reports, no graphs, no history. It is a
|
||||||
|
live readout. Recording and analysis belong elsewhere.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
**Option A — download (simplest):**
|
||||||
|
|
||||||
|
1. Grab **`BulwarkFrame-vX.Y.Z.zip`** from
|
||||||
|
[**Releases**](https://github.com/ShempError/BulwarkFrame/releases).
|
||||||
|
*(Use the Release zip, not "Code → Download ZIP" — that names the folder
|
||||||
|
`BulwarkFrame-master`, which WoW won't load.)*
|
||||||
|
2. Extract the **`BulwarkFrame`** folder into `Interface\AddOns\`.
|
||||||
|
3. Restart the client.
|
||||||
|
|
||||||
|
**Option B — git (auto-updatable):**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd Interface/AddOns
|
||||||
|
git clone https://github.com/ShempError/BulwarkFrame.git BulwarkFrame
|
||||||
|
```
|
||||||
|
|
||||||
|
`master` is the stable channel — git-based managers (GitAddonsManager, OctoWoW) stay current with
|
||||||
|
a `git pull`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
| Command | Does |
|
||||||
|
|---|---|
|
||||||
|
| `/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 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 show` | pins the frame visible (turns off hide-when-idle and combat-only) |
|
||||||
|
| `/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
|
||||||
|
readout.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
**Frame** — lock, scale, width, per-bar heights, minimap button, hide-when-idle, combat-only.
|
||||||
|
**Elements** — each of the three readouts and every text label individually; swing marker on the
|
||||||
|
expiry bar or on its own line; max hit vs. max shield.
|
||||||
|
**Colours** — the three threshold colours and the swing marker, via the standard colour picker.
|
||||||
|
**Absorb model** — talent rank (5 / 10 / 15 %), the set-bonus reading, and whether the swing
|
||||||
|
marker is latency-adjusted.
|
||||||
|
|
||||||
|
Every change applies immediately — an options panel whose effect only shows after `/reload`
|
||||||
|
teaches you to distrust it.
|
||||||
|
|
||||||
|
### A note on units
|
||||||
|
|
||||||
|
The threshold is in **post-mitigation** damage — the same numbers your combat log shows. The
|
||||||
|
buffer sits behind armor, block, resist and Stoneskin, so it never sees a boss's raw swing.
|
||||||
|
Compare the displayed value against the damage you actually take, not against a tooltip.
|
||||||
|
|
||||||
|
### The Tier 2.5 set bonus
|
||||||
|
|
||||||
|
The bonus is documented as "+3 % absorption" and it is *not* established whether that means
|
||||||
|
15 → 18 or 15 × 1.03. Both readings are selectable and neither is assumed; the default is off.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
**[SuperWoW](https://github.com/balakethelock/SuperWoW) is required.** The addon stays inactive
|
||||||
|
without it and says so once on login.
|
||||||
|
|
||||||
|
This is not a soft preference. The buffer's fill level is read from a specific aura *by spell id*,
|
||||||
|
which needs SuperWoW; the fallback would be matching on the icon texture, which several unrelated
|
||||||
|
auras share. That would produce a number that is confidently wrong rather than absent, and a wrong
|
||||||
|
number on a defensive readout is worse than no readout.
|
||||||
|
|
||||||
|
The swing timer likewise resets on SuperWoW's `UNIT_CASTEVENT`, which is language independent and
|
||||||
|
fires on misses too — unlike parsing combat-log text.
|
||||||
|
|
||||||
|
Nothing else is required. No other addon, no client patch.
|
||||||
|
|
||||||
|
## For developers
|
||||||
|
|
||||||
|
The calculation core is pure Lua with no WoW API, so it runs offline against a real Lua 5.0.3
|
||||||
|
interpreter — the same version the 1.12 client uses, which is what catches `#table`, `str:find()`
|
||||||
|
and `string.match` before they reach the game.
|
||||||
|
|
||||||
|
```
|
||||||
|
lua50 tools/luatests/test_calc.lua
|
||||||
|
lua50 tools/luatests/test_swing.lua
|
||||||
|
lua50 tools/luatests/test_env.lua
|
||||||
|
lua50 tools/luatests/test_config.lua
|
||||||
|
```
|
||||||
|
|
||||||
|
**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/`.
|
||||||
|
Same geometry and colour buckets as the addon, so the look can be iterated without a client
|
||||||
|
restart per change.
|
||||||
|
|
||||||
|
**Panel art:** `node tools/gen_panel_textures.js` regenerates the options panel's background and
|
||||||
|
border textures. Zero dependencies, and it verifies its own TGA headers and tiling seams.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT.
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
-- BulwarkFrame -- pure calculation core.
|
||||||
|
--
|
||||||
|
-- Deliberately free of any WoW API: everything here is arithmetic over three inputs the
|
||||||
|
-- display layer supplies (max health, the aura's stack count, the talent rank). That keeps
|
||||||
|
-- the error-prone part testable offline under real Lua 5.0.3, which is where the 1.12 traps
|
||||||
|
-- (#table, string methods via ':', string.match) actually bite.
|
||||||
|
--
|
||||||
|
-- The constants below are not folklore. All three were measured in-game on 2026-07-28:
|
||||||
|
-- * the pool is encoded as the stack count of aura 58127, capped at 100
|
||||||
|
-- * a full pool is 20% of max health => 1 stack = MaxHP/500 (4588 HP -> 9.176 points)
|
||||||
|
-- * the absorb rate at talent rank 3 is 15.0% (median over 477 hits)
|
||||||
|
-- The rate is NOT hardcoded here: rank 1/2/3 map to 5/10/15%, and a set bonus can be layered
|
||||||
|
-- on top, because the T2.5 bonus ("+3% absorption") is not yet understood -- see rateForRank.
|
||||||
|
|
||||||
|
BulwarkFrameCalc = {}
|
||||||
|
local C = BulwarkFrameCalc
|
||||||
|
|
||||||
|
local MAX_STACKS = 100 -- the aura caps here; more is not possible
|
||||||
|
|
||||||
|
local function clamp(v, lo, hi)
|
||||||
|
if v < lo then return lo end
|
||||||
|
if v > hi then return hi end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
local function round(v) return math.floor(v + 0.5) end
|
||||||
|
|
||||||
|
-- ---- points ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- maxPool(maxHP): the full buffer in absorb points -- 20% of max health.
|
||||||
|
-- Written as maxHP/5 rather than maxHP*0.20 so it is a single exact division; the stack scale
|
||||||
|
-- below is derived FROM it, so the bar and the numbers can never drift apart.
|
||||||
|
function C.maxPool(maxHP)
|
||||||
|
if type(maxHP) ~= "number" or maxHP <= 0 then return 0 end
|
||||||
|
return maxHP / 5
|
||||||
|
end
|
||||||
|
|
||||||
|
-- stackPoints(maxHP): what one stack of aura 58127 is worth, in absorb points.
|
||||||
|
function C.stackPoints(maxHP)
|
||||||
|
return C.maxPool(maxHP) / MAX_STACKS
|
||||||
|
end
|
||||||
|
|
||||||
|
-- poolPoints(stacks, maxHP): the current buffer in absorb points.
|
||||||
|
function C.poolPoints(stacks, maxHP)
|
||||||
|
if type(stacks) ~= "number" then return 0 end
|
||||||
|
return C.maxPool(maxHP) * (clamp(stacks, 0, MAX_STACKS) / MAX_STACKS)
|
||||||
|
end
|
||||||
|
|
||||||
|
function C.roundPoints(p)
|
||||||
|
if type(p) ~= "number" then return 0 end
|
||||||
|
return round(p)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- the headline number ------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- threshold(points, rate): how large a single incoming hit would have to be for the buffer to
|
||||||
|
-- swallow `points` of it -- i.e. to be drained by that one hit.
|
||||||
|
--
|
||||||
|
-- UNIT NOTE, easy to misread: this is POST-MITIGATION damage. The buffer sits behind armor,
|
||||||
|
-- block, resist and Stoneskin (all verified 2026-07-28), so the number is directly comparable
|
||||||
|
-- to the damage figures in the combat log, NOT to a boss's raw swing.
|
||||||
|
--
|
||||||
|
-- Returns nil for a missing or zero rate (talent not learned) instead of dividing by zero: an
|
||||||
|
-- "inf" on screen is worse than an empty frame.
|
||||||
|
function C.threshold(points, rate)
|
||||||
|
if type(rate) ~= "number" or rate <= 0 then return nil end
|
||||||
|
if type(points) ~= "number" then return nil end
|
||||||
|
return points / rate
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- bar fill -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- barFraction(stacks): how full the pool bar is, 0..1.
|
||||||
|
--
|
||||||
|
-- This is exactly stacks/100 -- not a shortcut but the exact answer. The pair shown on the bar
|
||||||
|
-- is (pool/rate) over (maxPool/rate); both the rate and max health cancel out, so the fill
|
||||||
|
-- carries NO rounding error even though the two displayed numbers are rounded.
|
||||||
|
function C.barFraction(stacks)
|
||||||
|
if type(stacks) ~= "number" then return 0 end
|
||||||
|
return clamp(stacks, 0, MAX_STACKS) / MAX_STACKS
|
||||||
|
end
|
||||||
|
|
||||||
|
-- timeFraction(timeLeft, duration): how full the expiry bar is, 0..1.
|
||||||
|
-- A refresh can report slightly more than the nominal duration -> clamped, not trusted.
|
||||||
|
function C.timeFraction(timeLeft, duration)
|
||||||
|
if type(timeLeft) ~= "number" or type(duration) ~= "number" or duration <= 0 then return 0 end
|
||||||
|
return clamp(timeLeft / duration, 0, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- colours ------------------------------------------------------------------------------
|
||||||
|
-- One flat colour for the whole filled area. No gradient inside a bar.
|
||||||
|
|
||||||
|
C.POOL_THRESHOLDS = { red = 0.30, yellow = 0.70 } -- fractions
|
||||||
|
C.TIME_THRESHOLDS = { red = 2.0, yellow = 5.0 } -- seconds remaining
|
||||||
|
|
||||||
|
C.COLORS = {
|
||||||
|
red = { 0.80, 0.15, 0.15 },
|
||||||
|
yellow = { 0.85, 0.75, 0.20 },
|
||||||
|
green = { 0.25, 0.75, 0.30 },
|
||||||
|
}
|
||||||
|
|
||||||
|
function C.poolColorName(fraction, cfg)
|
||||||
|
cfg = cfg or C.POOL_THRESHOLDS
|
||||||
|
if type(fraction) ~= "number" then return "red" end
|
||||||
|
if fraction <= (cfg.red or 0.30) then return "red" end
|
||||||
|
if fraction <= (cfg.yellow or 0.70) then return "yellow" end
|
||||||
|
return "green"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Time buckets run the other way round: MORE seconds left is better.
|
||||||
|
function C.timeColorName(timeLeft, cfg)
|
||||||
|
cfg = cfg or C.TIME_THRESHOLDS
|
||||||
|
if type(timeLeft) ~= "number" then return "red" end
|
||||||
|
if timeLeft < (cfg.red or 2.0) then return "red" end
|
||||||
|
if timeLeft <= (cfg.yellow or 5.0) then return "yellow" end
|
||||||
|
return "green"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- colorRGB(name): never returns nil -- an unknown name yields a visible neutral colour rather
|
||||||
|
-- than a Lua error deep inside a SetStatusBarColor call.
|
||||||
|
function C.colorRGB(name)
|
||||||
|
local c = C.COLORS[name or ""]
|
||||||
|
if not c then return 0.7, 0.7, 0.7 end
|
||||||
|
return c[1], c[2], c[3]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- formatting ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- fmtThresholdPair(curPoints, maxPoints, rate) -> "2820 / 6120"
|
||||||
|
-- Rounds the RESULT, not the inputs. Rounding the point values first shifts the number by a
|
||||||
|
-- few points; the two orders are not interchangeable (pinned by a test).
|
||||||
|
function C.fmtThresholdPair(curPoints, maxPoints, rate)
|
||||||
|
local a, b = C.threshold(curPoints, rate), C.threshold(maxPoints, rate)
|
||||||
|
if not a or not b then return "--" end
|
||||||
|
return round(a) .. " / " .. round(b)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- fmtShieldPair(curPoints, maxPoints) -> "459 / 918"
|
||||||
|
--
|
||||||
|
-- The other reading of the same buffer. fmtThresholdPair answers "how large may a single hit be
|
||||||
|
-- before this is drained"; this one answers "how much damage does it still absorb". They differ by
|
||||||
|
-- the absorb rate -- at rank 3 by a factor of about seven -- so which one is on screen has to be a
|
||||||
|
-- deliberate choice, not an accident.
|
||||||
|
--
|
||||||
|
-- Note it takes no rate: the shield value is the pool itself. That also makes it the honest
|
||||||
|
-- fallback when the talent rank is unknown, where fmtThresholdPair can only return "--".
|
||||||
|
function C.fmtShieldPair(curPoints, maxPoints)
|
||||||
|
if type(curPoints) ~= "number" or type(maxPoints) ~= "number" then return "--" end
|
||||||
|
return round(curPoints) .. " / " .. round(maxPoints)
|
||||||
|
end
|
||||||
|
|
||||||
|
function C.fmtTime(t)
|
||||||
|
if type(t) ~= "number" or t < 0 then t = 0 end
|
||||||
|
return string.format("%.1f s", t)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- absorb rate --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
C.RANK_RATE = { [0] = 0, [1] = 0.05, [2] = 0.10, [3] = 0.15 }
|
||||||
|
|
||||||
|
-- rateForRank(rank, addBonus, multBonus): the active absorb rate.
|
||||||
|
--
|
||||||
|
-- An unknown rank yields 0, never a silent 15%: a threshold computed from a guessed rate is
|
||||||
|
-- worse than no threshold, because it looks authoritative.
|
||||||
|
--
|
||||||
|
-- The T2.5 set bonus is documented as "+3% absorption" and it is NOT established whether that
|
||||||
|
-- means 15 -> 18 (additive) or 15 * 1.03 (multiplicative). Both readings are expressible and
|
||||||
|
-- neither is assumed; the caller decides once measurement settles it.
|
||||||
|
function C.rateForRank(rank, addBonus, multBonus)
|
||||||
|
local base = C.RANK_RATE[rank or -1]
|
||||||
|
if not base then return 0 end
|
||||||
|
if type(addBonus) == "number" then base = base + addBonus end
|
||||||
|
if type(multBonus) == "number" then base = base * multBonus end
|
||||||
|
return base
|
||||||
|
end
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
-- BulwarkFrame - core/config.lua
|
||||||
|
-- SavedVariables model. BulwarkFrameDB is declared in the .toc; ensureDefaults() fills in
|
||||||
|
-- anything missing, so a saved file from an older version keeps working after an update.
|
||||||
|
--
|
||||||
|
-- Pure table work, no WoW API -- covered by tools/luatests/test_config.lua.
|
||||||
|
|
||||||
|
BulwarkFrame = BulwarkFrame or {}
|
||||||
|
local BF = BulwarkFrame
|
||||||
|
|
||||||
|
BulwarkFrameDB = BulwarkFrameDB or {}
|
||||||
|
|
||||||
|
-- Aura ids of the buffer, both verified in game on 2026-07-28:
|
||||||
|
-- 58130 "Earthen Bulwark" -- a BUFF, carries the remaining DURATION (8 s)
|
||||||
|
-- 58127 "Earthen Bulwark Durability" -- a DEBUFF, its stack count IS the pool level (0..100)
|
||||||
|
-- They stay configurable rather than baked in because Turtle can renumber a custom spell in any
|
||||||
|
-- patch, and a hardcoded id would then read a buffer that is not there.
|
||||||
|
BF.DEFAULT_POOL_AURA = 58127
|
||||||
|
BF.DEFAULT_TIME_AURA = 58130
|
||||||
|
BF.BUFFER_DURATION = 8
|
||||||
|
|
||||||
|
BF.DEFAULTS = {
|
||||||
|
-- placement
|
||||||
|
point = "CENTER", relPoint = "CENTER", x = 0, y = -120,
|
||||||
|
locked = false,
|
||||||
|
hidden = false, -- explicitly hidden via minimap right-click / '/bulwark hide'
|
||||||
|
scale = 1.0,
|
||||||
|
minimapAngle = 215,
|
||||||
|
showMinimapButton = true,
|
||||||
|
|
||||||
|
-- layout
|
||||||
|
width = 150,
|
||||||
|
barHeight = 14,
|
||||||
|
timeBarHeight = 9,
|
||||||
|
swingHeight = 2,
|
||||||
|
spacing = 2,
|
||||||
|
|
||||||
|
-- elements
|
||||||
|
showThreshold = true,
|
||||||
|
showExpiry = true,
|
||||||
|
showSwing = true,
|
||||||
|
-- Swing marker rides the expiry bar (pfUI mana-tick style) instead of its own line.
|
||||||
|
swingOnExpiryBar = true,
|
||||||
|
-- What the number on the threshold bar means:
|
||||||
|
-- "hit" -- how large a single incoming hit may be before the buffer is drained
|
||||||
|
-- "shield" -- how much damage the buffer still absorbs
|
||||||
|
-- Same buffer, two questions; they differ by the absorb rate (factor ~7 at rank 3).
|
||||||
|
displayMode = "hit",
|
||||||
|
showThresholdText = true,
|
||||||
|
showExpiryText = true,
|
||||||
|
showSpeedText = true, -- current attack speed on the left of the expiry bar
|
||||||
|
hideOutOfCombat = false,
|
||||||
|
-- Visible by default. A readout that hides itself whenever there is nothing to read looks
|
||||||
|
-- broken on first install -- you cannot place it, and the obvious fix (clicking things until it
|
||||||
|
-- appears) is how a demo mode gets mistaken for live data. Opt in to hiding, not out of it.
|
||||||
|
hideWhenInactive = false,
|
||||||
|
|
||||||
|
-- colours (r,g,b 0..1); calc.lua picks WHICH one, these are the values
|
||||||
|
colorRed = { 0.80, 0.15, 0.15 },
|
||||||
|
colorYellow = { 0.85, 0.75, 0.20 },
|
||||||
|
colorGreen = { 0.25, 0.75, 0.30 },
|
||||||
|
colorSwing = { 0.90, 0.90, 0.95 },
|
||||||
|
colorBg = { 0.05, 0.05, 0.05, 0.80 },
|
||||||
|
|
||||||
|
-- The expiry bar shares the colour buckets but is drawn at this alpha. Seen side by side at
|
||||||
|
-- full saturation the two bars compete for attention even though the lower one is thinner --
|
||||||
|
-- the threshold is the primary readout and has to win. Set to 1.0 for the old, flat look.
|
||||||
|
expiryAlpha = 0.65,
|
||||||
|
|
||||||
|
-- colour thresholds (fraction of pool / seconds left)
|
||||||
|
poolRed = 0.30, poolYellow = 0.70,
|
||||||
|
timeRed = 2.0, timeYellow = 5.0,
|
||||||
|
|
||||||
|
-- absorb model
|
||||||
|
talentRank = 3, -- Elemental Weapons rank -> 5/10/15 %
|
||||||
|
setBonusMode = "none", -- "none" | "add" | "mult" (T2.5 reading, see README)
|
||||||
|
setBonusValue = 3,
|
||||||
|
|
||||||
|
-- swing timer
|
||||||
|
swingLatencyShare = 1.0, -- 1.0 = full round trip, 0.5 = downstream only
|
||||||
|
swingUseLatency = true,
|
||||||
|
|
||||||
|
-- aura ids
|
||||||
|
poolAura = 58127,
|
||||||
|
timeAura = 58130,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Deep-ish copy for the colour tables: handing out DEFAULTS directly would let a live edit of the
|
||||||
|
-- saved value mutate the default and make a "reset" a no-op.
|
||||||
|
local function copyColor(c)
|
||||||
|
if type(c) ~= "table" then return nil end
|
||||||
|
return { c[1], c[2], c[3], c[4] }
|
||||||
|
end
|
||||||
|
|
||||||
|
function BF.ensureDefaults(db)
|
||||||
|
db = db or BulwarkFrameDB
|
||||||
|
local k, v
|
||||||
|
for k, v in pairs(BF.DEFAULTS) do
|
||||||
|
if db[k] == nil then
|
||||||
|
if type(v) == "table" then db[k] = copyColor(v) else db[k] = v end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return db
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resets everything back to DEFAULTS in place (the caller keeps its table reference).
|
||||||
|
function BF.resetConfig(db)
|
||||||
|
db = db or BulwarkFrameDB
|
||||||
|
local k
|
||||||
|
for k in pairs(db) do db[k] = nil end
|
||||||
|
return BF.ensureDefaults(db)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The absorb rate the calculation should use, from talent rank plus the optional set bonus.
|
||||||
|
-- Delegates to BulwarkFrameCalc.rateForRank so the two readings of the T2.5 bonus stay in one
|
||||||
|
-- place. "mult" passes a factor, so 3 % becomes 1.03 here rather than at the call site.
|
||||||
|
function BF.currentRate(db)
|
||||||
|
db = db or BulwarkFrameDB
|
||||||
|
local add, mult = nil, nil
|
||||||
|
if db.setBonusMode == "add" then
|
||||||
|
add = (db.setBonusValue or 0) / 100
|
||||||
|
elseif db.setBonusMode == "mult" then
|
||||||
|
mult = 1 + ((db.setBonusValue or 0) / 100)
|
||||||
|
end
|
||||||
|
return BulwarkFrameCalc.rateForRank(db.talentRank, add, mult)
|
||||||
|
end
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- BulwarkFrame -- environment guard.
|
||||||
|
--
|
||||||
|
-- SuperWoW is a HARD requirement, not a nice-to-have. Two of the three things this addon does
|
||||||
|
-- have no honest fallback without it:
|
||||||
|
--
|
||||||
|
-- * identifying the buffer aura by SPELL ID (GetPlayerBuffID). The alternative is matching
|
||||||
|
-- on the texture path, which several unrelated auras share -- that produces a readout that
|
||||||
|
-- is confidently wrong rather than absent, which is worse.
|
||||||
|
-- * resetting the swing on UNIT_CASTEVENT (arg3 = "MAINHAND"/"OFFHAND"). Without it the only
|
||||||
|
-- route is parsing localised combat-log strings, which breaks on any client whose locale
|
||||||
|
-- or strings differ.
|
||||||
|
--
|
||||||
|
-- So there is deliberately no bypass switch. An addon that draws a plausible but wrong number
|
||||||
|
-- is worse than one that says it cannot run.
|
||||||
|
--
|
||||||
|
-- SUPERWOW_VERSION is set before any addon loads, so this can be evaluated at module scope --
|
||||||
|
-- unlike nampower and UnitXP, which are only detectable once in the world. Neither is used
|
||||||
|
-- here; if that changes, their detection has to move to a later event.
|
||||||
|
|
||||||
|
BulwarkFrameEnv = {}
|
||||||
|
local E = BulwarkFrameEnv
|
||||||
|
|
||||||
|
E.MESSAGE = "BulwarkFrame: SuperWoW is required and was not detected -- staying inactive. "
|
||||||
|
.. "The buffer pool is read by spell id, which needs SuperWoW."
|
||||||
|
|
||||||
|
-- check(superwowVersion) -> ok(boolean), message(string or nil). PURE.
|
||||||
|
--
|
||||||
|
-- Presence is a plain nil check, matching what the other addons in this ecosystem do
|
||||||
|
-- (`SUPERWOW_VERSION ~= nil`). A stricter test would risk rejecting a valid SuperWoW that
|
||||||
|
-- reports its version in an unexpected shape.
|
||||||
|
function E.check(superwowVersion)
|
||||||
|
if superwowVersion ~= nil then return true, nil end
|
||||||
|
return false, E.MESSAGE
|
||||||
|
end
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
-- BulwarkFrame -- swing-timer arithmetic (pure).
|
||||||
|
--
|
||||||
|
-- Why we build this instead of taking a dependency: the only Turtle-tested swing timer in
|
||||||
|
-- circulation (SP_SwingTimer) resets on LOCALISED combat-log strings, which is fragile, and it
|
||||||
|
-- has no extension points for the shaman specifics we care about -- Windfury extra attacks and
|
||||||
|
-- Flurry haste. Both of those break a generic timer, so a small own module is easier to keep
|
||||||
|
-- honest than a foreign addon to patch.
|
||||||
|
--
|
||||||
|
-- Two building blocks, both confirmed:
|
||||||
|
-- * swing DURATION -- UnitAttackSpeed("player") returns (mainhand, offhand) in seconds.
|
||||||
|
-- Present in 1.12: Blizzard's own PaperDollFrame.lua:285 uses it.
|
||||||
|
-- * swing RESET -- SuperWoW's UNIT_CASTEVENT with arg3 = "MAINHAND"/"OFFHAND". Language
|
||||||
|
-- independent and fires on misses too, unlike string parsing.
|
||||||
|
-- Neither is touched here; this file is arithmetic only, so it runs under plain Lua 5.0.3.
|
||||||
|
|
||||||
|
BulwarkFrameSwing = {}
|
||||||
|
local S = BulwarkFrameSwing
|
||||||
|
|
||||||
|
local function clamp(v, lo, hi)
|
||||||
|
if v < lo then return lo end
|
||||||
|
if v > hi then return hi end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
-- hasSwung(lastSwingAt): is there a swing to draw at all?
|
||||||
|
-- 0 doubles as the "never swung" sentinel, matching how the runtime layer will initialise it.
|
||||||
|
function S.hasSwung(lastSwingAt)
|
||||||
|
return type(lastSwingAt) == "number" and lastSwingAt > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
-- progress(lastSwingAt, now, speed) -> 0..1 through the current swing.
|
||||||
|
--
|
||||||
|
-- Saturates at 1 rather than wrapping: between swings (target dead, out of range, moving) the
|
||||||
|
-- marker should sit still at the end, not sweep round again as if an attack were imminent.
|
||||||
|
--
|
||||||
|
-- NOTE ON HASTE -- deliberately not decided here. When Flurry changes attack speed mid-swing it
|
||||||
|
-- is unverified whether the running swing rescales or the new speed only applies to the next
|
||||||
|
-- one. `speed` is therefore an argument: pass the value captured at swing start for the
|
||||||
|
-- "next swing only" reading, or the live value for the "rescales" reading.
|
||||||
|
-- LATENCY -- why it is an argument and not a constant.
|
||||||
|
--
|
||||||
|
-- The swing happens on the SERVER at T. The client only learns about it when the event
|
||||||
|
-- arrives, so the timestamp we record is already T + downstream. Everything derived from it is
|
||||||
|
-- therefore late by that much, and the two plausible corrections are NOT the same number:
|
||||||
|
--
|
||||||
|
-- * "show the true state" -> shift by the downstream leg alone (~rtt/2)
|
||||||
|
-- * "show the action point" -> shift by the FULL rtt, because a keypress still needs the
|
||||||
|
-- upstream leg to reach the server. The two legs ADD; they do
|
||||||
|
-- not cancel.
|
||||||
|
--
|
||||||
|
-- Which one is wanted is a display decision, so this module takes a plain `offset` in seconds
|
||||||
|
-- and applies it, rather than picking a model. Measured on this character: mean rtt 78 ms,
|
||||||
|
-- p90 82 ms, spikes to 253 ms -- i.e. 3% of a 2.6 s swing normally and 10% on a spike. Worth
|
||||||
|
-- offering, not worth hardcoding. (GetNetStats() supplies the rtt in ms, but only refreshes
|
||||||
|
-- every ~30 s in 1.12, so it tracks the baseline and not a spike.)
|
||||||
|
function S.progress(lastSwingAt, now, speed, offset)
|
||||||
|
if type(lastSwingAt) ~= "number" or type(now) ~= "number" then return 0 end
|
||||||
|
if type(speed) ~= "number" or speed <= 0 then return 0 end
|
||||||
|
if type(offset) ~= "number" then offset = 0 end
|
||||||
|
return clamp((now - lastSwingAt + offset) / speed, 0, 1)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- remaining(lastSwingAt, now, speed, offset) -> seconds until the next swing, floored at 0.
|
||||||
|
function S.remaining(lastSwingAt, now, speed, offset)
|
||||||
|
if type(lastSwingAt) ~= "number" or type(now) ~= "number" then return 0 end
|
||||||
|
if type(speed) ~= "number" or speed <= 0 then return 0 end
|
||||||
|
if type(offset) ~= "number" then offset = 0 end
|
||||||
|
return clamp(speed - (now - lastSwingAt + offset), 0, speed)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- latencySeconds(ms, share): turn GetNetStats' round-trip milliseconds into the offset above.
|
||||||
|
-- `share` selects the model: 0.5 for the downstream leg ("true state"), 1.0 for the full round
|
||||||
|
-- trip ("action point"). Defaults to 1.0 -- if a timer is used to decide WHEN TO PRESS, the
|
||||||
|
-- full trip is the honest number, and erring toward "press slightly early" is the harmless
|
||||||
|
-- direction: an ability queued a touch too soon still lands, one queued late misses the window.
|
||||||
|
function S.latencySeconds(ms, share)
|
||||||
|
if type(ms) ~= "number" or ms <= 0 then return 0 end
|
||||||
|
if type(share) ~= "number" then share = 1.0 end
|
||||||
|
return (ms / 1000) * share
|
||||||
|
end
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
-- BulwarkFrame - data.lua
|
||||||
|
-- Everything that touches the live client: the buffer aura, the swing timer, combat state.
|
||||||
|
-- WoW-API file (parse-checked only; the arithmetic it feeds lives in core/ and is unit tested).
|
||||||
|
--
|
||||||
|
-- Two reads, and they come from DIFFERENT enumerations -- settled in game on 2026-07-28, no
|
||||||
|
-- longer an open question:
|
||||||
|
-- * TIME -- aura 58130 "Earthen Bulwark", a BUFF. Seen by GetPlayerBuff / UnitBuff (spell id at
|
||||||
|
-- return 3). Counts 8 seconds down; verified across eight appearances.
|
||||||
|
-- * POOL -- aura 58127 "Earthen Bulwark Durability", a DEBUFF. Its stack count IS the fill
|
||||||
|
-- level, 0..100 (verified building up: 20, 44, 70, 91, 100). It is NOT listed by the
|
||||||
|
-- HELPFUL player-buff enumeration at all, only by UnitDebuff (spell id at return 4).
|
||||||
|
-- Both ids stay configurable, and the reader takes each value from whichever aura reports it.
|
||||||
|
|
||||||
|
BulwarkFrame = BulwarkFrame or {}
|
||||||
|
local BF = BulwarkFrame
|
||||||
|
local S = BulwarkFrameSwing
|
||||||
|
|
||||||
|
-- Live state, read by ui.lua. One table, updated in place -- no per-frame allocation.
|
||||||
|
BF.state = {
|
||||||
|
active = false, -- buffer aura present
|
||||||
|
stacks = 0,
|
||||||
|
timeLeft = 0,
|
||||||
|
maxHP = 0,
|
||||||
|
inCombat = false,
|
||||||
|
timeLeftAt = 0, -- GetTime() of the last timeLeft reading (see ScanAuras)
|
||||||
|
lastSwingAt = 0,
|
||||||
|
swingSpeed = 0,
|
||||||
|
swingOffset = 0,
|
||||||
|
hasPool = false, -- pool aura seen at least once this session (probe hint)
|
||||||
|
hasTime = false,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- ---- buff scan --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
-- whole reason SuperWoW is a hard requirement -- matching the icon texture instead would collide
|
||||||
|
-- with unrelated auras and produce a confidently wrong readout.
|
||||||
|
--
|
||||||
|
-- GOTCHA (in-game verified): GetPlayerBuffTimeLeft returns 0 for a permanent buff, not nil, so
|
||||||
|
-- "has a duration" must be tested as > 0.
|
||||||
|
function BF.ScanAuras()
|
||||||
|
local st = BF.state
|
||||||
|
local db = BulwarkFrameDB
|
||||||
|
local poolId, timeId = db.poolAura, db.timeAura
|
||||||
|
|
||||||
|
local stacks, timeLeft = 0, 0
|
||||||
|
local found = false
|
||||||
|
local i = 0
|
||||||
|
while i < 32 do
|
||||||
|
local slot = GetPlayerBuff(i, "HELPFUL")
|
||||||
|
if not slot or slot < 0 then break end
|
||||||
|
local id = GetPlayerBuffID and GetPlayerBuffID(slot)
|
||||||
|
if id == poolId or id == timeId then
|
||||||
|
found = true
|
||||||
|
-- Stacks AND duration are taken from whichever of the two auras carries them, rather
|
||||||
|
-- than assuming the pool id owns the count. Reading the count only from poolId meant
|
||||||
|
-- that if the buffer happens to sit on the other id, the aura was detected but the
|
||||||
|
-- count stayed 0 -- and the "no count means one stack" fallback below then reported a
|
||||||
|
-- single stack. On a full buffer that renders as 61 instead of 6117: a plausible
|
||||||
|
-- number, off by a factor of a hundred. Whichever aura reports more wins; they cannot
|
||||||
|
-- contradict each other because they describe the same buffer.
|
||||||
|
local n = GetPlayerBuffApplications and GetPlayerBuffApplications(slot)
|
||||||
|
if type(n) == "number" and n > stacks then
|
||||||
|
stacks = n
|
||||||
|
if id == poolId then st.hasPool = true end
|
||||||
|
end
|
||||||
|
local tl = GetPlayerBuffTimeLeft and GetPlayerBuffTimeLeft(slot)
|
||||||
|
if type(tl) == "number" and tl > 0 and tl > timeLeft then
|
||||||
|
timeLeft = tl
|
||||||
|
st.hasTime = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The fill level is NOT reachable through GetPlayerBuff. Measured in game 2026-07-28 while the
|
||||||
|
-- buffer was building (20 -> 44 -> 70 -> 91 -> 100):
|
||||||
|
--
|
||||||
|
-- 58130 "Earthen Bulwark" -- UnitBuff, spell id at return 3, carries the TIME
|
||||||
|
-- 58127 "Earthen Bulwark Durability" -- UnitDebuff, spell id at return 4, carries the STACKS
|
||||||
|
--
|
||||||
|
-- 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
|
||||||
|
-- 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.
|
||||||
|
local scanStacks = function(fn, idPos)
|
||||||
|
local i = 1
|
||||||
|
while i <= 64 do
|
||||||
|
local r1, r2, r3, r4
|
||||||
|
local ok = pcall(function() r1, r2, r3, r4 = fn("player", i) end)
|
||||||
|
if not ok or 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
|
||||||
|
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
|
||||||
|
-- single-stack aura without a count, and treating that as 0 would blank the bar while it is
|
||||||
|
-- protecting us. Only reached when NO source reported a count.
|
||||||
|
if found and stacks == 0 then stacks = 1 end
|
||||||
|
|
||||||
|
st.active = found
|
||||||
|
st.stacks = stacks
|
||||||
|
st.timeLeft = timeLeft
|
||||||
|
-- When this reading was taken. GetPlayerBuffTimeLeft is only sampled on aura events, so the
|
||||||
|
-- display interpolates from here instead of holding the same number until the next event --
|
||||||
|
-- otherwise an 8 second bar visibly steps down in chunks.
|
||||||
|
st.timeLeftAt = GetTime()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Re-reads ONLY the remaining time, cheaply enough to run on a timer rather than waiting for an
|
||||||
|
-- event. This exists because 1.12 does not reliably fire PLAYER_AURAS_CHANGED when an aura that is
|
||||||
|
-- already up is merely REFRESHED: same aura, same stack count, new duration. The event-driven read
|
||||||
|
-- then keeps its old timestamp and the interpolation counts down past a buffer that was in fact
|
||||||
|
-- reset to 8 seconds -- which is exactly the reported symptom.
|
||||||
|
--
|
||||||
|
-- Deliberately not the full ScanAuras: the stack count needs the two 64-slot unit enumerations,
|
||||||
|
-- while the duration only needs the player-buff walk, so the expensive part stays event-driven.
|
||||||
|
function BF.ScanTime()
|
||||||
|
local st = BF.state
|
||||||
|
local db = BulwarkFrameDB
|
||||||
|
local poolId, timeId = db.poolAura, db.timeAura
|
||||||
|
local timeLeft = 0
|
||||||
|
local i = 0
|
||||||
|
while i < 32 do
|
||||||
|
local slot = GetPlayerBuff(i, "HELPFUL")
|
||||||
|
if not slot or slot < 0 then break end
|
||||||
|
local id = GetPlayerBuffID and GetPlayerBuffID(slot)
|
||||||
|
if id == poolId or id == timeId then
|
||||||
|
local tl = GetPlayerBuffTimeLeft and GetPlayerBuffTimeLeft(slot)
|
||||||
|
if type(tl) == "number" and tl > timeLeft then timeLeft = tl end
|
||||||
|
end
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
-- Only accept a reading that moves the clock FORWARD (a refresh) or keeps it running down.
|
||||||
|
-- A zero here means the aura is gone; the caller's active flag comes from ScanAuras.
|
||||||
|
if timeLeft > 0 then
|
||||||
|
st.timeLeft = timeLeft
|
||||||
|
st.timeLeftAt = GetTime()
|
||||||
|
end
|
||||||
|
return timeLeft
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- swing ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Refreshes weapon speed and the latency offset. Cheap, but not per-frame cheap: called on
|
||||||
|
-- swing events and on the slow ticker, not from OnUpdate.
|
||||||
|
function BF.RefreshSwingInputs()
|
||||||
|
local st = BF.state
|
||||||
|
local db = BulwarkFrameDB
|
||||||
|
local main = UnitAttackSpeed("player")
|
||||||
|
if type(main) == "number" and main > 0 then st.swingSpeed = main end
|
||||||
|
if db.swingUseLatency then
|
||||||
|
local _, _, lag = GetNetStats()
|
||||||
|
st.swingOffset = S.latencySeconds(lag, db.swingLatencyShare)
|
||||||
|
else
|
||||||
|
st.swingOffset = 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- events -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local driver = CreateFrame("Frame", "BulwarkFrameDataDriver")
|
||||||
|
driver:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||||
|
driver:RegisterEvent("PLAYER_AURAS_CHANGED")
|
||||||
|
driver:RegisterEvent("PLAYER_REGEN_DISABLED")
|
||||||
|
driver:RegisterEvent("PLAYER_REGEN_ENABLED")
|
||||||
|
driver:RegisterEvent("UNIT_INVENTORY_CHANGED")
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
-- how the third-party swing timers get it wrong on a dodge.
|
||||||
|
driver:RegisterEvent("UNIT_CASTEVENT")
|
||||||
|
|
||||||
|
driver:SetScript("OnEvent", function()
|
||||||
|
if event == "UNIT_CASTEVENT" then
|
||||||
|
-- arg1 = caster GUID, arg3 = type. "MAINHAND"/"OFFHAND" are auto-attacks, not spells.
|
||||||
|
-- Only the main hand drives the bar: an off-hand swing has its own clock, and drawing
|
||||||
|
-- both on one line would show a marker that jumps backwards.
|
||||||
|
if arg3 == "MAINHAND" and arg1 == BF.playerGuid then
|
||||||
|
BF.state.lastSwingAt = GetTime()
|
||||||
|
BF.RefreshSwingInputs()
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if event == "PLAYER_REGEN_DISABLED" then
|
||||||
|
BF.state.inCombat = true
|
||||||
|
elseif event == "PLAYER_REGEN_ENABLED" then
|
||||||
|
BF.state.inCombat = false
|
||||||
|
elseif event == "PLAYER_ENTERING_WORLD" or event == "UNIT_INVENTORY_CHANGED" then
|
||||||
|
BF.state.maxHP = UnitHealthMax("player") or 0
|
||||||
|
BF.CapturePlayerGuid()
|
||||||
|
BF.RefreshSwingInputs()
|
||||||
|
end
|
||||||
|
if event == "PLAYER_AURAS_CHANGED" or event == "PLAYER_ENTERING_WORLD" then
|
||||||
|
BF.ScanAuras()
|
||||||
|
end
|
||||||
|
if BF.RequestUpdate then BF.RequestUpdate() end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- The player's own GUID, needed to tell our swings from everyone else's. SuperWoW appends it as a
|
||||||
|
-- second return of UnitExists -- resolved the same way elsewhere, rather than via a second API call.
|
||||||
|
-- Cached on world entry, not read per event.
|
||||||
|
BF.playerGuid = nil
|
||||||
|
|
||||||
|
function BF.CapturePlayerGuid()
|
||||||
|
local _, guid = UnitExists("player")
|
||||||
|
if guid then BF.playerGuid = guid end
|
||||||
|
return BF.playerGuid
|
||||||
|
end
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- BulwarkFrame - main.lua
|
||||||
|
-- Load order tail: wires the slash commands and starts the UI once the player is in the world.
|
||||||
|
-- WoW-API file (parse-checked only).
|
||||||
|
|
||||||
|
BulwarkFrame = BulwarkFrame or {}
|
||||||
|
local BF = BulwarkFrame
|
||||||
|
|
||||||
|
local function msg(s) DEFAULT_CHAT_FRAME:AddMessage("|cff66bbffBulwarkFrame|r " .. s) end
|
||||||
|
|
||||||
|
local loader = CreateFrame("Frame", "BulwarkFrameLoader")
|
||||||
|
loader:RegisterEvent("PLAYER_LOGIN")
|
||||||
|
loader:SetScript("OnEvent", function()
|
||||||
|
BF.ensureDefaults()
|
||||||
|
BF.InitUI()
|
||||||
|
BF.UpdateMinimapButton()
|
||||||
|
if not BF.envOk then
|
||||||
|
msg(BulwarkFrameEnv.MESSAGE)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
SLASH_BULWARKFRAME1 = "/bulwark"
|
||||||
|
SLASH_BULWARKFRAME2 = "/bf"
|
||||||
|
SlashCmdList["BULWARKFRAME"] = function(input)
|
||||||
|
local cmd = string.lower(input or "")
|
||||||
|
-- No string.match / no ':' string methods -- Lua 5.0.
|
||||||
|
if cmd == "" or cmd == "options" or cmd == "config" then
|
||||||
|
BF.ToggleOptions()
|
||||||
|
elseif cmd == "demo" then
|
||||||
|
local on = BF.ToggleDemo()
|
||||||
|
msg("demo " .. (on and "on" or "off"))
|
||||||
|
elseif cmd == "probe" then
|
||||||
|
BF.RunProbe()
|
||||||
|
elseif cmd == "lock" then
|
||||||
|
BulwarkFrameDB.locked = not BulwarkFrameDB.locked
|
||||||
|
BF.ApplyLayout()
|
||||||
|
msg(BulwarkFrameDB.locked and "frame locked" or "frame unlocked")
|
||||||
|
elseif cmd == "reset" then
|
||||||
|
BF.resetConfig()
|
||||||
|
BF.ApplyLayout()
|
||||||
|
BF.RequestUpdate()
|
||||||
|
BF.UpdateMinimapButton()
|
||||||
|
msg("settings reset to defaults")
|
||||||
|
elseif cmd == "show" then
|
||||||
|
BulwarkFrameDB.hideWhenInactive = false
|
||||||
|
BulwarkFrameDB.hideOutOfCombat = false
|
||||||
|
BF.RequestUpdate()
|
||||||
|
msg("frame pinned visible (hide-when-idle and combat-only turned off)")
|
||||||
|
else
|
||||||
|
msg("commands: options | demo | probe | lock | show | reset")
|
||||||
|
end
|
||||||
|
end
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
-- BulwarkFrame - minimap.lua
|
||||||
|
-- Hand-rolled minimap button (no LibDBIcon on 1.12). Orbits the minimap at a saved angle,
|
||||||
|
-- drag-repositions, left-click opens the options panel, right-click toggles demo mode.
|
||||||
|
-- WoW-API file (parse-checked only).
|
||||||
|
--
|
||||||
|
-- pfUI-safe, the same way TotemBar's button is: parent = Minimap, the frame NAME contains
|
||||||
|
-- "Minimap", strata HIGH with level 9 (MEDIUM ends up underneath pfUI). /bulwark options stays
|
||||||
|
-- the guaranteed access path if a UI collects or hides the button anyway.
|
||||||
|
|
||||||
|
BulwarkFrame = BulwarkFrame or {}
|
||||||
|
local BF = BulwarkFrame
|
||||||
|
|
||||||
|
local button = nil
|
||||||
|
|
||||||
|
local function db() return BulwarkFrameDB end
|
||||||
|
|
||||||
|
-- Position on the orbit for an angle in degrees. Plain trigonometry rather than a lib: the
|
||||||
|
-- radius is the minimap's own half-width so it follows a resized minimap.
|
||||||
|
local function PlaceButton(angleDeg)
|
||||||
|
if not button then return end
|
||||||
|
local radius = (Minimap:GetWidth() / 2) + 5
|
||||||
|
local rad = angleDeg * math.pi / 180
|
||||||
|
button:ClearAllPoints()
|
||||||
|
button:SetPoint("CENTER", Minimap, "CENTER", math.cos(rad) * radius, math.sin(rad) * radius)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function BuildButton()
|
||||||
|
if button then return end
|
||||||
|
|
||||||
|
local btn = CreateFrame("Button", "BulwarkFrameMinimapButton", Minimap)
|
||||||
|
btn:SetWidth(33)
|
||||||
|
btn:SetHeight(33)
|
||||||
|
btn:SetFrameStrata("HIGH")
|
||||||
|
btn:SetFrameLevel(9)
|
||||||
|
|
||||||
|
-- No custom art yet: a shield-shaped stock icon says "defensive readout" well enough, and a
|
||||||
|
-- placeholder texture that fails to load would leave an invisible button.
|
||||||
|
local icon = btn:CreateTexture("BulwarkFrameMinimapIcon", "ARTWORK")
|
||||||
|
icon:SetTexture("Interface\\Icons\\Spell_Nature_StoneClawTotem")
|
||||||
|
icon:SetWidth(20)
|
||||||
|
icon:SetHeight(20)
|
||||||
|
icon:SetPoint("CENTER", btn, "CENTER", 0, 0)
|
||||||
|
icon:SetTexCoord(0.08, 0.92, 0.08, 0.92)
|
||||||
|
|
||||||
|
local border = btn:CreateTexture("BulwarkFrameMinimapBorder", "OVERLAY")
|
||||||
|
border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
|
||||||
|
-- Vanilla recipe (MiniMapTrackingButton): a 54px border anchored TOPLEFT(0,0) on a 33px
|
||||||
|
-- button. The ring sits off-centre WITHIN the texture, so this exact anchor is what makes it
|
||||||
|
-- concentric with the icon -- centring it pushes the ring off.
|
||||||
|
border:SetWidth(54)
|
||||||
|
border:SetHeight(54)
|
||||||
|
border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
|
||||||
|
|
||||||
|
btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
|
||||||
|
btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
|
||||||
|
btn:RegisterForDrag("LeftButton")
|
||||||
|
|
||||||
|
btn:SetScript("OnDragStart", function() this.isDragging = true end)
|
||||||
|
btn:SetScript("OnDragStop", function() this.isDragging = false end)
|
||||||
|
btn:SetScript("OnUpdate", function()
|
||||||
|
if not this.isDragging then return end
|
||||||
|
-- Follow the cursor around the orbit: angle from the minimap centre to the pointer.
|
||||||
|
local mx, my = Minimap:GetCenter()
|
||||||
|
local cx, cy = GetCursorPosition()
|
||||||
|
local scale = Minimap:GetEffectiveScale()
|
||||||
|
cx, cy = cx / scale, cy / scale
|
||||||
|
local angle = math.deg(math.atan2(cy - my, cx - mx))
|
||||||
|
db().minimapAngle = angle
|
||||||
|
PlaceButton(angle)
|
||||||
|
end)
|
||||||
|
|
||||||
|
btn:SetScript("OnClick", function()
|
||||||
|
if arg1 == "RightButton" then
|
||||||
|
-- Show/hide the readout, the same thing TotemBar's button does. This used to toggle
|
||||||
|
-- DEMO mode, which was a trap: the frame hides itself when no buffer is up, so the
|
||||||
|
-- obvious reaction is to right-click to make it appear -- and that silently replaced
|
||||||
|
-- the live readout with synthetic numbers.
|
||||||
|
local shown = BF.ToggleFrame()
|
||||||
|
DEFAULT_CHAT_FRAME:AddMessage("BulwarkFrame: " .. (shown and "shown" or "hidden"))
|
||||||
|
else
|
||||||
|
BF.ToggleOptions()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
btn:SetScript("OnEnter", function()
|
||||||
|
GameTooltip:SetOwner(this, "ANCHOR_LEFT")
|
||||||
|
GameTooltip:SetText("BulwarkFrame", 1, 1, 1)
|
||||||
|
GameTooltip:AddLine("Left click: options", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:AddLine("Right click: show / hide the readout", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:AddLine("Drag: move around the minimap", 0.8, 0.8, 0.8)
|
||||||
|
GameTooltip:Show()
|
||||||
|
end)
|
||||||
|
btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
||||||
|
|
||||||
|
button = btn
|
||||||
|
PlaceButton(db().minimapAngle or 215)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Creates the button on demand and honours the "show minimap button" setting.
|
||||||
|
function BF.UpdateMinimapButton()
|
||||||
|
if not db().showMinimapButton then
|
||||||
|
if button then button:Hide() end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
BuildButton()
|
||||||
|
PlaceButton(db().minimapAngle or 215)
|
||||||
|
button:Show()
|
||||||
|
end
|
||||||
+489
@@ -0,0 +1,489 @@
|
|||||||
|
-- BulwarkFrame - options.lua
|
||||||
|
-- Standalone options panel (no Blizzard InterfaceOptions integration on 1.12). Built lazily on
|
||||||
|
-- first open, repopulated from BulwarkFrameDB on show. Opened by the minimap button's left click
|
||||||
|
-- and by /bulwark options. WoW-API file (parse-checked only).
|
||||||
|
--
|
||||||
|
-- Widget wiring uses the 1.12 templates: UICheckButtonTemplate (GetChecked() -> 1/nil) and
|
||||||
|
-- OptionsSliderTemplate (SetMinMaxValues BEFORE SetValue, else it clamps to 0). Colour swatches
|
||||||
|
-- drive ColorPickerFrame the way pfUI does -- func/cancelFunc plus ShowUIPanel.
|
||||||
|
|
||||||
|
BulwarkFrame = BulwarkFrame or {}
|
||||||
|
local BF = BulwarkFrame
|
||||||
|
|
||||||
|
local PANEL_W = 430
|
||||||
|
-- Content width of one column: panel minus both outer margins minus the gutter, halved.
|
||||||
|
local COL_W = (PANEL_W - 48 - 12) / 2
|
||||||
|
local panel = nil
|
||||||
|
local cbIndex, slIndex, swIndex = 0, 0, 0
|
||||||
|
local refreshers = {}
|
||||||
|
|
||||||
|
local function db() return BulwarkFrameDB end
|
||||||
|
|
||||||
|
-- 1.12: a FontString created WITHOUT a font object has no font, and FontString:SetText() then
|
||||||
|
-- throws "Font not set" -- which aborts the rest of the panel build. Every CreateFontString here
|
||||||
|
-- therefore passes an inherit template, and this only swaps in the pfUI face afterwards.
|
||||||
|
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 12)
|
||||||
|
else
|
||||||
|
fs:SetFontObject(GameFontNormalSmall)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Extends a widget's hover area across the whole row, so the tooltip also appears over its label
|
||||||
|
-- rather than only over the 22px box itself. 1.12 FontStrings take no mouse events, so this is an
|
||||||
|
-- invisible button laid over the row; clicking it forwards to the real widget, which also makes
|
||||||
|
-- the label clickable -- the behaviour anyone expects from a labelled checkbox.
|
||||||
|
local function AddRowHover(widget, tip, onClick)
|
||||||
|
if not widget then return end
|
||||||
|
local hover = CreateFrame("Button", nil, widget:GetParent())
|
||||||
|
hover:SetPoint("TOPLEFT", widget, "TOPLEFT", 0, 0)
|
||||||
|
hover:SetWidth(COL_W)
|
||||||
|
hover:SetHeight(widget:GetHeight() or 22)
|
||||||
|
hover:SetFrameLevel((widget:GetFrameLevel() or 1) + 1)
|
||||||
|
hover:SetScript("OnClick", onClick)
|
||||||
|
return hover
|
||||||
|
end
|
||||||
|
|
||||||
|
local function AddTooltip(widget, text)
|
||||||
|
if not widget or not text then return end
|
||||||
|
widget.bfTip = text
|
||||||
|
local oldEnter = widget:GetScript("OnEnter")
|
||||||
|
local oldLeave = widget:GetScript("OnLeave")
|
||||||
|
widget:SetScript("OnEnter", function()
|
||||||
|
if oldEnter then oldEnter() end
|
||||||
|
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
||||||
|
GameTooltip:SetText(this.bfTip, 1, 1, 1, 1, 1)
|
||||||
|
GameTooltip:Show()
|
||||||
|
end)
|
||||||
|
widget:SetScript("OnLeave", function()
|
||||||
|
if oldLeave then oldLeave() end
|
||||||
|
GameTooltip:Hide()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Any change to a display option has to reach the frame immediately -- an options panel whose
|
||||||
|
-- effect only shows after /reload trains you to distrust it.
|
||||||
|
local function Applied()
|
||||||
|
BF.ApplyLayout()
|
||||||
|
BF.RequestUpdate()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- widget factories -------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function CreateCheckbox(parent, label, tip, getter, setter)
|
||||||
|
cbIndex = cbIndex + 1
|
||||||
|
local name = "BulwarkFrameOptCheck" .. cbIndex
|
||||||
|
local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate")
|
||||||
|
cb:SetWidth(22)
|
||||||
|
cb:SetHeight(22)
|
||||||
|
local lbl = getglobal(name .. "Text")
|
||||||
|
if lbl then lbl:SetText(label); ApplyFont(lbl) end
|
||||||
|
cb.bfSet = setter
|
||||||
|
cb:SetScript("OnClick", function()
|
||||||
|
this.bfSet(this:GetChecked() == 1)
|
||||||
|
Applied()
|
||||||
|
end)
|
||||||
|
AddTooltip(cb, tip)
|
||||||
|
AddTooltip(AddRowHover(cb, tip, function()
|
||||||
|
cb:SetChecked(not (cb:GetChecked() == 1) and 1 or nil)
|
||||||
|
cb.bfSet(cb:GetChecked() == 1)
|
||||||
|
Applied()
|
||||||
|
end), tip)
|
||||||
|
if pfUI and pfUI.api and pfUI.api.SkinCheckbox then pfUI.api.SkinCheckbox(cb) end
|
||||||
|
table.insert(refreshers, function() cb:SetChecked(getter() and 1 or nil) end)
|
||||||
|
return cb
|
||||||
|
end
|
||||||
|
|
||||||
|
local function CreateSlider(parent, label, minVal, maxVal, step, fmt, tip, getter, setter)
|
||||||
|
slIndex = slIndex + 1
|
||||||
|
local name = "BulwarkFrameOptSlider" .. slIndex
|
||||||
|
local sl = CreateFrame("Slider", name, parent, "OptionsSliderTemplate")
|
||||||
|
sl:SetWidth(COL_W)
|
||||||
|
sl:SetHeight(16)
|
||||||
|
sl:SetMinMaxValues(minVal, maxVal)
|
||||||
|
sl:SetValueStep(step)
|
||||||
|
local low, high, txt = getglobal(name .. "Low"), getglobal(name .. "High"), getglobal(name .. "Text")
|
||||||
|
if low then low:SetText(tostring(minVal)); ApplyFont(low, 10) end
|
||||||
|
if high then high:SetText(tostring(maxVal)); ApplyFont(high, 10) end
|
||||||
|
if txt then
|
||||||
|
txt:ClearAllPoints()
|
||||||
|
txt:SetPoint("BOTTOMLEFT", sl, "TOPLEFT", 0, 2)
|
||||||
|
txt:SetJustifyH("LEFT")
|
||||||
|
ApplyFont(txt, 11)
|
||||||
|
end
|
||||||
|
sl.bfFmt = fmt
|
||||||
|
sl.bfText = txt
|
||||||
|
sl.bfSet = setter
|
||||||
|
sl:SetScript("OnValueChanged", function()
|
||||||
|
local v = this:GetValue()
|
||||||
|
if this.bfText then this.bfText:SetText(string.format(this.bfFmt, v)) end
|
||||||
|
this.bfSet(v)
|
||||||
|
Applied()
|
||||||
|
end)
|
||||||
|
AddTooltip(sl, tip)
|
||||||
|
if pfUI and pfUI.api and pfUI.api.SkinSlider then pfUI.api.SkinSlider(sl) end
|
||||||
|
table.insert(refreshers, function()
|
||||||
|
local v = getter()
|
||||||
|
sl:SetValue(v)
|
||||||
|
if txt then txt:SetText(string.format(fmt, v)) end
|
||||||
|
end)
|
||||||
|
return sl
|
||||||
|
end
|
||||||
|
|
||||||
|
-- A colour swatch. `key` names the config field holding {r,g,b}.
|
||||||
|
local function CreateSwatch(parent, label, key, tip)
|
||||||
|
swIndex = swIndex + 1
|
||||||
|
local holder = CreateFrame("Button", "BulwarkFrameOptSwatch" .. swIndex, parent)
|
||||||
|
holder:SetWidth(18)
|
||||||
|
holder:SetHeight(18)
|
||||||
|
|
||||||
|
local tex = holder:CreateTexture("BulwarkFrameOptSwatchTex" .. swIndex, "ARTWORK")
|
||||||
|
tex:SetTexture("Interface\\Buttons\\WHITE8X8")
|
||||||
|
tex:SetPoint("TOPLEFT", holder, "TOPLEFT", 1, -1)
|
||||||
|
tex:SetPoint("BOTTOMRIGHT", holder, "BOTTOMRIGHT", -1, 1)
|
||||||
|
|
||||||
|
local border = holder:CreateTexture("BulwarkFrameOptSwatchBorder" .. swIndex, "BACKGROUND")
|
||||||
|
border:SetTexture("Interface\\Buttons\\WHITE8X8")
|
||||||
|
border:SetVertexColor(0, 0, 0, 1)
|
||||||
|
border:SetAllPoints(holder)
|
||||||
|
|
||||||
|
local lbl = holder:CreateFontString("BulwarkFrameOptSwatchLabel" .. swIndex, "OVERLAY", "GameFontNormalSmall")
|
||||||
|
lbl:SetPoint("LEFT", holder, "RIGHT", 6, 0)
|
||||||
|
lbl:SetText(label)
|
||||||
|
ApplyFont(lbl)
|
||||||
|
|
||||||
|
holder:SetScript("OnClick", function()
|
||||||
|
local c = db()[key] or { 1, 1, 1 }
|
||||||
|
local pr, pg, pb = c[1], c[2], c[3]
|
||||||
|
-- Captured for cancel: ColorPickerFrame keeps calling func while the user drags, so the
|
||||||
|
-- config is written live and cancel has to put the old value back explicitly.
|
||||||
|
ColorPickerFrame.func = function()
|
||||||
|
local r, g, b = ColorPickerFrame:GetColorRGB()
|
||||||
|
local cc = db()[key]
|
||||||
|
cc[1], cc[2], cc[3] = r, g, b
|
||||||
|
tex:SetVertexColor(r, g, b)
|
||||||
|
Applied()
|
||||||
|
end
|
||||||
|
ColorPickerFrame.cancelFunc = function()
|
||||||
|
local cc = db()[key]
|
||||||
|
cc[1], cc[2], cc[3] = pr, pg, pb
|
||||||
|
tex:SetVertexColor(pr, pg, pb)
|
||||||
|
Applied()
|
||||||
|
end
|
||||||
|
ColorPickerFrame.opacityFunc = nil
|
||||||
|
ColorPickerFrame.hasOpacity = nil
|
||||||
|
ColorPickerFrame:SetColorRGB(pr, pg, pb)
|
||||||
|
ColorPickerFrame:SetFrameStrata("DIALOG")
|
||||||
|
ShowUIPanel(ColorPickerFrame)
|
||||||
|
end)
|
||||||
|
AddTooltip(holder, tip)
|
||||||
|
AddTooltip(AddRowHover(holder, tip, function() holder:Click() end), tip)
|
||||||
|
|
||||||
|
table.insert(refreshers, function()
|
||||||
|
local c = db()[key] or { 1, 1, 1 }
|
||||||
|
tex:SetVertexColor(c[1], c[2], c[3])
|
||||||
|
end)
|
||||||
|
return holder
|
||||||
|
end
|
||||||
|
|
||||||
|
local function CreateButton(parent, label, tip, onClick)
|
||||||
|
local b = CreateFrame("Button", nil, parent, "UIPanelButtonTemplate")
|
||||||
|
b:SetWidth(110)
|
||||||
|
b:SetHeight(20)
|
||||||
|
b:SetText(label)
|
||||||
|
b:SetScript("OnClick", onClick)
|
||||||
|
AddTooltip(b, tip)
|
||||||
|
if pfUI and pfUI.api and pfUI.api.SkinButton then pfUI.api.SkinButton(b) end
|
||||||
|
return b
|
||||||
|
end
|
||||||
|
|
||||||
|
local function CreateHeading(parent, text, x, y)
|
||||||
|
local fs = parent:CreateFontString(nil, "OVERLAY", "GameFontNormal")
|
||||||
|
fs:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y)
|
||||||
|
fs:SetText(text)
|
||||||
|
ApplyFont(fs, 13)
|
||||||
|
fs:SetTextColor(1, 0.82, 0)
|
||||||
|
return fs
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- panel ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
local function BuildPanel()
|
||||||
|
if panel then return end
|
||||||
|
|
||||||
|
-- Chrome copied from TotemBar's options panel so the two addons read as one family:
|
||||||
|
-- gold title top-left, close button top-right, clamped to screen, ESC closes, version footer.
|
||||||
|
-- The one thing NOT copied is its hardcoded height -- that file carries a comment warning that
|
||||||
|
-- every new row has to be added to the constant by hand or the last widget lands on the footer,
|
||||||
|
-- which is exactly the bug reported here. The height below is computed from the layout cursor.
|
||||||
|
panel = CreateFrame("Frame", "BulwarkFrameOptions", UIParent)
|
||||||
|
panel:SetWidth(PANEL_W)
|
||||||
|
panel:SetHeight(200) -- provisional; recomputed at the end of this function
|
||||||
|
panel:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
|
||||||
|
panel:SetFrameStrata("DIALOG")
|
||||||
|
panel:SetMovable(true)
|
||||||
|
panel:EnableMouse(true)
|
||||||
|
panel:RegisterForDrag("LeftButton")
|
||||||
|
panel:SetScript("OnDragStart", function() this:StartMoving() end)
|
||||||
|
panel:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
|
||||||
|
panel:SetClampedToScreen(true)
|
||||||
|
-- Own frame art, generated by tools/gen_panel_textures.js with the same parameters TotemBar
|
||||||
|
-- uses, so the two panels read as one product. Deliberately not pfUI's generic backdrop and
|
||||||
|
-- not the Blizzard dialog frame: this is the addon's own chrome, and it ships with the addon.
|
||||||
|
panel:SetBackdrop({
|
||||||
|
bgFile = "Interface\\AddOns\\BulwarkFrame\\textures\\panel_bg",
|
||||||
|
edgeFile = "Interface\\AddOns\\BulwarkFrame\\textures\\panel_border",
|
||||||
|
tile = true, tileSize = 128, edgeSize = 16,
|
||||||
|
insets = { left = 5, right = 5, top = 5, bottom = 5 },
|
||||||
|
})
|
||||||
|
panel:SetBackdropColor(1, 1, 1, 0.97)
|
||||||
|
|
||||||
|
local title = panel:CreateFontString("BulwarkFrameOptionsTitle", "OVERLAY", "GameFontNormal")
|
||||||
|
title:SetPoint("TOPLEFT", panel, "TOPLEFT", 16, -16)
|
||||||
|
title:SetText("BulwarkFrame Options")
|
||||||
|
ApplyFont(title, 14)
|
||||||
|
title:SetTextColor(0.85, 0.66, 0.31)
|
||||||
|
|
||||||
|
local close = CreateFrame("Button", "BulwarkFrameOptionsClose", panel, "UIPanelCloseButton")
|
||||||
|
close:SetPoint("TOPRIGHT", panel, "TOPRIGHT", -8, -8)
|
||||||
|
if pfUI and pfUI.api and pfUI.api.SkinCloseButton then pfUI.api.SkinCloseButton(close, panel) end
|
||||||
|
|
||||||
|
-- Layout cursors, one per column. Step sizes match TotemBar's: a checkbox row is 28, a slider
|
||||||
|
-- row 44 (it carries its value label above the track), a button row 28, a group heading 22.
|
||||||
|
local L, R = 24, PANEL_W / 2 + 6
|
||||||
|
local yL, yR = -44, -44
|
||||||
|
local lowest = -44
|
||||||
|
|
||||||
|
local function placeL(w, dy)
|
||||||
|
w:SetPoint("TOPLEFT", panel, "TOPLEFT", L, yL)
|
||||||
|
yL = yL - dy
|
||||||
|
if yL < lowest then lowest = yL end
|
||||||
|
end
|
||||||
|
local function placeR(w, dy)
|
||||||
|
w:SetPoint("TOPLEFT", panel, "TOPLEFT", R, yR)
|
||||||
|
yR = yR - dy
|
||||||
|
if yR < lowest then lowest = yR end
|
||||||
|
end
|
||||||
|
-- A slider carries its value label ABOVE the track (OptionsSliderTemplate's $parentText,
|
||||||
|
-- re-anchored in CreateSlider), so the cursor has to leave room for it before placing the
|
||||||
|
-- track -- otherwise the label overlaps whatever sits above, which is exactly how the
|
||||||
|
-- "Absorb model" heading ended up written through "Elemental Weapons rank". TotemBar's 44px
|
||||||
|
-- slider row is this same allowance, just folded into one number.
|
||||||
|
-- Measured, not guessed: with a 14px allowance the value label's top edge sat at rel-y 408.7
|
||||||
|
-- while the heading above ended at 414.7 -- a 6px overlap. 24 puts the label 4px clear.
|
||||||
|
local SLIDER_LABEL = 24
|
||||||
|
local function sliderL(w) yL = yL - SLIDER_LABEL; placeL(w, 26) end
|
||||||
|
local function sliderR(w) yR = yR - SLIDER_LABEL; placeR(w, 26) end
|
||||||
|
local function headL(text) CreateHeading(panel, text, L, yL); yL = yL - 22; if yL < lowest then lowest = yL end end
|
||||||
|
local function headR(text) CreateHeading(panel, text, R, yR); yR = yR - 22; if yR < lowest then lowest = yR end end
|
||||||
|
|
||||||
|
-- left column: frame ------------------------------------------------------------------
|
||||||
|
headL("Frame")
|
||||||
|
|
||||||
|
local lock = CreateCheckbox(panel, "Lock position", "Stops the frame from being dragged.",
|
||||||
|
function() return db().locked end,
|
||||||
|
function(v) db().locked = v end)
|
||||||
|
placeL(lock, 28)
|
||||||
|
|
||||||
|
local mm = CreateCheckbox(panel, "Minimap button", "Show the minimap button.",
|
||||||
|
function() return db().showMinimapButton end,
|
||||||
|
function(v) db().showMinimapButton = v; if BF.UpdateMinimapButton then BF.UpdateMinimapButton() end end)
|
||||||
|
placeL(mm, 28)
|
||||||
|
|
||||||
|
local hideInactive = CreateCheckbox(panel, "Hide when idle",
|
||||||
|
"Hide the frame when the buffer is down and you are out of combat.",
|
||||||
|
function() return db().hideWhenInactive end,
|
||||||
|
function(v) db().hideWhenInactive = v end)
|
||||||
|
placeL(hideInactive, 28)
|
||||||
|
|
||||||
|
local hideOOC = CreateCheckbox(panel, "Combat only", "Show the frame only while in combat.",
|
||||||
|
function() return db().hideOutOfCombat end,
|
||||||
|
function(v) db().hideOutOfCombat = v end)
|
||||||
|
placeL(hideOOC, 40)
|
||||||
|
|
||||||
|
local scale = CreateSlider(panel, "Scale", 0.5, 2.0, 0.05, "Scale: %.2f",
|
||||||
|
"Overall size of the frame.",
|
||||||
|
function() return db().scale end,
|
||||||
|
function(v) db().scale = v end)
|
||||||
|
sliderL(scale)
|
||||||
|
|
||||||
|
local width = CreateSlider(panel, "Width", 80, 400, 5, "Width: %d px",
|
||||||
|
"Frame width in pixels.",
|
||||||
|
function() return db().width end,
|
||||||
|
function(v) db().width = math.floor(v) end)
|
||||||
|
sliderL(width)
|
||||||
|
|
||||||
|
local barH = CreateSlider(panel, "Threshold bar height", 6, 30, 1, "Threshold bar: %d px",
|
||||||
|
"Height of the hit-threshold bar.",
|
||||||
|
function() return db().barHeight end,
|
||||||
|
function(v) db().barHeight = math.floor(v) end)
|
||||||
|
sliderL(barH)
|
||||||
|
|
||||||
|
local timeH = CreateSlider(panel, "Expiry bar height", 3, 24, 1, "Expiry bar: %d px",
|
||||||
|
"Height of the expiry bar. Deliberately flatter than the threshold bar.",
|
||||||
|
function() return db().timeBarHeight end,
|
||||||
|
function(v) db().timeBarHeight = math.floor(v) end)
|
||||||
|
sliderL(timeH)
|
||||||
|
|
||||||
|
local swingH = CreateSlider(panel, "Swing line height", 1, 8, 1, "Swing line: %d px",
|
||||||
|
"Thickness of the swing line.",
|
||||||
|
function() return db().swingHeight end,
|
||||||
|
function(v) db().swingHeight = math.floor(v) end)
|
||||||
|
sliderL(swingH)
|
||||||
|
|
||||||
|
-- right column: elements, colours, model ----------------------------------------------
|
||||||
|
local ry = -46
|
||||||
|
headR("Elements")
|
||||||
|
|
||||||
|
local e1 = CreateCheckbox(panel, "Hit threshold", "Show the threshold bar.",
|
||||||
|
function() return db().showThreshold end,
|
||||||
|
function(v) db().showThreshold = v end)
|
||||||
|
placeR(e1, 28)
|
||||||
|
|
||||||
|
local e2 = CreateCheckbox(panel, "Expiry", "Show the expiry bar.",
|
||||||
|
function() return db().showExpiry end,
|
||||||
|
function(v) db().showExpiry = v end)
|
||||||
|
placeR(e2, 28)
|
||||||
|
|
||||||
|
local e3 = CreateCheckbox(panel, "Swing timer", "Show the swing line.",
|
||||||
|
function() return db().showSwing end,
|
||||||
|
function(v) db().showSwing = v end)
|
||||||
|
placeR(e3, 28)
|
||||||
|
|
||||||
|
local e3b = CreateCheckbox(panel, "Swing on expiry bar",
|
||||||
|
"Let the swing marker ride the expiry bar like pfUI's mana tick, instead of taking its own line.",
|
||||||
|
function() return db().swingOnExpiryBar end,
|
||||||
|
function(v) db().swingOnExpiryBar = v end)
|
||||||
|
placeR(e3b, 28)
|
||||||
|
|
||||||
|
-- Which question the big number answers. A cycle button rather than two checkboxes: the two
|
||||||
|
-- readings are exclusive, and showing both at once would invite reading one as the other.
|
||||||
|
local modeBtn = CreateButton(panel, "Show: max hit",
|
||||||
|
"What the number means. 'max hit' = how large a single hit may be before the buffer is "
|
||||||
|
.. "drained. 'max shield' = how much damage it still absorbs. They differ by the absorb "
|
||||||
|
.. "rate, so this is not a formatting choice.",
|
||||||
|
function()
|
||||||
|
local dd = db()
|
||||||
|
dd.displayMode = (dd.displayMode == "hit") and "shield" or "hit"
|
||||||
|
this:SetText(dd.displayMode == "shield" and "Show: max shield" or "Show: max hit")
|
||||||
|
Applied()
|
||||||
|
end)
|
||||||
|
placeR(modeBtn, 28)
|
||||||
|
table.insert(refreshers, function()
|
||||||
|
modeBtn:SetText(db().displayMode == "shield" and "Show: max shield" or "Show: max hit")
|
||||||
|
end)
|
||||||
|
|
||||||
|
local e4 = CreateCheckbox(panel, "Threshold text", "Show the current / maximum numbers.",
|
||||||
|
function() return db().showThresholdText end,
|
||||||
|
function(v) db().showThresholdText = v end)
|
||||||
|
placeR(e4, 28)
|
||||||
|
|
||||||
|
local e5 = CreateCheckbox(panel, "Expiry text", "Show the remaining seconds as a number.",
|
||||||
|
function() return db().showExpiryText end,
|
||||||
|
function(v) db().showExpiryText = v end)
|
||||||
|
placeR(e5, 40)
|
||||||
|
|
||||||
|
headR("Colours")
|
||||||
|
|
||||||
|
local sw1 = CreateSwatch(panel, "Low", "colorRed", "Colour below the low threshold.")
|
||||||
|
placeR(sw1, 24)
|
||||||
|
local sw2 = CreateSwatch(panel, "Medium", "colorYellow", "Colour between the two thresholds.")
|
||||||
|
placeR(sw2, 24)
|
||||||
|
local sw3 = CreateSwatch(panel, "High", "colorGreen", "Colour above the high threshold.")
|
||||||
|
placeR(sw3, 24)
|
||||||
|
local sw4 = CreateSwatch(panel, "Swing marker", "colorSwing", "Colour of the swing marker.")
|
||||||
|
placeR(sw4, 34)
|
||||||
|
|
||||||
|
headR("Absorb model")
|
||||||
|
|
||||||
|
local rank = CreateSlider(panel, "Talent rank", 0, 3, 1, "Elemental Weapons rank: %d",
|
||||||
|
"Rank of Elemental Weapons: 5 / 10 / 15 % absorbed. Rank 0 means the talent is not learned.",
|
||||||
|
function() return db().talentRank end,
|
||||||
|
function(v) db().talentRank = math.floor(v) end)
|
||||||
|
sliderR(rank)
|
||||||
|
|
||||||
|
-- Set bonus: the T2.5 "+3 % absorption" wording is ambiguous (15 -> 18, or 15 x 1.03) and
|
||||||
|
-- unmeasured. Rather than pick one silently, both readings are selectable and "none" is the
|
||||||
|
-- default -- see README.
|
||||||
|
local setBtn = CreateButton(panel, "Set bonus: none",
|
||||||
|
"T2.5 set bonus reading. 'add' means 15 -> 18 %, 'mult' means 15 x 1.03. Not yet measured.",
|
||||||
|
function()
|
||||||
|
local d = db()
|
||||||
|
if d.setBonusMode == "none" then d.setBonusMode = "add"
|
||||||
|
elseif d.setBonusMode == "add" then d.setBonusMode = "mult"
|
||||||
|
else d.setBonusMode = "none" end
|
||||||
|
this:SetText("Set bonus: " .. d.setBonusMode)
|
||||||
|
Applied()
|
||||||
|
end)
|
||||||
|
placeR(setBtn, 28)
|
||||||
|
table.insert(refreshers, function() setBtn:SetText("Set bonus: " .. (db().setBonusMode or "none")) end)
|
||||||
|
|
||||||
|
local latency = CreateCheckbox(panel, "Latency-adjust swing",
|
||||||
|
"Shift the swing marker by your round-trip time, so it shows when to press rather than "
|
||||||
|
.. "when the server swung.",
|
||||||
|
function() return db().swingUseLatency end,
|
||||||
|
function(v) db().swingUseLatency = v; BF.RefreshSwingInputs() end)
|
||||||
|
placeR(latency, 28)
|
||||||
|
|
||||||
|
-- footer -------------------------------------------------------------------------------
|
||||||
|
local demoBtn = CreateButton(panel, "Demo", "Run the display off a synthetic cycle so colours "
|
||||||
|
.. "and sizes can be judged without a fight.", function()
|
||||||
|
local on = BF.ToggleDemo()
|
||||||
|
this:SetText(on and "Demo: on" or "Demo")
|
||||||
|
end)
|
||||||
|
demoBtn:SetPoint("BOTTOMLEFT", panel, "BOTTOMLEFT", 24, 20)
|
||||||
|
|
||||||
|
local probeBtn = CreateButton(panel, "Probe", "Dump the live aura/weapon data we still need "
|
||||||
|
.. "(also /bulwark probe).", function() BF.RunProbe() end)
|
||||||
|
probeBtn:SetPoint("LEFT", demoBtn, "RIGHT", 8, 0)
|
||||||
|
|
||||||
|
local resetBtn = CreateButton(panel, "Reset", "Restore every setting to its default.", function()
|
||||||
|
BF.resetConfig()
|
||||||
|
Applied()
|
||||||
|
BF.RefreshOptions()
|
||||||
|
if BF.UpdateMinimapButton then BF.UpdateMinimapButton() end
|
||||||
|
end)
|
||||||
|
resetBtn:SetPoint("BOTTOMRIGHT", panel, "BOTTOMRIGHT", -24, 20)
|
||||||
|
|
||||||
|
-- Height from the layout cursor, not from a hand-maintained constant. `lowest` tracks the
|
||||||
|
-- deepest point either column reached, and the footer row is added below it -- so a new option
|
||||||
|
-- can never again end up outside the frame (which is what happened: the last checkbox sat 14px
|
||||||
|
-- past the bottom edge after two options were added to a fixed 470).
|
||||||
|
local FOOTER = 56
|
||||||
|
panel:SetHeight(-lowest + FOOTER)
|
||||||
|
|
||||||
|
-- Footer buttons, full content width split three ways, mirroring TotemBar's block layout.
|
||||||
|
local btnW = (PANEL_W - 48 - 16) / 3
|
||||||
|
demoBtn:SetWidth(btnW)
|
||||||
|
probeBtn:SetWidth(btnW)
|
||||||
|
resetBtn:SetWidth(btnW)
|
||||||
|
|
||||||
|
local ver = (GetAddOnMetadata and GetAddOnMetadata("BulwarkFrame", "Version")) or "0.2.0"
|
||||||
|
local verFS = panel:CreateFontString("BulwarkFrameOptionsVersion", "OVERLAY", "GameFontNormalSmall")
|
||||||
|
ApplyFont(verFS, 10)
|
||||||
|
verFS:SetPoint("BOTTOM", panel, "BOTTOM", 0, 6)
|
||||||
|
verFS:SetText("v" .. ver)
|
||||||
|
verFS:SetTextColor(0.6, 0.6, 0.6)
|
||||||
|
|
||||||
|
-- ESC closes it, like every other panel in this UI.
|
||||||
|
tinsert(UISpecialFrames, "BulwarkFrameOptions")
|
||||||
|
|
||||||
|
panel:Hide()
|
||||||
|
end
|
||||||
|
|
||||||
|
function BF.RefreshOptions()
|
||||||
|
local i
|
||||||
|
for i = 1, table.getn(refreshers) do refreshers[i]() end
|
||||||
|
end
|
||||||
|
|
||||||
|
function BF.ToggleOptions()
|
||||||
|
BuildPanel()
|
||||||
|
if panel:IsShown() then
|
||||||
|
panel:Hide()
|
||||||
|
else
|
||||||
|
BF.RefreshOptions()
|
||||||
|
panel:Show()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
-- BulwarkFrame - probe.lua
|
||||||
|
-- `/bulwark probe` -- one read-only sweep of everything the display still needs answered, dumped
|
||||||
|
-- both to chat and (with SuperWoW) to <WoW>\imports\bulwark_probe.txt so it can be read from
|
||||||
|
-- the dev side without anyone transcribing numbers. WoW-API file (parse-checked only).
|
||||||
|
--
|
||||||
|
-- It answers, in one go:
|
||||||
|
-- 1. which aura carries the remaining DURATION -- 58130 or 58127 (the bar depends on it);
|
||||||
|
-- 2. the real aura ids of the buffs we may want to show. A CAST id is not a BUFF id:
|
||||||
|
-- Stormstrike is cast as 17364 but sits on the player as 52412;
|
||||||
|
-- 3. weapon speeds and the Elemental Weapons rank (-> absorb rate).
|
||||||
|
--
|
||||||
|
-- Best run WITH THE BUFFS UP, in combat, Rockbiter active. A buff that is not up simply does not
|
||||||
|
-- appear -- that is a valid answer too, just a less complete one.
|
||||||
|
--
|
||||||
|
-- ExportFile GOTCHA (in-game verified): it appends ".txt" itself, so the name is passed WITHOUT
|
||||||
|
-- an extension -- "bulwark_probe" becomes bulwark_probe.txt, "bulwark_probe.txt" would become
|
||||||
|
-- bulwark_probe.txt.txt.
|
||||||
|
|
||||||
|
BulwarkFrame = BulwarkFrame or {}
|
||||||
|
local BF = BulwarkFrame
|
||||||
|
|
||||||
|
local function out(lines, s)
|
||||||
|
table.insert(lines, s)
|
||||||
|
DEFAULT_CHAT_FRAME:AddMessage("|cff66bbffBF|r " .. s)
|
||||||
|
end
|
||||||
|
|
||||||
|
function BF.RunProbe()
|
||||||
|
local lines = {}
|
||||||
|
out(lines, "probe " .. date("%Y-%m-%d %H:%M:%S"))
|
||||||
|
out(lines, "superwow=" .. tostring(SUPERWOW_VERSION) .. " nampower=" .. tostring(NAMPOWER_VERSION))
|
||||||
|
|
||||||
|
-- 1) every player buff with id, stacks and remaining time
|
||||||
|
local i = 0
|
||||||
|
local shown = 0
|
||||||
|
while i < 32 do
|
||||||
|
local slot = GetPlayerBuff(i, "HELPFUL")
|
||||||
|
if not slot or slot < 0 then break end
|
||||||
|
local id = GetPlayerBuffID and GetPlayerBuffID(slot)
|
||||||
|
local tl = GetPlayerBuffTimeLeft and GetPlayerBuffTimeLeft(slot)
|
||||||
|
local n = GetPlayerBuffApplications and GetPlayerBuffApplications(slot)
|
||||||
|
local nm = "?"
|
||||||
|
if SpellInfo and type(id) == "number" then
|
||||||
|
local ok, s = pcall(SpellInfo, id)
|
||||||
|
if ok and s then nm = s end
|
||||||
|
end
|
||||||
|
out(lines, "buff id=" .. tostring(id) .. " name=" .. tostring(nm)
|
||||||
|
.. " stacks=" .. tostring(n) .. " timeLeft=" .. tostring(tl))
|
||||||
|
shown = shown + 1
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
if shown == 0 then out(lines, "buff NONE -- no player buffs found") end
|
||||||
|
|
||||||
|
-- 2) the two candidate auras, called out explicitly so the answer is not buried in the list
|
||||||
|
local poolId, timeId = BulwarkFrameDB.poolAura, BulwarkFrameDB.timeAura
|
||||||
|
out(lines, "configured poolAura=" .. tostring(poolId) .. " timeAura=" .. tostring(timeId))
|
||||||
|
out(lines, "state hasPool=" .. tostring(BF.state.hasPool) .. " hasTime=" .. tostring(BF.state.hasTime)
|
||||||
|
.. " stacks=" .. tostring(BF.state.stacks) .. " timeLeft=" .. tostring(BF.state.timeLeft))
|
||||||
|
|
||||||
|
-- 3) API availability -- a missing function should be data, not a silent nil later
|
||||||
|
local names = { "GetPlayerBuff", "GetPlayerBuffID", "GetPlayerBuffTimeLeft",
|
||||||
|
"GetPlayerBuffApplications", "UnitAttackSpeed", "SpellInfo", "ExportFile",
|
||||||
|
"GetNetStats", "UnitExists" }
|
||||||
|
local j
|
||||||
|
for j = 1, table.getn(names) do
|
||||||
|
out(lines, "api " .. names[j] .. "=" .. type(getglobal(names[j])))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 4) weapon speeds, health, latency
|
||||||
|
local main, off = nil, nil
|
||||||
|
pcall(function() main, off = UnitAttackSpeed("player") end)
|
||||||
|
out(lines, "attackSpeed main=" .. tostring(main) .. " off=" .. tostring(off))
|
||||||
|
out(lines, "maxHP=" .. tostring(UnitHealthMax("player"))
|
||||||
|
.. " -> 1 stack = " .. tostring(BulwarkFrameCalc.stackPoints(UnitHealthMax("player")))
|
||||||
|
.. " points, full pool = " .. tostring(BulwarkFrameCalc.maxPool(UnitHealthMax("player"))))
|
||||||
|
local _, _, lag = GetNetStats()
|
||||||
|
out(lines, "latency_ms=" .. tostring(lag))
|
||||||
|
|
||||||
|
-- 5) talent rank of Elemental Weapons, read rather than assumed
|
||||||
|
local rank = nil
|
||||||
|
if GetTalentInfo then
|
||||||
|
local tab, idx
|
||||||
|
for tab = 1, 3 do
|
||||||
|
for idx = 1, 20 do
|
||||||
|
local nm, _, _, _, cur = GetTalentInfo(tab, idx)
|
||||||
|
if nm == "Elemental Weapons" then rank = cur end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
out(lines, "talent ElementalWeapons rank=" .. tostring(rank)
|
||||||
|
.. " configured=" .. tostring(BulwarkFrameDB.talentRank))
|
||||||
|
|
||||||
|
-- 6) to disk, so the dev side can read it directly instead of transcribing chat
|
||||||
|
if type(ExportFile) == "function" then
|
||||||
|
local blob = table.concat(lines, "\n")
|
||||||
|
local ok = pcall(ExportFile, "bulwark_probe", blob)
|
||||||
|
if ok then
|
||||||
|
DEFAULT_CHAT_FRAME:AddMessage("|cff66bbffBF|r written to imports\\bulwark_probe.txt")
|
||||||
|
else
|
||||||
|
DEFAULT_CHAT_FRAME:AddMessage("|cff66bbffBF|r ExportFile failed -- chat output only")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return lines
|
||||||
|
end
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 353 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
@@ -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