TotemBar v0.3.0
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user