Files
Vampify/gui/sct.lua
T
ShempError ad50ebe636 Vampify 0.3.0
A TurtleWoW 1.12.1 addon that shows the healing returned by the Vampirism item stat.

The game emits no event for that healing, so the addon computes it from the player's own
outgoing damage using a formula measured in-game, and shows it live on a movable bar with a
per-ability breakdown, an overheal split, automatic source detection from equipped gear, and
optional scrolling combat text.
2026-08-25 18:14:18 +02:00

432 lines
20 KiB
Lua

-- Vampify -- scrolling combat text, our own.
--
-- Blizzard's FCT (gui/fct.lua) cannot be positioned or fully recolored by an addon --
-- Blizzard_CombatText owns its own frame, and its position is not settable from outside. This is
-- the alternative engine: a positionable anchor frame, a pooled set of FontStrings, and one shared
-- OnUpdate driving every number in flight. Which engine actually runs is chosen by
-- VampifyDB.sct.mode ("own" | "bar" | "blizzard") and dispatched from core/commands.lua -- "bar"
-- is this SAME engine and pool, just anchored to the display bar instead of a free-floating
-- position (see reanchor() below); this file does not know or care that the Blizzard alternative
-- exists.
--
-- Pure-Lua 5.0 dofile-loadable: VampifySCT exists at file scope, but every function that touches
-- the WoW API lives inside "if CreateFrame then" -- same shape as gui/display.lua and
-- gui/options.lua. resolveAnchorMode below is the one exception: it is pure decision logic (mode +
-- bar-frame-exists -> "bar" | "free"), so it lives outside the guard, offline-testable the same
-- way gui/display.lua's V.boundFlag is (tools/luatests/test_sct.lua).
VampifySCT = {}
local S = VampifySCT
-- Which anchor a given mode resolves to. "bar" only when the mode actually asks for it AND the
-- bar frame exists yet -- reanchor() below is the reason existence, not visibility, is the gate:
-- a hidden-but-created bar can still anchor to, a not-yet-created one cannot. Every other mode,
-- including nil (fresh/unmigrated profile) and any future/unknown value, resolves to the
-- free-floating anchor rather than erroring -- the safe, always-available fallback.
function S.resolveAnchorMode(mode, barExists)
if mode == "bar" and barExists then return "bar" end
return "free"
end
if CreateFrame then
local W = VampifyWidgets
local C = W.COLORS
local POOL_MAX = 20
local DUR_FALLBACK = 1.5
-- Crit pop, in multiples of the configured font size. Ratios taken from Blizzard's own combat
-- text: MINHEIGHT 30 and MAXHEIGHT 60 against a base of 25, grown over 0.05s and shrunk back by
-- 0.2s (Blizzard_CombatText.lua:4-8).
local CRIT_MIN = 30 / 25
local CRIT_MAX = 60 / 25
local CRIT_GROW = 0.05
local CRIT_SHRINK = 0.2
local anchor, label
local moveMode = false
local spawnCounter = 0
-- Cache of the last anchor mode actually applied ("bar" | "free" | nil before the first
-- build). Lets reanchor() below be a cheap no-op on every spawn once nothing has changed,
-- instead of an unconditional ClearAllPoints+SetPoint per number -- which matters because it
-- would otherwise fight an in-progress free-anchor drag. It cannot: the mode cannot change
-- mid-drag (moveMode is only reachable in "own" mode, see S.setMoveMode below), so a drag never
-- sees anchorMode flip underneath it.
local lastAnchorMode = nil
-- Fixed-size slot pool: a pre-sized array of records, each either idle or carrying one number.
-- Free slots live on a manual LIFO stack (freeStack/freeTop) -- push/pop by hand, never
-- table.insert/table.remove/table.getn, so there is no growing "n" field and no need for
-- VampifyConst.resetList (core/const.lua): the Lua-5.0 trap that helper guards against simply
-- does not exist in this design.
local slots = {}
local freeStack, freeTop = {}, 0
for i = 1, POOL_MAX do
slots[i] = { active = false, fs = nil, t = 0, dur = DUR_FALLBACK, dx = 0, dy0 = 0,
alphaMul = 1, crit = false, base = 16 }
freeTop = freeTop + 1
freeStack[freeTop] = i
end
local function db() return VampifyConfig.get() end
-- Only the border/background/label are hidden in normal operation -- the frame itself always
-- stays shown, because the pooled FontStrings are its children and a hidden parent would hide
-- them too. "Invisible" here means alpha 0, not SetBackdrop(nil).
local function applyMoveVisuals()
if moveMode then
anchor:EnableMouse(true)
anchor:SetBackdropColor(0.08, 0.09, 0.12, 0.85)
anchor:SetBackdropBorderColor(C.gold.r, C.gold.g, C.gold.b, 1)
if label then label:Show() end
else
anchor:EnableMouse(false)
anchor:SetBackdropColor(0, 0, 0, 0)
anchor:SetBackdropBorderColor(0, 0, 0, 0)
if label then label:Hide() end
end
end
-- Applies the resolved anchor mode to the live frame -- "bar" hangs the anchor off
-- VampifyDisplayFrame's top edge (numbers rise FROM the bar's top upward, matching every other
-- rising-number layout in this file); anything else restores the stored free position, the
-- same positioning logic build() always used. `force` bypasses the lastAnchorMode cache: used
-- for the initial build() placement and for an explicit runtime switch (S.applyPos, called from
-- gui/options.lua on every mode-radio click and on Reset) so the anchor moves immediately
-- instead of waiting for the next spawn. Without force, a spawn's routine call is a no-op
-- unless the resolved mode actually changed since last time -- see lastAnchorMode's comment.
local function reanchor(force)
local cfg = db()
local mode = cfg and cfg.sct and cfg.sct.mode
local barExists = getglobal("VampifyDisplayFrame") and true or false
local anchorMode = S.resolveAnchorMode(mode, barExists)
if not force and anchorMode == lastAnchorMode then return end
lastAnchorMode = anchorMode
anchor:ClearAllPoints()
if anchorMode == "bar" then
anchor:SetPoint("BOTTOM", VampifyDisplayFrame, "TOP", 0, 6)
else
local pos = (cfg and cfg.sct and cfg.sct.pos) or { point = "CENTER", x = -180, y = 0 }
anchor:SetPoint(pos.point, UIParent, pos.point, pos.x, pos.y)
end
end
local function build()
if anchor then return anchor end
local a = CreateFrame("Frame", "VampifySCTAnchor", UIParent)
a:SetWidth(140)
a:SetHeight(30)
-- HIGH, not the default MEDIUM/level 1 -- MEDIUM disappears under pfUI, the exact failure
-- gui/display.lua already hit and documented there.
a:SetFrameStrata("HIGH")
a:SetFrameLevel(10)
a:SetMovable(true)
a:EnableMouse(false) -- normal operation: out of the mouse's way entirely
a:SetClampedToScreen(true)
a:RegisterForDrag("LeftButton")
a:SetBackdrop(W.SOLID_BACKDROP)
a:SetScript("OnDragStart", function()
if moveMode then this:StartMoving() end
end)
a:SetScript("OnDragStop", function()
this:StopMovingOrSizing()
local point, _, _, x, y = this:GetPoint()
local c = db()
if c and c.sct then c.sct.pos = { point = point, x = x, y = y } end
end)
label = a:CreateFontString("VampifySCTAnchorLabel", "OVERLAY")
W.ApplyFont(label, 10)
W.SetTextColorG(label, C.goldHi)
label:SetPoint("CENTER", a, "CENTER", 0, 0)
label:SetText("Vampify SCT")
label:Hide()
anchor = a
-- Initial placement: bar anchor if mode == "bar" AND VampifyDisplayFrame already exists at
-- this point, else the stored free position. If the bar has not been created yet (load
-- order), this falls back to free without error -- spawn()'s own reanchor() call re-checks
-- on every emit, lazily, so the very next number picks up the bar once it exists.
reanchor(true)
anchor:Show() -- always shown; see applyMoveVisuals for why
applyMoveVisuals()
return a
end
local function fontSize()
local cfg = db()
local sct = cfg and cfg.sct
return (sct and sct.fontSize) or 16
end
-- Outline flag and shadow, the two halves of "readable over anything". Stored as a string
-- because that is exactly what SetFont's third argument takes; "" means no outline.
local function outlineFlag()
local cfg = db()
local sct = cfg and cfg.sct
local f = sct and sct.outline
if f == "" then return nil end
return f or "THICKOUTLINE"
end
local function shadowOn()
local cfg = db()
local sct = cfg and cfg.sct
if sct and sct.shadow == false then return false end
return true
end
-- How far a number travels over its lifetime. Doubles as the crowding control: a taller rise
-- spreads a burst over more vertical space, which is exactly what the user asked this slider
-- for -- so the numbers don't overlap so heavily and have more room.
local function riseHeight()
local cfg = db()
local sct = cfg and cfg.sct
return (sct and sct.rise) or 40
end
local function ensureFs(i)
local s = slots[i]
if s.fs then return s.fs end
local fs = anchor:CreateFontString("VampifySCTNum"..i, "OVERLAY")
W.ApplyFont(fs, fontSize(), outlineFlag())
W.ApplyShadow(fs, shadowOn())
s.fs = fs
return fs
end
-- Re-apply the font to every pooled FontString. Called when the size slider moves: the pool is
-- built lazily and lives for the session, so without this only numbers spawned from fresh
-- slots would pick up the new size.
function S.applyFont()
local sz, fl, sh = fontSize(), outlineFlag(), shadowOn()
for i = 1, POOL_MAX do
if slots[i] and slots[i].fs then
W.ApplyFont(slots[i].fs, sz, fl)
W.ApplyShadow(slots[i].fs, sh)
end
end
end
-- Pop a free slot off the LIFO stack -- O(1), no table.remove/table.getn. Only when the stack
-- is empty (all 20 numbers in flight at once) does this fall back to an O(n) scan, and even
-- then it picks the oldest-active slot: closest to fading out anyway, so recycling it is the
-- least noticeable choice.
local function acquire()
if freeTop > 0 then
local i = freeStack[freeTop]
freeStack[freeTop] = nil
freeTop = freeTop - 1
return i
end
local oldest, oldestT = 1, -1
for i = 1, POOL_MAX do
if slots[i].t > oldestT then oldestT = slots[i].t; oldest = i end
end
return oldest
end
-- Push a slot back onto the free stack once its number has fully faded.
local function release(i)
freeTop = freeTop + 1
freeStack[freeTop] = i
end
-- The one shared animation driver for every active number. Attached on the first spawn,
-- detached the moment nothing is left in flight -- an idle SCT costs nothing.
local function onUpdate()
local dt = arg1
local any = false
for i = 1, POOL_MAX do
local s = slots[i]
if s.active then
s.t = s.t + dt
if s.t >= s.dur then
s.active = false
s.fs:Hide()
release(i)
else
any = true
local frac = s.t / s.dur
-- Rises sct.rise px over its lifetime; dy0 is an extra starting offset that decays to
-- zero as frac -> 1, so a burst of simultaneous numbers starts spread out and
-- converges onto the same rising line instead of stacking exactly on top of
-- each other for their whole arc. UNVERIFIED: this staggering is not backed by
-- any researched pattern (none exists for this in the wow-domain knowledge base
-- at time of writing) -- it is a simple, untested heuristic. Watch it in-game.
local rise = s.rise
if s.crit then
-- Blizzard pins a crit at its start position; here it still drifts, but far
-- less, so it reads as "popped in place" without freezing on screen.
rise = s.rise * 0.25
-- Grow, then shrink back -- the pop that distinguishes a crit from a big
-- normal number.
local h
if s.t < CRIT_GROW then
h = CRIT_MIN + (CRIT_MAX - CRIT_MIN) * (s.t / CRIT_GROW)
elseif s.t < CRIT_SHRINK then
h = CRIT_MAX - (CRIT_MAX - CRIT_MIN)
* ((s.t - CRIT_GROW) / (CRIT_SHRINK - CRIT_GROW))
else
h = CRIT_MIN
end
s.fs:SetTextHeight(s.base * h)
end
local y = frac * rise + s.dy0 * (1 - frac)
s.fs:ClearAllPoints()
s.fs:SetPoint("CENTER", anchor, "CENTER", s.dx, y)
s.fs:SetAlpha((1 - frac) * s.alphaMul)
end
end
end
if not any then anchor:SetScript("OnUpdate", nil) end
end
-- The path that actually spawns a number. S.emit gates this on cfg.sct.enabled; S.demo calls it
-- directly so positioning still works while SCT is switched off in the options window.
local function spawn(amount, isOverheal, isCrit)
build()
-- Lazy re-check, not OnUpdate polling: cheap no-op once the resolved anchor mode has not
-- changed (see lastAnchorMode), but catches a bar that came into existence after this
-- anchor was first built (load-order dependent) on the very next number spawned.
reanchor()
local cfg = db()
local sct = cfg and cfg.sct
if not sct then return end
local i = acquire()
local s = slots[i]
local fs = ensureFs(i)
s.active = true
s.t = 0
s.dur = sct.duration or DUR_FALLBACK
s.rise = riseHeight()
spawnCounter = spawnCounter + 1
-- Stagger scales with the rise: a taller column gets proportionally wider lanes and bigger
-- starting offsets, so raising the slider genuinely buys room instead of just stretching
-- the same crowding over a longer arc.
local spread = s.rise / 40
if math.mod(spawnCounter, 2) == 0 then s.dx = 12 * spread else s.dx = -12 * spread end
s.dy0 = math.mod(spawnCounter, 3) * 6 * spread
if isOverheal then s.alphaMul = 0.5 else s.alphaMul = 1 end
-- Vampirism itself never crits. A return CAUSED by a crit is marked the way the client's
-- own combat text marks one -- and that is not merely "bigger": Blizzard grows the number,
-- shrinks it back, and keeps it in place instead of scrolling it away
-- (Blizzard_CombatText.lua:280-282, 336-339). Copying the behaviour, not just the size, is
-- what makes it read as a crit at a glance.
s.crit = isCrit and true or false
s.base = fontSize()
W.ApplyFont(fs, s.base, outlineFlag())
W.ApplyShadow(fs, shadowOn())
if s.crit then
-- SetTextHeight scales the rendered text without touching the font object, which is why
-- Blizzard animates with it rather than re-applying a font every frame.
fs:SetTextHeight(s.base * CRIT_MIN)
else
fs:SetTextHeight(s.base)
end
local col = sct.color or { r = 0.4, g = 0.9, b = 0.4 }
fs:SetTextColor(col.r, col.g, col.b)
-- Overheal is parenthesized rather than minus-prefixed: it is still healing, and a leading
-- "-" would read as damage at a glance. The halved alpha above is the other half of telling
-- it apart from a real crossing.
if isOverheal then
fs:SetText("("..amount..")")
else
fs:SetText("+"..amount)
end
fs:ClearAllPoints()
fs:SetPoint("CENTER", anchor, "CENTER", s.dx, 0)
fs:SetAlpha(1 * s.alphaMul)
fs:Show()
anchor:SetScript("OnUpdate", onUpdate)
end
-- amount is the integer to show -- an accumulator crossing for real heals (core/model.lua),
-- a per-hit floored amount for overheal (core/commands.lua) -- never a raw float. Zero
-- crossings are not emitted: the accumulator does not cross an integer on every hit, and a
-- stream of "+0" would just be noise (the same rule gui/fct.lua enforced before it).
function S.emit(amount, isOverheal, isCrit)
if not amount or amount <= 0 then return end
local cfg = db()
if not cfg or not cfg.sct or not cfg.sct.enabled then return end
spawn(amount, isOverheal, isCrit)
end
-- Three sample numbers so the user can see where they land while dragging the anchor. Bypasses
-- the enabled gate on purpose: positioning must work even with SCT switched off.
-- A burst spread over a couple of seconds, not three numbers at once: the whole point of the
-- demo is to judge spacing, rise and fade while tuning the sliders, and all three of those are
-- only visible when numbers arrive the way they do in a fight. Runs on its own ticker frame so
-- it cannot interfere with the animation driver, and detaches itself when finished.
local demoLeft, demoAcc, demoTicker = 0, 0, nil
function S.demo(count)
build()
demoLeft = count or 10
demoAcc = 0
if not demoTicker then
demoTicker = CreateFrame("Frame", "VampifySCTDemoFrame", UIParent)
end
demoTicker:SetScript("OnUpdate", function()
demoAcc = demoAcc + arg1
if demoAcc < 0.25 then return end
demoAcc = 0
if demoLeft <= 0 then
this:SetScript("OnUpdate", nil)
return
end
demoLeft = demoLeft - 1
-- Every style the real thing can produce, because a demo that only shows one of them
-- lets you tune half the appearance: plain returns, dimmed overheal in parentheses, and
-- crit-caused returns in the larger font. Crits carry a bigger number, since a crit is
-- a bigger hit and therefore a bigger return.
local phase = math.mod(demoLeft, 5)
local isOver = (phase == 0)
local isCrit = (phase == 2 or phase == 3)
local n = math.mod(demoLeft, 4) + 3
if isCrit then n = n * 2 end
spawn(n, isOver, isCrit)
end)
end
-- Returns true on success. Refuses to turn ON while anchored to the bar (mode == "bar") --
-- dragging is meaningless there, the anchor point is derived from VampifyDisplayFrame, not a
-- stored position -- and returns false plus a one-line reason instead, so callers (currently
-- core/commands.lua's "/vf sctmove") can tell the player why nothing happened. Turning OFF is
-- always allowed; there is nothing to refuse there.
function S.setMoveMode(on)
build()
if on then
local cfg = db()
local mode = cfg and cfg.sct and cfg.sct.mode
if mode == "bar" then
return false, "SCT is anchored to the bar in this mode -- switch to \"Free anchor\""
.." in Options to move it."
end
end
moveMode = on and true or false
applyMoveVisuals()
if moveMode then S.demo() end
return true
end
function S.isMoveMode()
return moveMode
end
-- Explicit, forced re-anchor: called from gui/options.lua on every engine-mode radio click and
-- on "Reset" -- both want the anchor to move at once, not wait for the next spawn's lazy check.
function S.applyPos()
build()
reanchor(true)
end
end