AnySCT 0.3.0

This commit is contained in:
2026-10-15 14:49:24 +02:00
parent 521342f1b6
commit 4600cc0719
8 changed files with 405 additions and 67 deletions
+295 -48
View File
@@ -216,6 +216,152 @@ function S.Format(res, opt, mode)
return text, colourKey, isWord, mul, suffix
end
-- Demo threat preview: the buffer climbs from ~300 to 1.3x the required lead over the burst (f 0..1).
function S.DemoThreat(f, need)
need = need or 1500
local start = (need > 300) and 300 or need * 0.2
if f < 0 then f = 0 elseif f > 1 then f = 1 end
local b = start + (need * 1.3 - start) * f
return (b >= need) and "GO" or "WAIT", b, need
end
-- Demo timeline: see S.BuildDemo. Event tables are reused on every run (no allocation while it plays).
-- Fixed-anchor box and the GO / WAIT label sitting inside it (rough glyph width: 0.62 x size per char).
S.ANCHOR_W, S.ANCHOR_H, S.ANCHOR_LABEL_SIZE, S.PLATE_LABEL_SIZE = 200, 30, 12, 14
function S.EstTextWidth(text, size) return string.len(text) * size * 0.62 end
-- Where anchor-mode damage lines start so they clear the box (and the list label above it).
function S.AnchorLineOffset(showLabel) return showLabel and 16 or 2 end
-- ---- merging identical attacks ---------------------------------------------------------------
-- Is the result a word (Miss / Dodge / ...)? Same rule as S.Format.
function S.IsWordResult(res, opt)
return (S.WORDS[res.kind] ~= nil) and (((res.amount or 0) <= 0) or (opt.colourize and true or false))
end
-- Result class that must match to merge: normal hits (glancing / crushing too) and crits stay apart.
function S.MergeClass(res)
local k = res.kind
if k == "crit" or res.crit then return "crit" end
if k == "hit" or k == "glancing" or k == "crushing" then return "hit" end
return k
end
-- Does line slot s (fields mKey, mWord, mCls, mSrc, mSpell, mHealer, mPet, t) absorb this result?
-- key = the place/victim key of the line (guid, name or "anchor"); words ignore the spell.
function S.MergeMatches(s, res, key, isWord, window)
if not s.active or s.mKey ~= key or s.mWord ~= isWord or s.t > window then return false end
if s.mCls ~= S.MergeClass(res) or s.mSrc ~= res.source or s.mPet ~= res.srcPet then return false end
if isWord then return true end
return s.mSpell == (res.spellId or res.spell) and s.mHealer == res.healer
end
function S.MergeLabel(text, count, showCount)
if showCount and count > 1 then return text .. " x" .. count end
return text
end
S.DEMO_BURST_LEN = 2.5
-- Demo source: the first listed targetSCT (name, class, own colour); without one a generic shaman tank.
S.DEMO_FALLBACK_NAME = "Shemp" -- leak-gate: allow
S.DEMO_VICTIM = "Training Dummy"
function S.DemoSource(st)
local e = st and st.order and st.byName and st.byName[st.order[1]]
if e then
return e.name, e.class, e.rgb, table.getn(st.order) > 1
end
return S.DEMO_FALLBACK_NAME, "SHAMAN", { 0, 0.44, 0.87 }, false
end
-- Burst skill sets per class. Entry: { spell (nil = white hit, "@kind" = miss word), weight, typical
-- amount, upper typical amount, typical crit amount, crit % , windfury-style extra hits }.
-- SHAMAN: shares and amounts from recorded tanking fights in dungeons and raids, last 30 days
-- (Stormstrike and Earth Shock are boosted so the preview shows them; crit rates are raised to
-- make a few crits visible). Every other class: plausible level-60 tank numbers, ESTIMATES.
S.DEMO_SETS = {
SHAMAN = {
{ "Fire Strike", 51, 2, 2, 3, 10 }, { "Lightning Strike", 15, 3, 3, 5, 12 },
{ "Tidal Wave", 10, 29, 31, 43, 10 }, { nil, 9, 97, 118, 216, 22, 2 },
{ "Engulfing Shadows", 6, 100, 100, 100, 0 }, { "Ancient Accord", 3, 100, 110, 150, 6 },
{ "Thundercall", 2, 39, 56, 56, 0 }, { "Earth Shock", 8, 666, 760, 992, 15 },
{ "Stormstrike", 10, 117, 140, 241, 30 }, { "Flame Shock", 2, 85, 100, 458, 5 },
{ "@miss", 1 }, { "@dodge", 1 }, { "@parry", 1 }, { "@block", 1 }, { "@resist", 1 },
},
WARRIOR = {
{ nil, 28, 150, 190, 320, 25 }, { "Heroic Strike", 20, 280, 340, 560, 25 },
{ "Revenge", 14, 350, 420, 700, 25 }, { "Shield Slam", 14, 480, 560, 900, 25 },
{ "Sunder Armor", 10, 60, 60, 60, 0 }, { "@miss", 1 }, { "@dodge", 1 }, { "@parry", 1 }, { "@block", 1 },
},
DRUID = {
{ nil, 24, 120, 150, 260, 25 }, { "Maul", 22, 260, 320, 520, 25 }, { "Swipe", 30, 90, 110, 190, 25 },
{ "Lacerate", 12, 70, 70, 70, 0 }, { "@miss", 1 }, { "@dodge", 1 }, { "@parry", 1 }, { "@block", 1 },
},
PALADIN = {
{ nil, 28, 140, 175, 300, 25 }, { "Holy Shield", 18, 100, 120, 200, 0 },
{ "Consecration", 16, 70, 70, 70, 0 }, { "Seal of Righteousness", 22, 90, 110, 190, 25 },
{ "Hammer of the Righteous", 8, 320, 380, 640, 25 }, { "@miss", 1 }, { "@dodge", 1 }, { "@parry", 1 }, { "@block", 1 },
},
}
function S.DemoSet(class)
return S.DEMO_SETS[class or ""] or S.DEMO_SETS.WARRIOR
end
-- Timeline: a 2.5 s burst starting at once, of ~9 slots/s picked from the class set
-- by weight (deterministic credit round robin, so rare skills still show up). Each entry: t, f (0..1
-- through the burst, nil before it), sn (spell name or nil), res (event table, reused every run).
function S.BuildDemo(class)
local set = S.DemoSet(class)
local list, n = {}, 0
local t0
local function add(t, kind, amount, sn)
n = n + 1
list[n] = { t = t, sn = sn, res = { kind = kind, amount = amount } }
if t0 then list[n].f = (t - t0) / S.DEMO_BURST_LEN end
end
t0 = 0
local total = 0
local credit, uses, critAcc = {}, {}, {}
for i = 1, table.getn(set) do
total = total + set[i][2]
credit[i], uses[i], critAcc[i] = 0, 0, 60
end
local count = 22
-- every word of the set is guaranteed to appear once, spread over the burst
local forced, words = {}, {}
for i = 1, table.getn(set) do
if set[i][1] and string.sub(set[i][1], 1, 1) == "@" then table.insert(words, i) end
end
local nw = table.getn(words)
for j = 1, nw do forced[3 + math.floor((j - 1) * (count - 4) / nw)] = words[j] end
for slot = 1, count do
local t = t0 + (slot - 1) * S.DEMO_BURST_LEN / count
local best = 1
for i = 1, table.getn(set) do
credit[i] = credit[i] + set[i][2]
if credit[i] > credit[best] then best = i end
end
credit[best] = credit[best] - total
if forced[slot] then best = forced[slot] end
local d = set[best]
uses[best] = uses[best] + 1
local word = d[1] and string.sub(d[1], 1, 1) == "@" and string.sub(d[1], 2)
if word then
add(t, word, 0)
else
local crit = false
local rate = d[6] or 0
if rate > 0 then
critAcc[best] = critAcc[best] + rate
if critAcc[best] >= 100 then critAcc[best] = critAcc[best] - 100; crit = true end
end
local amount = crit and d[5] or ((math.mod(uses[best], 2) == 1) and d[3] or d[4])
add(t, crit and "crit" or "hit", amount, d[1])
if not d[1] and d[7] and uses[best] == 2 then -- an extra attack in the same frame
for _ = 1, d[7] do add(t, "hit", d[3], nil) end
end
end
end
return list, n, list[n].t + 1.5
end
if CreateFrame then
local W = AnySCT_W
local C = AnySCT_Core
@@ -258,6 +404,19 @@ if CreateFrame then
end
end
local dragging = false
-- UIParent's rect in the same coordinate system as the anchor's (see C.AnchorPosFromCenter).
function S.ScreenRect()
local l, b, r, t = UIParent:GetLeft(), UIParent:GetBottom(), UIParent:GetRight(), UIParent:GetTop()
if not (l and b and r and t) then
l, b, r, t = 0, 0, UIParent:GetWidth(), UIParent:GetHeight()
end
return l, b, r, t
end
function S.IsDragging() return dragging end
local function placeAnchor()
local pos = opt("pos") or AnySCT_DEFAULTS.pos
anchor:ClearAllPoints()
@@ -276,8 +435,8 @@ if CreateFrame then
float:SetClampedToScreen(true)
local a = CreateFrame("Frame", "AnySCTAnchor", UIParent)
a:SetWidth(160)
a:SetHeight(30)
a:SetWidth(S.ANCHOR_W)
a:SetHeight(S.ANCHOR_H)
a:SetFrameStrata("HIGH")
a:SetFrameLevel(10)
a:SetMovable(true)
@@ -286,12 +445,18 @@ if CreateFrame then
a:RegisterForDrag("LeftButton")
a:SetBackdrop(W.SOLID_BACKDROP)
a:SetScript("OnDragStart", function()
if moveMode then this:StartMoving() end
if moveMode then
dragging = true
this:StartMoving()
end
end)
a:SetScript("OnDragStop", function()
this:StopMovingOrSizing()
dragging = false
local cx, cy = this:GetCenter()
C.SetOpt("pos", { point = "CENTER", x = cx - UIParent:GetWidth() / 2, y = cy - UIParent:GetHeight() / 2 })
if not cx then return end
local l, b, r, t = S.ScreenRect()
C.SetOpt("pos", C.AnchorPosFromCenter(cx, cy, (l + r) / 2, (b + t) / 2))
end)
anchor = a
@@ -403,7 +568,11 @@ if CreateFrame then
if y > s.lastH - margin then y = s.lastH - margin end
s.fs:SetPoint("BOTTOM", UIParent, "BOTTOMLEFT", x, y)
else
s.fs:SetPoint("BOTTOM", anchor, "CENTER", s.dx, off)
if s.dir < 0 then
s.fs:SetPoint("TOP", anchor, "BOTTOM", s.dx, off - 2)
else
s.fs:SetPoint("BOTTOM", anchor, "TOP", s.dx, off + S.AnchorLineOffset(opt("showLabel")))
end
end
s.fs:SetAlpha(a)
s.fs:SetShadowColor(0, 0, 0, a)
@@ -420,8 +589,36 @@ if CreateFrame then
local o = C.Opts()
local key = res.mobGuid or res.mobName or "anchor"
if mode == 3 then key = "anchor" end
local isWord0 = S.IsWordResult(res, o)
if o.mergeHits ~= false then
local window = o.mergeWindow or 0.4
for j = 1, POOL_MAX do
local m = slots[j]
if m.active and S.MergeMatches(m, res, key, isWord0, window) then
-- fold into the live line: same position and fade, only the text changes
if not isWord0 then
m.mSum = m.mSum + (res.amount or 0)
m.mCount = m.mCount + 1
local a0 = res.amount
res.amount = m.mSum
local text = S.Format(res, o, mode)
res.amount = a0
text = S.MergeLabel(text, m.mCount, o.mergeCount)
if text ~= m.mText then
m.mText = text
m.fs:SetText(text)
end
end
C.Ledger(res, "merged")
return
end
end
end
local i = acquire(key)
local s = slots[i]
s.mKey, s.mWord, s.mCls, s.mSrc, s.mPet = key, isWord0, S.MergeClass(res), res.source, res.srcPet
s.mSpell, s.mHealer = res.spellId or res.spell, res.healer
s.mSum, s.mCount = res.amount or 0, 1
C.Ledger(res, s.active and "shown+recycled" or "shown")
s.key = key
ensureFs(i)
@@ -475,6 +672,7 @@ if CreateFrame then
local cr, cg, cb = S.PickColour(res, o, ckey)
fs:SetTextColor(cr, cg, cb)
fs:SetText(text)
s.mText = text
fs:SetAlpha(0)
fs:SetShadowColor(0, 0, 0, 0)
fs:Show()
@@ -503,7 +701,7 @@ if CreateFrame then
if o.placement ~= "anchor" and (res.mobGuid or res.mobName) then
plate = P.Find(res.mobGuid, res.mobName, res.mobUnit)
end
local mode, px, py, pw, ph = P.Select(plate, o)
local mode, px, py, pw, ph, also = P.Places(plate, o)
if C.debug then
C.Dbg("place " .. tostring(res.mobName) .. ": " .. (plate and "plate found" or "no plate")
.. ", mode " .. tostring(mode) .. (mode == 0 and " (hidden by option)" or ""))
@@ -513,11 +711,12 @@ if CreateFrame then
return
end
spawn(res, mode, plate, px, py, pw, ph)
if also then spawn(res, 3) end -- "Nameplate + anchor": a second, separately capped stack
end
function S.OptionChanged(key)
if not anchor then return end
if key == "all" or key == "pos" then placeAnchor() end
if (key == "all" or key == "pos") and not dragging then placeAnchor() end
S.UpdateLabel()
end
@@ -526,6 +725,7 @@ if CreateFrame then
-- (C.ThreatDecide); this only draws. The follow OnUpdate is attached only while the label is shown.
local TL = { fs = nil, shown = false, plate = nil, status = nil, text = nil, lastText = nil,
lastMode = 0, lastName = nil, mobName = nil, guid = nil }
local TL2 = { fs = nil, lastText = nil, lastMode = 0, lastName = nil } -- anchor copy ("both")
local followFrame
local findAcc = 0
@@ -536,6 +736,7 @@ if CreateFrame then
if TL.shown then
TL.shown = false
if TL.fs then TL.fs:Hide() end
if TL2.fs then TL2.fs:Hide() end
end
if followFrame then followFrame:SetScript("OnUpdate", nil) end
return
@@ -552,6 +753,31 @@ if CreateFrame then
followFrame:SetScript("OnUpdate", followUpdate)
end
-- Draw the label fs (state record L) for a placement mode; text and font change only on change.
local function drawLabel(L, fs, mode, px, py, pw, ph, margin, lift)
local size = (mode == 3) and S.ANCHOR_LABEL_SIZE or S.PLATE_LABEL_SIZE
if size ~= L.size then
L.size = size
fs:SetFont(S.FontPath(opt("font")), size, "OUTLINE")
L.lastText = nil
end
local name = (mode == 3) and TL.mobName or nil
if TL.text ~= L.lastText or mode ~= L.lastMode or name ~= L.lastName then
L.lastText, L.lastMode, L.lastName = TL.text, mode, name
fs:SetText(name and (name .. ": " .. TL.text) or TL.text)
if TL.status == "GO" then fs:SetTextColor(0.2, 1, 0.2) else fs:SetTextColor(1, 0.6, 0.1) end
end
fs:ClearAllPoints()
if mode == 3 then
fs:SetPoint("CENTER", anchor, "CENTER", 0, 0)
else
local x, y = px, py + lift
if mode == 2 then x, y = AnySCT_ClampToScreen(x, y, pw, ph, margin) end
fs:SetPoint("BOTTOM", UIParent, "BOTTOMLEFT", x, y)
end
fs:Show()
end
followUpdate = function()
if not TL.shown then return end
findAcc = findAcc + arg1
@@ -561,56 +787,47 @@ if CreateFrame then
TL.plate = nil
TL.mobName = UnitName("target")
TL.guid = C.ResolveGuid("target")
if o.placement ~= "anchor" then TL.plate = P.Find(TL.guid, TL.mobName, "target") end
if o.placement ~= "anchor" and not TL.forceAnchor then TL.plate = P.Find(TL.guid, TL.mobName, "target") end
end
local margin = o.edgeMargin or 40
local lift = S.TextSize(STYLE.sizeScale, o.sizeFactor or 1, GetScreenWidth(), GetScreenHeight()) + 6
local mode, px, py, pw, ph = P.Select(TL.plate, o)
if mode == 0 then TL.fs:Hide() return end
local name = (mode == 3) and TL.mobName or nil
if TL.text ~= TL.lastText or mode ~= TL.lastMode or name ~= TL.lastName then
TL.lastText, TL.lastMode, TL.lastName = TL.text, mode, name
TL.fs:SetText(name and (name .. ": " .. TL.text) or TL.text)
if TL.status == "GO" then TL.fs:SetTextColor(0.2, 1, 0.2) else TL.fs:SetTextColor(1, 0.6, 0.1) end
local mode, px, py, pw, ph, also = P.Places(TL.plate, o)
if mode == 0 then
TL.fs:Hide()
if TL2.fs then TL2.fs:Hide() end
return
end
TL.fs:ClearAllPoints()
if mode == 3 then
TL.fs:SetPoint("BOTTOM", anchor, "TOP", 0, 18)
else
local x, y = px, py + lift
if mode == 2 then x, y = AnySCT_ClampToScreen(x, y, pw, ph, margin) end
TL.fs:SetPoint("BOTTOM", UIParent, "BOTTOMLEFT", x, y)
drawLabel(TL, TL.fs, mode, px, py, pw, ph, margin, lift)
if also then
if not TL2.fs then
TL2.fs = float:CreateFontString("AnySCTThreatLabel2", "OVERLAY")
TL2.fs:SetFont(S.FontPath(opt("font")), S.ANCHOR_LABEL_SIZE, "OUTLINE")
end
drawLabel(TL2, TL2.fs, 3, nil, nil, nil, nil, margin, lift)
elseif TL2.fs then
TL2.fs:Hide()
end
TL.fs:Show()
end
-- ---- demo / move / memory ------------------------------------------------------------------
-- Fixed sequence for side-by-side comparison with the player's own real numbers.
local DEMO = {
{ kind = "hit", amount = 72 }, { kind = "hit", amount = 66 }, { kind = "crit", amount = 141 },
{ kind = "miss", amount = 0 }, { kind = "dodge", amount = 0 }, { kind = "parry", amount = 0 },
{ kind = "block", amount = 0 },
}
local demoIdx, demoLeft, demoAcc, demoTicker, demoAtAnchor = 0, 0, 0, nil, false
-- Fixed sequence for side-by-side comparison with the player's own real numbers, then the burst.
local demoCache = {}
local DEMO, DEMO_N, DEMO_END
local demoIdx, demoT, demoTicker, demoAtAnchor = 1, 0, nil, false
local dSrc, dClass, dRgb, dMulti, dVictim = nil, nil, nil, false, nil
-- /asct demo plays at the nameplate of the current target (the same placement rules as real
-- events); without a target, or with atAnchor, at the fixed anchor.
function S.Demo(atAnchor)
build()
demoLeft = table.getn(DEMO)
demoIdx = 0
demoAcc = 0
demoAtAnchor = atAnchor and true or false
if not demoTicker then demoTicker = CreateFrame("Frame", "AnySCTDemoFrame", UIParent) end
demoTicker:SetScript("OnUpdate", function()
demoAcc = demoAcc + arg1
if demoAcc < 0.45 then return end
demoAcc = 0
if demoLeft <= 0 then this:SetScript("OnUpdate", nil) return end
demoLeft = demoLeft - 1
local function demoOnUpdate()
demoT = demoT + arg1
local showName = opt("showSpellName")
while demoIdx <= DEMO_N and DEMO[demoIdx].t <= demoT do
local e = DEMO[demoIdx]
local res = e.res
demoIdx = demoIdx + 1
local res = DEMO[demoIdx]
res.mobGuid, res.mobName, res.mobUnit = nil, nil, nil
-- same fields a real event carries (C.Emit), so every display setting applies
res.source, res.srcRgb, res.srcMulti, res.class = dSrc, dRgb, dMulti, dClass
res.spell = (e.sn and showName) and e.sn or nil
res.spellId = e.sn and 1 or nil
res.mobGuid, res.mobName, res.mobUnit = nil, dVictim, nil
if not demoAtAnchor and UnitExists("target") then
res.mobGuid = C.ResolveGuid("target")
res.mobName = UnitName("target")
@@ -619,7 +836,37 @@ if CreateFrame then
else
spawn(res, 3)
end
end)
if e.f then
-- the GO / WAIT preview rides the burst (real label code path, even with threatGo off)
local status, buf, need = S.DemoThreat(e.f, opt("threatGoLead"))
S.SetThreat(status, buf, need, C.ThreatSrcName and C.ThreatSrcName(dSrc))
end
end
if demoT >= DEMO_END then
TL.forceAnchor = false
S.SetThreat(nil)
this:SetScript("OnUpdate", nil)
end
end
-- /asct demo plays at the nameplate of the current target (the same placement rules as real
-- events); without a target, or with atAnchor, at the fixed anchor. A second call restarts it.
function S.Demo(atAnchor)
build()
demoIdx, demoT = 1, 0
demoAtAnchor = atAnchor and true or false
dSrc, dClass, dRgb, dMulti = S.DemoSource(C.state)
dVictim = UnitExists("target") and UnitName("target") or S.DEMO_VICTIM
local key = S.DEMO_SETS[dClass or ""] and dClass or "WARRIOR"
if not demoCache[key] then
local l, n, e = S.BuildDemo(key)
demoCache[key] = { l, n, e }
end
DEMO, DEMO_N, DEMO_END = demoCache[key][1], demoCache[key][2], demoCache[key][3]
TL.forceAnchor = demoAtAnchor
S.SetThreat(nil)
if not demoTicker then demoTicker = CreateFrame("Frame", "AnySCTDemoFrame", UIParent) end
demoTicker:SetScript("OnUpdate", demoOnUpdate)
end
function S.FontStringCount()