diff --git a/AnySCT.toc b/AnySCT.toc
index 2829e49..3fa2518 100644
--- a/AnySCT.toc
+++ b/AnySCT.toc
@@ -2,7 +2,7 @@
## Title: AnySCT
## Notes: Blizzard-pet-style floating combat text for any units you pick: tanks, their pets and totems, mobs, enemy players.
## Author: ShempError
-## Version: 0.2.0
+## Version: 0.3.0
## X-Website: https://octowow.st/git/ShempError/AnySCT
## OptionalDeps: pfUI, CT_RaidAssist, oRA, TWThreat
## SavedVariables: AnySCTDB
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9f4291..7f8078f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,21 @@
All notable changes to AnySCT are documented here.
+## 0.3.0 — 2026-10-15
+
+### Added
+- **Merge identical hits** (Text tab, on by default): identical attacks (same source, victim and ability; normal hit or crit kept apart) within the **merge window** (0.1-1.5 s, default 0.4) become ONE line whose number is the running sum, updated in place. Misses and other words are not summed; identical words collapse. Optional **Show hit count** appends e.g. ` x5`.
+- Placement **Nameplate + anchor** (`/asct place both`): every line and the GO / WAIT label appear over the victim's nameplate and at the fixed anchor, with separate line limits per place. Without a plate it shows once at the anchor.
+- The **Demo** button and `/asct demo` now play a 2.5 s burst of the first listed targetSCT's class skills (a shaman demo tank without a list; amounts for the shaman set from recorded tanking fights, estimates for other classes) through the normal display path, so spell names, name prefixes, colours, limits and placement apply. Every result type (crit, Miss, Dodge, Parry, Block) appears, and a simulated GO / WAIT label previews the threat display (also with GO / WAIT switched off).
+
+### Changed
+- The GO / WAIT label at the fixed anchor sits inside the anchor box (now 200 x 30); damage lines in anchor mode start above its top edge.
+- Anchor X / Y sliders span the real screen instead of fixed +-800 / +-500 and never clamp a stored position.
+- The options window is 540 px high to fit the new Text-tab controls.
+
+### Fixed
+- The fixed anchor jumped to another place when dropped after a drag: the drop position was computed from `UIParent:GetWidth()` / `GetHeight()`, which are not in the coordinate system of frame rects on a non-default UI scale. It now uses the screen rect's centre and rounds to whole units; slider refreshes no longer write back while dragging.
+
## 0.2.0 — 2026-10-15
### Changed
diff --git a/README.md b/README.md
index e4c3ca8..62d4de2 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,19 @@ are on the list, your own SCT addon stays the only one that shows your damage).
*A listed tank hitting the training dummies in Orgrimmar, seen from another character: an orange melee
"Miss", a gold spell "Resist", and the experimental WAIT label under the dummy being attacked.*
+## What's new — 0.3.0 · 2026-10-15
+
+- **Identical hits are merged.** Fire Strike style tick storms become one line with the running sum
+ (same source, victim, ability and hit/crit within 0.4 s; the number updates in place, words are not
+ summed). Switch off or tune under Text: *Merge identical hits*, *Merge window*, *Show hit count*.
+- **Placement "Nameplate + anchor"** shows every line, and the GO / WAIT label, over the plate and at the
+ fixed anchor at once, with separate line limits.
+- **The Demo previews your own setup**: a 2.5 s burst of your first targetSCT's class skills (Stormstrike,
+ Earth Shock and friends for a shaman) with all your display settings, plus the GO / WAIT label inside
+ the anchor box, to judge the line tower without a second character.
+- **Fixed: the anchor no longer jumps when you drop it.** The dropped position is now exact, and the
+ Anchor X / Y sliders cover the whole screen.
+
## What's new — 0.2.0 · 2026-10-15
- **targetSCT.** What used to be called "tank" is now *targetSCT* everywhere (menu, options, commands
@@ -32,6 +45,8 @@ are on the list, your own SCT addon stays the only one that shows your damage).
**Version history** — details in [CHANGELOG.md](CHANGELOG.md):
+- **0.3.0** (2026-10-15) — merged identical hits, "Nameplate + anchor" placement, a demo that plays your own
+ targetSCT's class burst and previews GO / WAIT, anchor drop fix.
- **0.2.0** (2026-10-15) — targetSCT rename, name option, per-targetSCT colours, regrouped options with
label tooltips, higher line limits, experimental threat-based GO / WAIT.
- **0.1.0** (2026-10-14) — first release: list, victim-routed text in the client's own style, nameplate
@@ -46,6 +61,8 @@ Everything floats over the **victim** of the event, never over the source:
- **Words for what did not land**: Miss, Dodge, Parry, Block, Resist, Immune, ... in the same style.
- **Every hit is shown**: up to 16 lines per mob and 60 on screen by default, so a fast burst is not
recycled early (both are sliders on the Text tab).
+- **Identical hits are merged**: the same attack from the same source on the same mob within 0.4 s shows
+ as one line with the summed value (tiny procs like Fire Strike no longer flood the tower; Text tab).
- Pets and totems of a listed unit are shown under their owner; Fire Nova, traps and other area damage
land on the mob they hit.
@@ -54,6 +71,12 @@ Everything floats over the **victim** of the event, never over the source:
*A crit "366" popping over the Heroic Training Dummy, a "Block" in grey, and a GO label partly covered by
the crit.*
+
+
+*The Demo button at the fixed anchor (no target): a shaman burst with spell names on - a 783 Stormstrike
+crit, a white hit, merged Fire Strike ticks (7), Miss / Dodge / Parry / Block, and the GO / WAIT preview
+"WAIT 1200 / 1500" inside the anchor box.*
+
## GO / WAIT threat label (experimental)
Off by default. Shows under the nameplate of **your own target** how much threat lead the targetSCT has
@@ -151,9 +174,9 @@ character.
| `list` | print the list |
| `clear` | empty the list |
| `colours` | give every listed unit a new automatic colour |
-| `place clamp/anchoroff/anchor` | where the text appears |
+| `place clamp/anchoroff/anchor/both` | where the text appears |
| `unlock` / `lock` | move the fixed anchor (drag the box; a demo stream plays) |
-| `demo` | sample text at your target's nameplate, to compare with your own numbers |
+| `demo` | sample text and a 2.5 s damage burst at your target's nameplate, to compare with your own numbers and judge the line tower |
| `threat` | one line with the state of the GO / WAIT label |
| `debug` | toggle the event / drop trace in the chat frame |
| `mem` | memory self-test (idle and a demo burst) |
@@ -206,6 +229,9 @@ six sliders including the line limits) below.*
| Show spell name | ability name under the number for special attacks | off |
| Show healer name | healer's name under heal numbers | on |
| Mark glancing / crushing | add the word after the number | off |
+| Merge identical hits | identical attacks (same source, victim, ability; normal hit or crit) within the merge window become one line with the running sum, updated in place; words are not summed | on |
+| Show hit count | append the number of merged hits, e.g. `12 x5` | off |
+| Merge window | seconds a line keeps absorbing identical hits, counted from its first hit (0.1-1.5) | 0.4 |
| Font | Client (the damage font, honours pfUI) / FrizQT / Skurri / Arial Narrow | Client |
| Direction | text rises (Up) or sinks (Down) | Up |
| Number size | factor on the engine size, 0.5-3 | 1.00 |
@@ -243,7 +269,7 @@ list label, and the Debug section.*
| control | what it does | default |
|---|---|---|
-| Placement | Nameplate, clamp (over the victim's plate, held at the screen edge) / Nameplate, else anchor / Always anchor | Nameplate, clamp |
+| Placement | Nameplate, clamp (over the victim's plate, held at the screen edge) / Nameplate, else anchor / Always anchor / Nameplate + anchor (both at once, separate line limits) | Nameplate, clamp |
| No nameplate | when the victim has no visible plate: Anchor / Hide | Anchor |
| Plate X offset | horizontal shift from the plate centre, -150..150 | 40 |
| Plate Y offset | vertical shift from the plate's top edge, -100..100 | 0 |
@@ -252,7 +278,7 @@ list label, and the Debug section.*
| Anchor X / Anchor Y | position of the fixed anchor from the screen centre, -800..800 / -500..500 | 0 / 120 |
| Unlock anchor / Lock anchor | drag the anchor box; a demo stream plays while unlocked | |
| Reset anchor | back to screen centre, 120 px up | |
-| Demo | hit, hit, crit, miss, dodge, parry, block at your target's plate (anchor without a target) | |
+| Demo | starts at once with a 2.5 s burst of your first targetSCT's class skills (a shaman demo tank without one) on one victim, using all your display settings (Stormstrike, Earth Shock, tiny Fire Strike ticks, melee, crits, extra attacks, and every result type: crit, miss, dodge, parry, block, resist) that fills the per-mob line tower, plus a simulated GO / WAIT threat label (works with Threat GO off); at your target's plate (anchor without a target); click again to restart | |
| Show targetSCT list label | a `targetSCTs: ...` line above the anchor | off |
| Record threat trace | writes the received threat data to `\imports\anysct_threat.txt` (needs SuperWoW), for bug reports | off |
@@ -262,7 +288,7 @@ Enemy nameplates must be on (V key) for text over the mob.
The text always floats over the **victim** of the event, not over the source. Placement modes: over the
nameplate clamped to the screen edge (default), over the nameplate with the fixed anchor as fallback,
-or always the fixed anchor. At the fixed anchor the victim's name is always added because the position no
+always the fixed anchor, or both the plate and the fixed anchor at once (the GO / WAIT label too; with no plate it shows once at the anchor). At the fixed anchor the victim's name is always added because the position no
longer identifies the mob. If no plate addon or plate is found the text simply uses the fixed anchor.
## Colours and names
diff --git a/core.lua b/core.lua
index ab5cd24..b94a27d 100644
--- a/core.lua
+++ b/core.lua
@@ -28,6 +28,9 @@ AnySCT_DEFAULTS = {
maxLinesPerTarget = 16,
onlyWhileSourceFights = false, -- true: hide text while the listed source itself is not in combat (renamed from showOnlyInCombat, which gated on the PLAYER)
colourize = false, -- false: everything orange like the pet text; true: crit yellow, miss red, block blue, ...
+ mergeHits = true, -- identical attacks (same source, victim, attack, hit/crit) within a short window become ONE line with the running sum
+ mergeWindow = 0.4, -- seconds a line keeps absorbing identical hits
+ mergeCount = false, -- append " x5" to a merged line
markGlancing = false, -- true: add "glancing" / "crushing" after the number
guessTotemOwner = false, -- unknown totem owner = the only listed shaman (can misattribute enemy / own totems)
classColours = false, -- numbers / words take the source's class colour (pets: the owner's)
@@ -49,7 +52,7 @@ AnySCT_DEFAULTS = {
minimap = true,
mmAngle = 200,
importMain = true,
- placement = "clamp", -- "clamp" | "anchorOff" | "anchor"
+ placement = "clamp", -- "clamp" | "anchorOff" | "anchor" | "both"
noPlate = "anchor", -- "anchor" | "hide"
edgeMargin = 40,
xOffset = 40, -- plate modes: shift right so targetSCT numbers sit beside the player's own
@@ -1242,6 +1245,23 @@ function C.LeadFromPos(p)
return p * 100
end
+-- Fixed-anchor geometry. Frame rects (GetCenter/GetLeft/...) and SetPoint offsets live in one
+-- coordinate system; UIParent:GetWidth()/GetHeight() do NOT (1834x768 against a 2580x1080 rect on a
+-- UI scale of 0.711), so the screen centre must come from UIParent's own rect, never from its size.
+function C.AnchorPosFromCenter(cx, cy, ux, uy)
+ return { point = "CENTER", x = math.floor(cx - ux + 0.5), y = math.floor(cy - uy + 0.5) }
+end
+
+-- Half extents of the screen as slider limits: rounded up to a multiple of 5, at least 100.
+function C.ScreenHalfRange(left, right, bottom, top)
+ local function half(a, b)
+ local h = math.ceil(((b or 0) - (a or 0)) / 2 / 5) * 5
+ if h < 100 then h = 100 end
+ return h
+ end
+ return half(left, right), half(bottom, top)
+end
+
function C.PosFromLead(v)
v = v or 0
if v < 0 then v = 0 end
@@ -1346,6 +1366,8 @@ local function threatSrcName(name)
return nil
end
+C.ThreatSrcName = threatSrcName
+
local function threatHide()
if TH.shown then
TH.shown = false
@@ -1427,7 +1449,7 @@ local HELP = {
" clear remove every targetSCT",
" colours re-assign the targetSCT colours automatically",
" threat state of the experimental threat GO / WAIT label",
- " place clamp|anchoroff|anchor where the text appears",
+ " place clamp|anchoroff|anchor|both where the text appears",
" unlock | lock move the fixed anchor",
" demo show sample text",
" mem measure memory use (10 s idle, then a demo burst)",
@@ -1449,7 +1471,7 @@ function C.Canon(n)
return nil
end
-local PLACE = { clamp = "clamp", anchoroff = "anchorOff", anchor = "anchor" }
+local PLACE = { clamp = "clamp", anchoroff = "anchorOff", anchor = "anchor", both = "both" }
function C.Slash(msg)
msg = msg or ""
@@ -1482,7 +1504,7 @@ function C.Slash(msg)
C.Print("targetSCT colours re-assigned")
elseif cmd == "place" then
local p = PLACE[string.lower(rest)]
- if p then C.SetOpt("placement", p) C.Print("placement: " .. p) else C.Print("usage: /anysct place clamp|anchoroff|anchor") end
+ if p then C.SetOpt("placement", p) C.Print("placement: " .. p) else C.Print("usage: /anysct place clamp|anchoroff|anchor|both") end
elseif cmd == "unlock" or cmd == "lock" then
if AnySCT_SCT and AnySCT_SCT.SetMoveMode then AnySCT_SCT.SetMoveMode(cmd == "unlock") end
elseif cmd == "demo" then
diff --git a/options.lua b/options.lua
index 89a7d34..8eef1f9 100644
--- a/options.lua
+++ b/options.lua
@@ -36,6 +36,10 @@ if CreateFrame then
"Print the healer's name under the heal number." },
{ "colourize", "Colour coding",
"Off: everything is orange like Blizzard's pet text. On: crit yellow, miss / dodge / parry red, block blue, absorb / resist / immune purple. A per-targetSCT colour (Colours tab) overrides this." },
+ { "mergeHits", "Merge identical hits",
+ "Identical attacks (same source, victim and ability, normal hit or crit) that arrive within the merge window become ONE line whose number is the running sum, updated in place. Tames Fire Strike style tick storms. Misses, dodges and the like are not summed; identical words collapse to one line. On by default." },
+ { "mergeCount", "Show hit count",
+ "Append the number of merged hits to a merged line, e.g. 12 x5. Off by default." },
{ "markGlancing", "Mark glancing / crushing",
"Add the word glancing or crushing after the number (Blizzard style). Off: shown as a plain number." },
{ "showSpells", "Show special attacks",
@@ -68,8 +72,8 @@ if CreateFrame then
local CYCLES = {
{ "placement", "Placement",
{ { "clamp", "Nameplate, clamp" }, { "anchorOff", "Nameplate, else anchor" },
- { "anchor", "Always anchor" } },
- "Where the text appears. Over the nameplate needs enemy nameplates on (V). Clamp keeps text along the screen edge when the plate is off screen." },
+ { "anchor", "Always anchor" }, { "both", "Nameplate + anchor" } },
+ "Where the text appears. Over the nameplate needs enemy nameplates on (V). Clamp keeps text along the screen edge when the plate is off screen. 'Nameplate + anchor' shows every line and the GO / WAIT label over the plate AND at the fixed anchor (separate line limits); without a plate it shows once at the anchor." },
{ "noPlate", "No nameplate",
{ { "anchor", "Anchor" }, { "hide", "Hide" } },
"What to do when the targetSCT's target has no visible nameplate." },
@@ -94,6 +98,8 @@ if CreateFrame then
{ "wordFactor", "Word size", 0.5, 3, 0.05, "%.2f", "Size factor of Miss / Dodge / Parry / ..." },
{ "riseHeight", "Rise (px)", 0, 300, 5, nil,
"How far a normal text rises (crits stay in place). The engine rises 2 yards; the screen distance depends on the camera." },
+ { "mergeWindow", "Merge window (s)", 0.1, 1.5, 0.05, "%.2f",
+ "How long a line keeps absorbing identical hits, counted from its first hit. Short: more, smaller lines. Long: fewer lines that update longer. Only used with Merge identical hits." },
{ "jitter", "Jitter (px)", 0, 40, 1, nil, "Random horizontal offset per line. 0 like the engine; lines stack instead." },
{ "maxLines", "Max lines", 3, 100, 1, nil, "Maximum number of lines on screen over all mobs; when it is reached the oldest line is recycled." },
{ "maxLinesPerTarget", "Max lines per mob", 1, 30, 1, nil, "Maximum number of lines over one mob. Lines live 1.5 s, so 16 shows every hit of a burst of about 10 per second; lower values recycle the oldest line early." },
@@ -114,8 +120,8 @@ if CreateFrame then
local function getVal(key)
if key == "threatGoLead" then return C.PosFromLead(C.Opt(key) or 1500) end
- if key == "posX" then return (C.Opt("pos") or {}).x or 0 end
- if key == "posY" then return (C.Opt("pos") or {}).y or 0 end
+ if key == "posX" then return math.floor(((C.Opt("pos") or {}).x or 0) + 0.5) end
+ if key == "posY" then return math.floor(((C.Opt("pos") or {}).y or 0) + 0.5) end
return C.Opt(key) or 0
end
@@ -202,6 +208,15 @@ if CreateFrame then
local sl = sliders[i]
local v = getVal(sl.asctKey)
sl.asctBusy = true
+ if (sl.asctKey == "posX" or sl.asctKey == "posY") and AnySCT_SCT and AnySCT_SCT.ScreenRect then
+ local hw, hh = C.ScreenHalfRange(AnySCT_SCT.ScreenRect())
+ local lim = sl.asctKey == "posX" and hw or hh
+ -- never clamp a stored position: widen the range to hold it
+ if math.abs(v) > lim then lim = math.ceil(math.abs(v) / 5) * 5 end
+ sl:SetMinMaxValues(-lim, lim)
+ getglobal(sl:GetName() .. "Low"):SetText(tostring(-lim))
+ getglobal(sl:GetName() .. "High"):SetText(tostring(lim))
+ end
sl:SetValue(v)
sl.asctBusy = false
local txt = sl.asctFmt and string.format(sl.asctFmt, v) or tostring(v)
@@ -237,7 +252,7 @@ if CreateFrame then
O.refresh = refresh
-- Fixed panel geometry (UI scale 1): 520 x 460, four tabs hanging below the bottom edge, 16 px margins.
- local PANEL_W, PANEL_H = 520, 460
+ local PANEL_W, PANEL_H = 520, 540
local COLA, COLB = 20, 270 -- two content columns, 230-240 wide each
local TOP = -48 -- first content row, below the title
@@ -424,6 +439,7 @@ if CreateFrame then
if d[8] then getglobal(name .. "High"):SetText("7500") end
sl:SetScript("OnValueChanged", function()
if this.asctBusy then return end
+ if AnySCT_SCT and AnySCT_SCT.IsDragging and AnySCT_SCT.IsDragging() then return end
local v = math.floor(this:GetValue() / this.asctStep + 0.5) * this.asctStep
local txt = this.asctFmt and string.format(this.asctFmt, v) or tostring(v)
if this.asctFmt then v = tonumber(txt) end
@@ -656,7 +672,7 @@ if CreateFrame then
y2 = y2 - 30
makeCycle(p2, "mobNameMode", COLA, y2)
y2 = y2 - 30
- local CONTENT = { "showSpellName", "healShowHealer", "markGlancing" }
+ local CONTENT = { "showSpellName", "healShowHealer", "markGlancing", "mergeHits", "mergeCount" }
for i = 1, table.getn(CONTENT) do
makeCheck(p2, CONTENT[i], COLA, y2)
y2 = y2 - 21
@@ -668,7 +684,7 @@ if CreateFrame then
y2 = y2 - 30
makeCycle(p2, "direction", COLA, y2)
y2 = y2 - 48
- local LOOK = { "sizeFactor", "wordFactor", "riseHeight", "jitter", "maxLines", "maxLinesPerTarget" }
+ local LOOK = { "sizeFactor", "wordFactor", "riseHeight", "jitter", "maxLines", "maxLinesPerTarget", "mergeWindow" }
for i = 1, table.getn(LOOK) do
local col = COLA + 10
if math.mod(i, 2) == 0 then col = COLB + 10 end
@@ -738,7 +754,7 @@ if CreateFrame then
end)
ax = ax + bPos:GetWidth() + 6
makeButton(p4, "AnySCTOptionsDemo", "Demo", nil, ax, y4, "Demo",
- "Play hit, hit, crit, miss, dodge, parry, block at your target's nameplate (fixed anchor without a target) to compare with your own numbers.",
+ "Plays a 2.5 s burst of your first targetSCT's class skills (a shaman demo tank without one) on your target's nameplate (fixed anchor without a target), using all your display settings. Fills the per-mob tower and previews the GO / WAIT threat label; click again to restart.",
function() if AnySCT_SCT then AnySCT_SCT.Demo() end end)
y4 = y4 - 28
makeCheck(p4, "showLabel", COLA, y4)
diff --git a/plates.lua b/plates.lua
index a001083..4faa9a8 100644
--- a/plates.lua
+++ b/plates.lua
@@ -221,7 +221,7 @@ function P.PlatePosition(plate)
return cx, cy, UIParent:GetWidth(), UIParent:GetHeight()
end
--- Decide the display mode. opt needs: placement ("clamp" | "anchorOff" | "anchor"), noPlate
+-- Decide the display mode. opt needs: placement ("clamp" | "anchorOff" | "anchor" | "both"), noPlate
-- ("anchor" | "hide"), edgeMargin. Returns mode, x, y, screenW, screenH (x, y only for mode 1/2).
function P.Select(plate, opt)
if opt.placement == "anchor" then return 3 end
@@ -237,6 +237,18 @@ function P.Select(plate, opt)
end
AnySCT_SelectMode = P.Select
+-- Placement "both": the plate rules of "clamp" (modes 1/2) plus a copy at the fixed anchor. Without a
+-- usable plate (none found, off screen with hide, or hidden) the anchor shows it once, whatever the
+-- no-plate setting says. Returns mode, x, y, screenW, screenH, alsoAnchor.
+function P.Places(plate, opt)
+ local mode, x, y, w, h = P.Select(plate, opt)
+ if opt.placement == "both" then
+ if mode == 1 or mode == 2 then return mode, x, y, w, h, true end
+ return 3
+ end
+ return mode, x, y, w, h, false
+end
+
-- Does the plate still show the mob? Uses the last scan only (no per-frame GetName).
function P.PlateMatches(plate, guidNorm)
if not plate:IsVisible() then return false end
diff --git a/screenshots/demo-anchor-tower.png b/screenshots/demo-anchor-tower.png
new file mode 100644
index 0000000..2f675b0
Binary files /dev/null and b/screenshots/demo-anchor-tower.png differ
diff --git a/sct.lua b/sct.lua
index f56a15e..aedea27 100644
--- a/sct.lua
+++ b/sct.lua
@@ -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()