Files
pfUI/modules/energytick.lua
T
Brues 8268af915a energytick: let the five-second rule display as a separate bar or the old shade
The shading bar sat on top of the power bar's own fill and shrank from the
right as the window drained, which reads as part of the mana bar rather than
a countdown over it -- the same problem nameplate castbars solve by living
below the health bar instead of on it.

Show Five Second Rule Style now picks between that: a real StatusBar below
the power bar, backdrop and all, or the original translucent overlay for
anyone who prefers it. The style is read once at module load like the other
structural toggles here, not swapped live -- a /reload picks up a change.
Height and color are only meaningful for the separate-bar style, so the
GUI's Five Second Rule Height and the new appearance.castbar.fsrcolor picker
apply to it alone; the overlay is unchanged from before.

The sweep spark's own dim/solid signal (fsrEnd/fsrGain) is untouched by any
of this -- it keeps dimming through an unconfirmed window regardless of
which display, or none, is shown.

Energy/Mana Ticks -- including both five-second-rule settings -- moves from
Unit Frames > General to Unit Frames > Player, right after Powerbar: they
are C.unitframes.player-only, so General was never the right tab, and
Powerbar is the setting group they actually belong to.
2026-09-22 21:19:12 -05:00

299 lines
11 KiB
Lua

-- One server clock drives every power: Player::RegenerateAll fires every
-- REGEN_TIME_FULL (2s), re-arms with `+=`, and is never reset by casting. The
-- five-second rule (SetLastManaUse on any mana-costing cast) changes what a tick
-- pays, never when it lands; mp5 and the player's MOD_MANA_REGEN_INTERRUPT share
-- still come in. That share can't be computed here -- item sources are equip
-- auras absent from the buff list and m_modManaRegenInterrupt is never sent --
-- so the spark shows it instead: dim through the window until a tick lands.
--
-- The sweep free-runs on that clock and phase-locks to observed gains. A gain
-- mid-sweep (Illumination, Judgement of Wisdom, potions, a Mana Spring totem on
-- its own phase) is not the tick and never moves it.
local FIVE_SECOND_RULE = 5
-- gains farther than this from the predicted boundary are not the tick
local TICK_TOLERANCE = .25
-- arrival jitter; a tick inside this band confirms the sweep rather than
-- re-anchoring it, or the spark hitches at every wrap
local TICK_JITTER = .08
-- Player::RegenerateAll:
-- mod = GetTotalAuraModifier(SPELL_AURA_MOD_ENERGY_REGEN_TIME)
-- if mod > 0 then mod = mod * agility / 10 end
-- m_regenTimer += max(1, REGEN_TIME_FULL - mod) -- milliseconds
local REGEN_TIME_FULL = 2
local ENERGY_REGEN_TIME_AURA = 217 -- SPELL_AURA_MOD_ENERGY_REGEN_TIME
-- fixed magnitude is basePoints + baseDice (stored 11 -> 12); a die above 1 is
-- a roll the client can't know, so it counts as nothing rather than a guess
local amountCache = {}
local function auraAmount(spellID)
local amount = amountCache[spellID]
if amount then return amount end
amount = 0
local effects = C_Spell.GetSpellEffectInfo(spellID) -- nil for an id with no record
if effects then
for i = 1, 3 do
local fx = effects[i]
if fx.auraName == ENERGY_REGEN_TIME_AURA and fx.dieSides <= 1 then
amount = fx.basePoints + fx.baseDice
break
end
end
end
amountCache[spellID] = amount
return amount
end
-- A passive is in effect exactly while known (current rank only, never in the
-- buff list); anything castable or cast on us counts only while it is up.
local function getEnergyRegenTimeMod()
local sum = 0
for _, spellID in ipairs(C_SpellBook.GetPlayerSpellsByAura(ENERGY_REGEN_TIME_AURA)) do
if C_Spell.IsSpellPassive(spellID) then
sum = sum + auraAmount(spellID)
end
end
for i = 1, 32 do
local spellID = select(10, C_UnitAuras.UnitAura("player", i, "HELPFUL"))
if not spellID then break end
sum = sum + auraAmount(spellID)
end
return sum
end
-- cleared on SPELLS_CHANGED (passives) and PLAYER_AURAS_CHANGED (buffs), and
-- recomputed by the next tick that asks. Agility stays live: it's one call.
local energyRegenTimeMod
local function getAdjustedTickTimer()
if not energyRegenTimeMod then
energyRegenTimeMod = getEnergyRegenTimeMod()
end
if energyRegenTimeMod == 0 then return REGEN_TIME_FULL end
-- ms on the server, seconds here; the 1ms floor is the server's and this is a divisor
local reduction = energyRegenTimeMod * UnitStat("player", 2) / 10000
return math.max(0.001, REGEN_TIME_FULL - reduction)
end
pfUI:RegisterModule("energytick", function()
if not pfUI.uf or not pfUI.uf.player then
return
end
-- inside the module body on purpose: C is on pfUI.env, not _G
local function getBarWidth()
return C.unitframes.player.pwidth ~= "-1" and C.unitframes.player.pwidth or C.unitframes.player.width
end
local rawborder, default_border = GetBorderSize("unitframes")
local cbtexture = pfUI.media[C.appearance.castbar.texture]
-- style is read once: switching it in the GUI needs a /reload, same as the
-- other structural (not cosmetic) toggles in this file
local fsrOverlay = C.unitframes.player.fsrstyle == "1"
-- was this gain the regen tick? if so, re-anchor the sweep on it
local function lockTick(frame)
local now, period = GetTime(), getAdjustedTickTimer()
if frame.start then
-- signed distance to the nearest predicted boundary
local err = mod(now - frame.start, period)
if err > period / 2 then err = err - period end
if math.abs(err) <= TICK_TOLERANCE then
-- correct only what lies beyond normal jitter
if err > TICK_JITTER then
frame.start = frame.start + (err - TICK_JITTER)
elseif err < -TICK_JITTER then
frame.start = frame.start + (err + TICK_JITTER)
end
frame.max, frame.rejected = period, nil
return true
end
-- two rejected gains one period apart are the real clock: relock to it
local periodic = frame.rejected and math.abs(now - frame.rejected - period) <= TICK_TOLERANCE
if not periodic then
frame.rejected = now
return false
end
end
frame.start, frame.max, frame.rejected = now, period, nil
return true
end
local energytick = CreateFrame("Frame", nil, pfUI.uf.player.power.bar)
energytick:SetAllPoints(pfUI.uf.player.power.bar)
energytick:RegisterEvent("PLAYER_ENTERING_WORLD")
energytick:RegisterUnitEvent("UNIT_DISPLAYPOWER", "player")
energytick:RegisterUnitEvent("UNIT_ENERGY", "player")
energytick:RegisterUnitEvent("UNIT_MANA", "player")
energytick:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")
energytick:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_STOP", "player")
energytick:RegisterEvent("SPELLS_CHANGED")
energytick:RegisterEvent("PLAYER_AURAS_CHANGED")
energytick:SetScript("OnEvent", function()
if UnitPowerType("player") == Enum.PowerType.Mana and C.unitframes.player.manatick == "1" then
this.mode = "MANA"
this:Show()
elseif UnitPowerType("player") == Enum.PowerType.Energy and C.unitframes.player.energy == "1" then
this.mode = "ENERGY"
this:Show()
else
this:Hide()
end
if event == "SPELLS_CHANGED" or event == "PLAYER_AURAS_CHANGED" then
energyRegenTimeMod = nil
return
end
if event == "PLAYER_ENTERING_WORLD" then
this.lastPower = UnitPower("player")
end
-- the rule arms on the cast (Spell::TakePower: mana powerType, cost > 0),
-- not on a mana drop -- Mana Burn lowers mana without arming it
if event == "UNIT_SPELLCAST_SUCCEEDED" and arg1 == "player" then
local cost = C_Spell.GetSpellPowerCost(arg3)
cost = cost and cost[1]
if cost and cost.type == Enum.PowerType.Mana and cost.cost > 0 then
this.fsrSpell, this.fsrEnd = arg3, GetTime() + FIVE_SECOND_RULE
this.fsrGain = nil
end
return
end
-- Unit::Update won't expire the rule while the spending spell still channels
if event == "UNIT_SPELLCAST_CHANNEL_STOP" and arg1 == "player" then
if this.fsrSpell and this.fsrSpell == arg3 then
this.fsrEnd, this.fsrGain = GetTime() + FIVE_SECOND_RULE, nil
end
return
end
if (event == "UNIT_MANA" or event == "UNIT_ENERGY") and arg1 == "player" then
local power = UnitPower("player")
local diff = this.lastPower and (power - this.lastPower) or 0
this.lastPower = power
-- only a gain can be the tick; a spend never touches the phase
if diff > 0 and lockTick(this) then
-- a tick inside the window proves regen continues through it
if this.fsrEnd and this.fsrEnd > GetTime() then
this.fsrGain = true
end
end
-- phase is kept while hidden; OnUpdate catches up by whole periods
if this.mode == "MANA" and power >= UnitPowerMax("player") then
this:Hide()
end
end
end)
energytick:SetScript("OnUpdate", function()
-- Throttle for performance
if (this.tick or 0) > GetTime() then
return
end
this.tick = GetTime() + 0.020 -- ~50 FPS
-- five-second rule drains to nothing. The display is cosmetic and can be
-- hidden on its own; the window itself (fsrEnd/fsrGain) keeps running
-- either way -- it still dims the sweep spark below.
local remaining = this.fsrEnd and (this.fsrEnd - GetTime()) or 0
if this.mode == "MANA" and remaining > 0 then
if C.unitframes.player.showfsr == "1" then
if fsrOverlay then
this.fsrbar:SetWidth(getBarWidth() * remaining / FIVE_SECOND_RULE)
else
this.fsrbar:SetValue(remaining)
end
this.fsrbar:Show()
else
this.fsrbar:Hide()
end
else
this.fsrSpell, this.fsrEnd, this.fsrGain = nil, nil, nil
this.fsrbar:Hide()
end
if not this.start then
this.spark:SetAlpha(0)
return
end
if this.mode == "MANA" and UnitPower("player") >= UnitPowerMax("player") then
this.spark:SetAlpha(0)
return
end
this.current = GetTime() - this.start
-- roll over by whole periods, not from now: restarting bakes frame
-- overshoot into the phase as drift the lock then has to chase
if this.current > this.max then
this.start = this.start + this.max * math.floor(this.current / this.max)
this.max = getAdjustedTickTimer()
this.current = GetTime() - this.start
end
-- dim while the rule is up and nothing has ticked inside it yet
this.spark:SetAlpha((remaining > 0 and not this.fsrGain) and .4 or 1)
if not C.unitframes.player.pheight then
return
end
local pos = getBarWidth() * (this.current / this.max)
this.spark:SetPoint("LEFT", pos - ((C.unitframes.player.pheight + 5) / 2), 0)
end)
if fsrOverlay then
-- classic style: a translucent shade on top of the power bar's own fill
energytick.fsrbar = energytick:CreateTexture(nil, "ARTWORK")
energytick.fsrbar:SetTexture(1, 1, 1, .15)
energytick.fsrbar:SetPoint("TOPLEFT", 0, 0)
energytick.fsrbar:SetPoint("BOTTOMLEFT", 0, 0)
else
-- a real bar below the power bar, not an overlay on top of it -- the same
-- treatment nameplate castbars get relative to the health bar, so the
-- window can't be mistaken for missing mana
energytick.fsrbar = CreateFrame("StatusBar", nil, pfUI.uf.player)
energytick.fsrbar:SetStatusBarTexture(cbtexture)
energytick.fsrbar:SetMinMaxValues(0, FIVE_SECOND_RULE)
energytick.fsrbar:SetPoint("TOPLEFT", pfUI.uf.player.power.bar, "BOTTOMLEFT", 0, -default_border * 2)
energytick.fsrbar:SetPoint("TOPRIGHT", pfUI.uf.player.power.bar, "BOTTOMRIGHT", 0, -default_border * 2)
energytick.fsrbar:SetHeight(tonumber(C.unitframes.player.fsrheight) or 4)
energytick.fsrbar:SetStatusBarColor(GetStringColor(C.appearance.castbar.fsrcolor))
CreateBackdrop(energytick.fsrbar, default_border)
CreateBackdropShadow(energytick.fsrbar)
end
energytick.fsrbar:Hide()
energytick.spark = energytick:CreateTexture(nil, "OVERLAY")
energytick.spark:SetTexture("Interface\\CastingBar\\UI-CastingBar-Spark")
energytick.spark:SetHeight(C.unitframes.player.pheight + 15)
energytick.spark:SetWidth(C.unitframes.player.pheight + 5)
energytick.spark:SetBlendMode("ADD")
local hookUpdateConfig = pfUI.uf.player.UpdateConfig
function pfUI.uf.player.UpdateConfig()
energytick.spark:SetHeight(C.unitframes.player.pheight + 15)
energytick.spark:SetWidth(C.unitframes.player.pheight + 5)
if not fsrOverlay then
energytick.fsrbar:SetHeight(tonumber(C.unitframes.player.fsrheight) or 4)
energytick.fsrbar:SetStatusBarColor(GetStringColor(C.appearance.castbar.fsrcolor))
end
hookUpdateConfig(pfUI.uf.player)
end
end)