TotemBar v0.3.0

This commit is contained in:
2026-08-17 15:19:36 +02:00
parent 8dbc411e80
commit 166d870dec
12 changed files with 1009 additions and 98 deletions
+125 -61
View File
@@ -65,8 +65,7 @@ local EMPTY_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
local RECALL_SPELL_NAME = "Totemic Recall"
local RECALL_ICON_FALLBACK = "Interface\\Icons\\Spell_Nature_AstralRecal"
-- Pulse UI Rev 2 - floating round icons + ripple pulse (spec
-- docs/superpowers/specs/2026-07-09-pulse-ui-design.md, section "Rev 2").
-- Pulse UI - floating round icons + ripple pulse.
-- Ring flipbook: 8x8 grid texture (64 cells). Cells 1..62 = duration-arc
-- fill frames (0=empty .. 61=full), cell 63 = wave ring (ripple), cell 64 =
-- the decorative frame band - always shown, hides the icon's square
@@ -172,7 +171,11 @@ local anyOutOfRange = false
-- cast it ONCE without changing the slot's default; right-click one to
-- make it the slot's new default. Shares one frame + a pool of icon
-- buttons.
local MAX_FLYOUT_ICONS = 6 -- most totems any single element has
-- Computed from the static totem map, not hard-coded: Air has SEVEN totems,
-- and a hard-coded 6 silently dropped the last one (Tranquil Air Totem)
-- whenever the Air slot was empty -- the flyout then lists ALL known totems of
-- the element, not "all minus the chosen default".
local MAX_FLYOUT_ICONS = TotemBar.maxTotemsPerElement() -- most totems any single element has
local FLYOUT_PAD = 4 -- inner padding inside the flyout frame
local FLYOUT_GAP = 2 -- gap between element button top and flyout bottom
local FLYOUT_HIDE_INTERVAL = 0.1 -- throttle for the mouse-leave hide check
@@ -191,6 +194,7 @@ local RefreshCooldown
local EnsureFlyoutFrame
local ShowFlyout
local RefreshFlyoutCooldowns
local RefreshFlyoutMana
local HideFlyout
local OnFlyoutUpdate
local CreateElementButton
@@ -667,6 +671,7 @@ ShowFlyout = function(button, element)
if TotemBar.refreshBindOverlays then
TotemBar.refreshBindOverlays()
end
RefreshFlyoutMana()
end
-- Re-drives the cooldown swipe on every CURRENTLY-SHOWN pooled flyout
@@ -694,6 +699,33 @@ RefreshFlyoutCooldowns = function()
end
end
-- Dims every shown flyout icon the player cannot currently afford, on the same
-- rule as the bar buttons (TotemBar.iconTintFor / notEnoughMana). Flyout icons
-- have no range state of their own -- they are alternatives that aren't out --
-- so only the mana half applies. Cheap: the cost lookup is cached, and the
-- flyout only exists while it is hovered.
RefreshFlyoutMana = function()
if not (flyoutFrame and flyoutFrame:IsShown()) then
return
end
local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
local clearcasting = TotemBar.hasClearcasting and TotemBar.hasClearcasting()
for i = 1, MAX_FLYOUT_ICONS do
local ico = flyoutIcons[i]
if ico:IsShown() and ico.totemName then
local oom = false
if not clearcasting then
oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(ico.totemName), playerMana)
end
local r, g, b, key = TotemBar.iconTintFor(false, oom)
if key ~= ico.tintKey then
ico.icon:SetVertexColor(r, g, b)
ico.tintKey = key
end
end
end
end
HideFlyout = function()
if flyoutFrame then
flyoutFrame:Hide()
@@ -713,6 +745,7 @@ OnFlyoutUpdate = function()
return
end
flyoutElapsed = 0
RefreshFlyoutMana()
if flyoutOwnerButton and (MouseIsOver(flyoutFrame) or MouseIsOver(flyoutOwnerButton)) then
return
end
@@ -890,7 +923,8 @@ CreateElementButton = function(element, index)
btn.timerVisible = false -- cached shown-state, avoid redundant Show/Hide
btn.timerLastText = nil -- cached last string, avoid redundant SetText
btn.timerLastLow = nil -- cached last <=5s tint state, avoid redundant SetTextColor
btn.tintRed = false -- cached out-of-range tint state, avoid redundant SetVertexColor
btn.tintRed = false -- last out-of-range verdict (read by the Recall button's pulse)
btn.tintKey = 0 -- cached composed tint (range + out-of-mana), avoid redundant SetVertexColor
-- Set here (ahead of the later btn.element = element below) because
-- ApplyRoundFrame, called at the end of this block, needs it for the
@@ -1222,21 +1256,22 @@ CreateRecallButton = function(index)
ChatOut:AddMessage("TotemBar: auto-recall before setting OFF")
end
RefreshRecallIndicator()
elseif TotemBar.confidentNoneOut and TotemBar.confidentNoneOut() then
-- Confidently nothing out (tracked this session, all expired): don't waste
-- Totemic Recall's 6s cooldown on a no-op cast. After a /reload the tracking is
-- empty and 1.12 can't re-detect a pre-reload totem, so confidentNoneOut fails
-- open there and the recall goes through the else branch below (see core/cast.lua).
ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown).")
else
-- Snapshot for the refund learner runs AFTER the cast: activeTotems
-- is still populated here (clearActiveTotems runs last).
CastSpellByName(RECALL_SPELL_NAME)
if TotemBar.snapshotRecallCost then TotemBar.snapshotRecallCost() end
-- Totemic Recall drops every active totem at once; clear our
-- own-tracking timers so the icons' countdowns disappear too
-- (GetTotemInfo, if present, will also reflect this).
TotemBar.clearActiveTotems()
-- Shared with the TOTEMBAR_RECALL keybind (bind.lua) so the two manual
-- paths cannot drift apart: TotemBar.manualRecall does the gate, the
-- cooldown check, the queue-safe cast, the refund snapshot and the
-- own-tracking wipe (see core/cast.lua). Only the chat feedback is ours.
local action = TotemBar.manualRecall()
if action == "none-out" then
-- Confidently nothing out (cast something this session, all expired):
-- don't waste Totemic Recall's 6s cooldown on a no-op cast. After a
-- /reload the session flag is clear and 1.12 can't re-detect a pre-reload
-- totem, so the gate fails open there. Pressing again also overrides it,
-- for a totem we cannot see (e.g. dropped from the action bar).
ChatOut:AddMessage("TotemBar: no totems out - not recalling (saves the 6s cooldown). Press again to recall anyway.")
elseif action == "cooldown" then
ChatOut:AddMessage("TotemBar: Totemic Recall isn't ready yet - totem timers kept.")
end
end
end)
@@ -1353,6 +1388,12 @@ UpdateTimerDisplays = function()
local activeTotems = TotemBar.activeTotems
local outOfRangeFound = false -- OR-accumulator across this pass; written to anyOutOfRange at the end
-- Mana inputs for the out-of-mana dim, read ONCE per pass rather than per
-- button: hasClearcasting walks the player's buffs, and there is no reason
-- to do that four times for one tick.
local playerMana = (type(UnitMana) == "function") and UnitMana("player") or nil
local clearcasting = TotemBar.hasClearcasting and TotemBar.hasClearcasting()
for i = 1, table.getn(elements) do
local element = elements[i]
local btn = elementButtons[element]
@@ -1394,9 +1435,21 @@ UpdateTimerDisplays = function()
gtiActive = active
gtiName = tname
gtiStart = start
gtiDuration = duration
if start and duration then
gtiRemaining = TotemBar.remaining(start, duration, now)
-- Mastery parity: libtotem reports FLAT book durations, own
-- tracking stores the Totemic Mastery-inflated one. Without
-- scaling GTI by the same factor the timer flips source at the
-- book duration and the countdown jumps back UP instead of
-- running out (see TotemBar.gtiDurationWithMastery).
gtiDuration = TotemBar.gtiDurationWithMastery(duration, tname,
TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery())
if start and gtiDuration then
gtiRemaining = TotemBar.remaining(start, gtiDuration, now)
end
-- Latch: did libtotem ever report THIS record's totem active?
-- Only then may a later "slot inactive" veto the range tint
-- below (TotemBar.rangeTintActive).
if ownRecord and gtiActive and gtiName == ownRecord.totemName then
ownRecord.gtiTracked = true
end
end
@@ -1517,52 +1570,51 @@ UpdateTimerDisplays = function()
-- buff whose texture matches this totem's icon?".
--
-- ACTIVE requires an own-tracking record (ownRecord, already
-- nil'd above once its stored duration expires) AND, when
-- pfUI's libtotem is present, GetTotemInfo(i) agreeing the
-- slot is still active. That second check is what keeps a
-- totem someone/something DESTROYED (burned, killed) before
-- its timer ran out from flashing red - once GTI says the
-- slot is gone, we just go back to normal, not red.
local rangeActive = (ownRecord ~= nil)
if rangeActive and hasGTI and not gtiActive then
rangeActive = false
end
-- nil'd above once its stored duration expires). pfUI's libtotem
-- may veto it - that is what keeps a totem someone/something
-- DESTROYED (burned, killed) before its timer ran out from
-- flashing red - but ONLY for a slot libtotem actually tracked
-- (gtiTracked, latched above). Its single cast queue records at
-- most one totem per multi-drop, so vetoing on a merely MISSING
-- GTI record silenced the tint and the Recall pulse for most of
-- a freshly dropped set. See TotemBar.rangeTintActive.
local rangeActive = TotemBar.rangeTintActive(ownRecord ~= nil, hasGTI,
ownRecord and ownRecord.gtiTracked, gtiActive)
if not rangeActive then
if btn.tintRed then
btn.icon:SetVertexColor(1, 1, 1)
btn.tintRed = false
end
else
local hasBuff = TotemBar.hasBuffWithIcon(ownRecord.icon)
if hasBuff then
local rangeRed = false
if rangeActive then
if TotemBar.hasBuffWithIcon(ownRecord.icon) then
-- In range: remember it (self-learning - marks this
-- as a buff totem so a later drop-off can be told
-- apart from a totem that simply never grants one).
ownRecord.everHadBuff = true
if btn.tintRed then
btn.icon:SetVertexColor(1, 1, 1)
btn.tintRed = false
end
elseif ownRecord.everHadBuff then
-- Had the buff earlier from this cast, don't have it
-- now: wandered out of the totem's range.
if not btn.tintRed then
btn.icon:SetVertexColor(1, 0.35, 0.35)
btn.tintRed = true
end
else
-- Either a non-buff totem (Searing/Magma/Grounding/
-- etc. never grant a matching buff, so everHadBuff
-- stays false forever -> never red) or we simply
-- haven't been in range yet since this cast.
if btn.tintRed then
btn.icon:SetVertexColor(1, 1, 1)
btn.tintRed = false
end
-- now: wandered out of the totem's range. (A non-buff
-- totem - Searing/Magma/Grounding - never sets
-- everHadBuff, so it can never turn red here.)
rangeRed = true
end
end
-- Out-of-mana dim for the totem THIS slot would cast (the chosen
-- one, not the record's - the record is a totem already standing,
-- which costs nothing to keep). Composed with the range tint in one
-- place: two independent SetVertexColor call sites would race, and
-- whichever ran last would win (see TotemBar.iconTintFor).
local chosenName = TotemBarDB.chosen and TotemBarDB.chosen[element]
local oom = false
if chosenName and not clearcasting then
oom = TotemBar.notEnoughMana(TotemBar.getTotemManaCost(chosenName), playerMana)
end
local tr2, tg2, tb2, tintKey = TotemBar.iconTintFor(rangeRed, oom)
if tintKey ~= btn.tintKey then
btn.icon:SetVertexColor(tr2, tg2, tb2)
btn.tintKey = tintKey
end
btn.tintRed = rangeRed
if btn.tintRed then
outOfRangeFound = true
end
@@ -1788,9 +1840,13 @@ function TotemBar.DumpRingRenderState()
if hasGTI then
local ok, active, tname, start, duration = pcall(GetTotemInfo, i)
if ok then
gtiActive, gtiDuration = active, duration
if start and duration then
gtiRemaining = TotemBar.remaining(start, duration, now)
-- Same mastery scaling UpdateTimerDisplays applies, so this
-- dump keeps reporting what the render path would compute.
gtiActive = active
gtiDuration = TotemBar.gtiDurationWithMastery(duration, tname,
TotemBar.hasTotemicMastery and TotemBar.hasTotemicMastery())
if start and gtiDuration then
gtiRemaining = TotemBar.remaining(start, gtiDuration, now)
end
end
end
@@ -2530,7 +2586,15 @@ eventFrame:SetScript("OnEvent", function()
local element = TotemBar.elementOf(totemName)
local rec = element and TotemBar.activeTotems[element]
if rec and rec.totemName == totemName then
rec.pulseAnchor = GetTime()
-- These lines carry no caster, so another shaman's
-- identically-named totem is indistinguishable from ours.
-- Accept at most one anchor per pulse interval so a foreign
-- tick train can't drag our phase off-beat.
local pd = TotemBar.pulseInfo(totemName)
local nowGain = GetTime()
if TotemBar.shouldAnchorPulse(rec.pulseAnchor, pd and pd.interval, nowGain) then
rec.pulseAnchor = nowGain
end
end
end
end