TotemBar 0.4.1
@@ -0,0 +1,34 @@
|
|||||||
|
<Bindings>
|
||||||
|
<Binding name="TOTEMBAR_DROPSET" runOnUp="true" header="TOTEMBAR">TotemBar.dropSetKey(keystate);</Binding>
|
||||||
|
<Binding name="TOTEMBAR_RECALL">TotemBar.CastRecall();</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOGGLEBAR">TotemBar.ToggleBar();</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOGGLEOPTIONS">TotemBar.ToggleOptions();</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOGGLEBIND">TotemBar.ToggleBindMode();</Binding>
|
||||||
|
<Binding name="TOTEMBAR_CAST_FIRE">TotemBar.CastElement("Fire");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_CAST_EARTH">TotemBar.CastElement("Earth");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_CAST_WATER">TotemBar.CastElement("Water");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_CAST_AIR">TotemBar.CastElement("Air");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_SEARING_TOTEM" header="TOTEMBAR_FIRE">TotemBar.CastTotem("Searing Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_FIRE_NOVA_TOTEM">TotemBar.CastTotem("Fire Nova Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_MAGMA_TOTEM">TotemBar.CastTotem("Magma Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_FLAMETONGUE_TOTEM">TotemBar.CastTotem("Flametongue Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_FROST_RESISTANCE_TOTEM">TotemBar.CastTotem("Frost Resistance Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_EARTHBIND_TOTEM" header="TOTEMBAR_EARTH">TotemBar.CastTotem("Earthbind Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_STONECLAW_TOTEM">TotemBar.CastTotem("Stoneclaw Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_STONESKIN_TOTEM">TotemBar.CastTotem("Stoneskin Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_STRENGTH_OF_EARTH_TOTEM">TotemBar.CastTotem("Strength of Earth Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_TREMOR_TOTEM">TotemBar.CastTotem("Tremor Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_FIRE_RESISTANCE_TOTEM" header="TOTEMBAR_WATER">TotemBar.CastTotem("Fire Resistance Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_HEALING_STREAM_TOTEM">TotemBar.CastTotem("Healing Stream Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_MANA_SPRING_TOTEM">TotemBar.CastTotem("Mana Spring Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_POISON_CLEANSING_TOTEM">TotemBar.CastTotem("Poison Cleansing Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_DISEASE_CLEANSING_TOTEM">TotemBar.CastTotem("Disease Cleansing Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_MANA_TIDE_TOTEM">TotemBar.CastTotem("Mana Tide Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_GRACE_OF_AIR_TOTEM" header="TOTEMBAR_AIR">TotemBar.CastTotem("Grace of Air Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_GROUNDING_TOTEM">TotemBar.CastTotem("Grounding Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_NATURE_RESISTANCE_TOTEM">TotemBar.CastTotem("Nature Resistance Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_SENTRY_TOTEM">TotemBar.CastTotem("Sentry Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_WINDFURY_TOTEM">TotemBar.CastTotem("Windfury Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_WINDWALL_TOTEM">TotemBar.CastTotem("Windwall Totem");</Binding>
|
||||||
|
<Binding name="TOTEMBAR_TOTEM_TRANQUIL_AIR_TOTEM">TotemBar.CastTotem("Tranquil Air Totem");</Binding>
|
||||||
|
</Bindings>
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to TotemBar are documented here.
|
||||||
|
|
||||||
|
## v0.4.1 — 2026-08-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A totem destroyed by the enemy inside the ~0.4s window between a Totemic Recall wipe and its
|
||||||
|
refusal being attributed now stays dead instead of having its countdown resurrected by the
|
||||||
|
refused-recall restore (`core/cast.lua`).
|
||||||
|
|
||||||
|
## v0.4.0 — 2026-08-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Destroyed totems can now be detected while SuperWoW is present (verified against captured
|
||||||
|
event log 2026-08-20, live eviction pending retest).** A totem killed before its timer ran
|
||||||
|
out used to keep counting down as if it were still standing — both TotemBar's own tracking
|
||||||
|
and pfUI's libtotem are blind, duration-only timers, and neither ever checked whether the
|
||||||
|
totem itself was still alive (pfUI's `GetTotemInfo` was previously assumed to cover this
|
||||||
|
case; checked against its source — it does not). A live event tap against the real client
|
||||||
|
(2026-08-20) captured totem spawn/pulse/death lines and found the original mechanism could
|
||||||
|
never have fired at all: the spawn/pulse latch paths compared totem names for *exact*
|
||||||
|
equality, but the live client hands back a *ranked* name ("Searing Totem VI"), never the bare
|
||||||
|
book name; and the pulse-cast latch mapped through the pulse *spell's* name ("Searing Bolt"),
|
||||||
|
which is not the totem's own name either. Both are now fixed, and a third, GUID-free signal
|
||||||
|
was added as the primary path:
|
||||||
|
1. **Primary — `CHAT_MSG_COMBAT_FRIENDLY_DEATH`** (native 1.12, needs no SuperWoW/nampower):
|
||||||
|
TurtleWoW emits a totem's own death as `"<Name> (<Owner>) is destroyed."` verbatim; parsed
|
||||||
|
and evicted once the owner resolves to the player and the name rank-tolerantly matches a
|
||||||
|
tracked totem. The generic vanilla `"<Name> dies."` form is accepted as a second, stricter
|
||||||
|
shape — since it carries no owner, it only evicts a candidate that already has a latched
|
||||||
|
GUID, confirmed via a live `UnitExists`/`UnitHealth`/`UnitIsDeadOrGhost` read.
|
||||||
|
2. **`UNIT_CASTEVENT`** (SuperWoW), for totems that periodically cast their own pulse — now
|
||||||
|
keyed off `UnitName(casterGUID)` (the totem unit's own, rank-tolerant name) instead of the
|
||||||
|
pulse spell's name; covers only pulsing totems (Searing/Magma/Fire Nova/Mana Spring/
|
||||||
|
Healing Stream/Mana Tide/the Cleansing totems), gated on the caster's resolved owner
|
||||||
|
matching the player.
|
||||||
|
3. **`UNIT_MODEL_CHANGED`** (native 1.12), for totems that never cast anything visible — now
|
||||||
|
rank-tolerant instead of an exact name compare.
|
||||||
|
4. **`UNIT_DIED`** (nampower), extended with a GUID-free fallback: when no GUID was ever
|
||||||
|
latched, a rank-tolerant name + owner match at the death instant (both resolve reliably
|
||||||
|
there per the captured log) still evicts.
|
||||||
|
|
||||||
|
A totem confirmed destroyed is evicted immediately and tombstoned for the remainder of its
|
||||||
|
own natural duration, so pfUI's libtotem — itself just another blind timer — cannot
|
||||||
|
resurrect the countdown/ring/pulse a moment later; a real re-cast on that element clears the
|
||||||
|
tombstone right away. This clears the countdown, duration ring, pulse animation and
|
||||||
|
out-of-range tint together, since all four already derive from the same tracking record. On
|
||||||
|
a client without SuperWoW, only the primary `CHAT_MSG_COMBAT_FRIENDLY_DEATH` signal is
|
||||||
|
active; without either SuperWoW or TurtleWoW's own death-line wording, behaviour is
|
||||||
|
byte-for-byte unchanged from before this feature. The decision logic (name matching, line
|
||||||
|
parsing, eviction gating) is fully offline-tested against the captured fixture lines (95
|
||||||
|
assertions in `tools/luatests/test_destroy.lua`); a live totem-kill retest with the addon
|
||||||
|
in the loop is still pending — `/tb tdump` prints each totem's latched `guid`, the raw
|
||||||
|
`UnitExists`/`UnitHealth`/`UnitIsDeadOrGhost` reads, and (for an evicted slot) its tombstone
|
||||||
|
status, for that verification.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A totem's countdown no longer starts when the cast was refused for lack of mana.** The
|
||||||
|
pre-cast mana gate stood down completely while Clearcasting (Elemental Focus) was up, on the
|
||||||
|
assumption that it zeroes the next cast's price. Elemental Focus covers the shaman's damage
|
||||||
|
spells, not totem summons -- so a totem costs full mana while Clearcasting is up, and since
|
||||||
|
totems are the only thing this addon casts, that exemption disabled the mana gate outright.
|
||||||
|
The client then refused the cast while the timer had already been recorded, leaving a
|
||||||
|
countdown running for a totem that was never placed. The exemption is gone from the cast
|
||||||
|
gate, the out-of-mana dim and the flyout tint alike; the unverified buff-icon constant it
|
||||||
|
relied on went with it. The gate still accepts (and ignores) the old sixth argument, so no
|
||||||
|
stale caller can quietly reinstate it.
|
||||||
|
|
||||||
|
- **A Totemic Recall the server refuses no longer wipes your totem timers.**
|
||||||
|
Recall already checked whether the cast could go out *before* touching the
|
||||||
|
tracking, but a press that passes that check can still be refused after the
|
||||||
|
fact — moving at the wrong moment, a stun or silence landing first, or plain
|
||||||
|
latency. The tracking was cleared unconditionally the instant the cast was
|
||||||
|
issued, so a refused Recall left the addon believing nothing was out while a
|
||||||
|
full set kept standing — the same phantom-state bug class the totem casts'
|
||||||
|
own two-layer guard already closed, mirrored. The recall paths now remember
|
||||||
|
what they cleared, and the same failure signals that take back a refused
|
||||||
|
totem's countdown (the exact per-spell one with nampower, the error-message
|
||||||
|
fallback without it) put the timers back. The restore follows the same
|
||||||
|
narrow rules as the totem-cast revoke: only when the Recall itself was the
|
||||||
|
last cast attempted, only within a short window afterward, and never over a
|
||||||
|
slot a new cast has already refilled — when attribution is uncertain it
|
||||||
|
stays silent, since here a *wrongly restored* timer would be the phantom.
|
||||||
|
Covers the Recall button, its keybind, and the DropSet keybind's recall
|
||||||
|
stroke alike.
|
||||||
|
|
||||||
|
## v0.3.0 — 2026-08-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Totems you cannot afford are dimmed.** Element buttons and the hover flyout
|
||||||
|
grey out while your mana is below the totem's cost, so the bar shows what is
|
||||||
|
actually castable instead of only what is chosen. The cost is the real one for
|
||||||
|
your highest known rank, including cost talents, and the dim composes with the
|
||||||
|
out-of-range tint rather than replacing it — an out-of-range totem you also
|
||||||
|
cannot afford still reads as red, just darker. Clearcasting suspends it, since
|
||||||
|
the next spell is free while it is up.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **A refused cast no longer starts a countdown, and one that already started
|
||||||
|
can now be taken back.** Pressing a totem without the mana for it (or while
|
||||||
|
it is genuinely on cooldown) used to start a full timer for a totem that was
|
||||||
|
never placed — which also made Totemic Recall believe something was out and
|
||||||
|
burn its 6s cooldown on an empty board. The cast's chances are now checked
|
||||||
|
*before* it leaves, so an attempt that cannot have gone through is never
|
||||||
|
tracked in the first place. A second layer catches what the pre-check
|
||||||
|
cannot predict — movement, another action already in progress, a stun, or a
|
||||||
|
server-side refusal — by watching the client's own "cast failed" signal
|
||||||
|
(the exact one with nampower, an error-message fallback without it) and
|
||||||
|
undoing the countdown after the fact. That revoke is filtered narrowly on
|
||||||
|
purpose: it only fires for the totem cast just attempted, only within a
|
||||||
|
short window afterward, and only for messages the client is known to use
|
||||||
|
for a refused cast, so it stays silent whenever it cannot be sure — a
|
||||||
|
refused *repeat* press restores the original timer rather than clearing it.
|
||||||
|
In every case where attribution is uncertain, this fails **closed**: no
|
||||||
|
revoke happens, and the existing timer is left standing, because a missing
|
||||||
|
timer is worse than a phantom one.
|
||||||
|
- **Totemic Recall no longer wipes your totem timers on a press that could not
|
||||||
|
have gone out.** Recall's own 6s cooldown or the global cooldown could
|
||||||
|
refuse a manual Recall press while the button and keybind still cleared the
|
||||||
|
addon's totem tracking as if it had fired — after which the addon believed
|
||||||
|
nothing was out and refused every further Recall press until something
|
||||||
|
re-armed it. Recall now checks whether the cast can actually go out before
|
||||||
|
it touches the tracking, and a press that cannot keeps it intact.
|
||||||
|
- **Totemic Recall no longer wastes its cooldown on a no-op moments after your
|
||||||
|
totems expire.** The check for "is anything even out" relied on the totem
|
||||||
|
tracking table staying occupied, but that table is emptied the instant each
|
||||||
|
totem's own timer runs out — so within a fraction of a second of the last
|
||||||
|
totem expiring, Recall forgot it had ever cast anything and let a needless
|
||||||
|
press through, burning the cooldown on an empty board. A separate flag now
|
||||||
|
answers "did we cast this session", so the check survives normal timer
|
||||||
|
expiry as intended.
|
||||||
|
- **The Recall button and its keybind can no longer sweep away a set you just
|
||||||
|
redeployed.** Both issued a plain Totemic Recall cast, which nampower
|
||||||
|
queues behind the global cooldown — so pressing Recall during a GCD could
|
||||||
|
fire it *after* you had already placed your totems again, recalling the
|
||||||
|
set you had just dropped. Both now share the same queue-safe cast the
|
||||||
|
automatic recall paths already used, so a GCD-blocked press is skipped
|
||||||
|
instead of firing late.
|
||||||
|
- **A totem you have not learned no longer starts a countdown.** The saved
|
||||||
|
totem set is account-wide and not checked against the spellbook, so a set
|
||||||
|
carried over from another character could land on one that never learned
|
||||||
|
those totems. Casting it was a guaranteed no-op, but it still started a
|
||||||
|
full phantom timer and made Totemic Recall think something was out.
|
||||||
|
- **Sentry Totem's timer no longer expires 180 seconds early.** It was
|
||||||
|
missing its own duration entry and silently fell back to a generic
|
||||||
|
120-second default, well short of the totem's real 5-minute duration.
|
||||||
|
- **The seventh Air totem is no longer dropped from the hover flyout.** The
|
||||||
|
flyout's icon pool was sized for six totems, but Air offers seven — with
|
||||||
|
the Air slot left empty, the last entry could neither be cast nor be set
|
||||||
|
as the slot's new default from the bar.
|
||||||
|
- **Windwall Totem no longer reads permanently in-range once you are also
|
||||||
|
carrying Strength of Earth Totem.** The range check matched buff icons by
|
||||||
|
an unanchored substring, and Strength of Earth Totem's icon name happens to
|
||||||
|
start with Windwall's, so carrying one made the other's out-of-range tint
|
||||||
|
never trigger.
|
||||||
|
- **A totem pulse from another shaman can no longer drag your own pulse ring
|
||||||
|
backward.** The periodic self-heal/self-mana messages TotemBar reads to
|
||||||
|
time the pulse ring carry no caster information, so an identically worded
|
||||||
|
tick from someone else's totem re-anchored the phase and made the ring
|
||||||
|
visibly jump. Anchoring is now limited to at most once per pulse interval.
|
||||||
|
- **The out-of-range tint no longer skips most of a freshly-placed set when
|
||||||
|
pfUI is loaded.** pfUI's own totem tracking commits at most one totem per
|
||||||
|
cast, so dropping a full four-totem set left three slots with no
|
||||||
|
information from it at all — and those slots were treated as "known to be
|
||||||
|
in range" instead of falling back to TotemBar's own tracking, so most of
|
||||||
|
the set never turned red out of range or pulsed the Recall button.
|
||||||
|
- **The duration ring no longer counts down, jumps back up, and counts down
|
||||||
|
again with Totemic Mastery talented.** TotemBar's own tracking stores the
|
||||||
|
Mastery-inflated duration while pfUI's totem data reports the shorter flat
|
||||||
|
spellbook value; the display preferred pfUI's number until it ran out, then
|
||||||
|
jumped back up to the correct remaining time. Both sources now agree on the
|
||||||
|
same, Mastery-scaled endpoint.
|
||||||
|
|
||||||
|
## v0.2.5 — 2026-07-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **New option: "Show drop-all button".** The drop-all-totems button can now be
|
||||||
|
hidden from the bar, for anyone who drives it purely from a keybind or a macro
|
||||||
|
and would rather not spend the slot on it. On by default, so nothing changes
|
||||||
|
unless you turn it off.
|
||||||
|
- **The keybind keeps working while the button is hidden.** It is bound to the
|
||||||
|
cast function, not to the frame, and the entry stays listed in the Esc key
|
||||||
|
bindings menu — otherwise you could never assign it again.
|
||||||
|
- **The bar resizes instead of leaving a gap.** In the 1×6 layout it shrinks by
|
||||||
|
one slot. In 2×3 and 3×2 the frame keeps its size on purpose: Recall still
|
||||||
|
needs the last column, and shrinking there would break up the 2×2 block of
|
||||||
|
the four element totems. What remains is one empty grid cell in the trailing
|
||||||
|
corner — invisible, and it simply adds drag area.
|
||||||
|
|
||||||
|
## v0.2.4 — 2026-07-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Totemic Recall no longer refuses to fire after a /reload.** The "no totems
|
||||||
|
out — not recalling" guard could block a legitimate recall: TotemBar tracks
|
||||||
|
which totems you've dropped in memory only, and a `/reload` wipes that memory.
|
||||||
|
On 1.12 there is no `GetTotemInfo` / totem unit-id to re-detect a totem that
|
||||||
|
was placed before the reload, so the addon wrongly concluded nothing was out
|
||||||
|
and refused to recall. The guard now blocks **only** when it is certain nothing
|
||||||
|
is out (it tracked totems this session and they've all expired); after a
|
||||||
|
/reload the state is unknown, so it fails open and lets the recall through.
|
||||||
|
Worst case is one needless recall right after a reload — far better than a
|
||||||
|
blocked one.
|
||||||
|
|
||||||
|
## v0.2.3 — 2026-07-13
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Totemic Recall no longer sweeps away a freshly-placed set (nampower).**
|
||||||
|
With nampower installed, dropping your set while the global cooldown was
|
||||||
|
active could make the new totems appear and then instantly vanish: nampower
|
||||||
|
queued Totemic Recall behind the GCD, so it fired *after* the (off-GCD)
|
||||||
|
totems were placed and pulled them right back down. TotemBar now casts
|
||||||
|
Recall via `CastSpellByNameNoQueue` when nampower provides it, so a
|
||||||
|
GCD-blocked Recall is skipped for that press instead of firing late. No
|
||||||
|
effect for setups without nampower.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **The drop-set keybind now recalls on key-down and places on key-release.**
|
||||||
|
This keeps the recall and the totem placements as separate hardware events;
|
||||||
|
for a normal tap the difference is imperceptible.
|
||||||
|
|
||||||
|
## v0.2.2 — 2026-07-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **"Button spacing" slider** in the options panel (10–30 px) — adjusts the
|
||||||
|
gap between bar buttons live, including the flyout and assignment panel.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Keybind casts now start TotemBar's own duration/pulse timers.**
|
||||||
|
Per-totem and element key bindings call the same tracking as click-casts.
|
||||||
|
Previously, setups without pfUI got no timer/ring/pulse feedback for
|
||||||
|
keybind-cast totems, and even with pfUI the spawn ripple and out-of-range
|
||||||
|
tint were missing for them.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Dead `pulseGlow` setting** — the anticipation glow is part of "Show
|
||||||
|
pulse waves"; stale saved values are cleaned up automatically.
|
||||||
|
|
||||||
|
## v0.2.1 — 2026-07-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Tooltips showed rank 1 instead of your highest rank.** Both totem
|
||||||
|
tooltips (element buttons and the flyout) resolved the spell by its first
|
||||||
|
spellbook match — which is rank 1 — so mana cost, damage and duration read
|
||||||
|
far too low. Display-only: the actual cast is name-based and always used
|
||||||
|
your highest rank. Tooltips now resolve to the highest known rank **and
|
||||||
|
show the rank** next to the spell name (vanilla's tooltip omits it).
|
||||||
|
|
||||||
|
## v0.2.0 — 2026-07-10
|
||||||
|
|
||||||
|
**The Pulse UI release** — a complete visual rework of the bar around one idea:
|
||||||
|
you should see at a glance *how long* each totem still stands and *when* it
|
||||||
|
pulses next.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Round floating slots.** Buttons are free-floating round icons in a subtle
|
||||||
|
shadow-rim frame — the square boxes and backdrops are gone. Flyout, Recall
|
||||||
|
and drop-set share the round look (round hover/pressed states included).
|
||||||
|
- **Duration ring (time traffic light).** The outer ring drains with the
|
||||||
|
totem's remaining lifetime and blends green → yellow → orange → red as time
|
||||||
|
runs out.
|
||||||
|
- **Pulse ring.** A thin inner arc in the totem's element color fills toward
|
||||||
|
the next pulse and resets when it fires — animated per frame, no stutter.
|
||||||
|
- **Server-accurate pulse timing.** Intervals and first-tick behavior come
|
||||||
|
from TurtleWoW server data, not guesswork: e.g. Tremor pulses every **4s**
|
||||||
|
(not the commonly cited 3s) and ticks **immediately** on placement, while
|
||||||
|
Magma/Healing Stream/Mana Spring first pulse one full interval after
|
||||||
|
placement. Water totems additionally re-anchor on the real observed
|
||||||
|
mana/heal ticks.
|
||||||
|
- **Pulse waves & anticipation glow.** An expanding ripple in the element
|
||||||
|
color marks each pulse; a soft halo builds around the ring during the last
|
||||||
|
stretch before it. A spawn ripple confirms every placement.
|
||||||
|
- **Bar layouts.** Cycle between 1×6, 2×3 (elements grouped as a 2×2 block)
|
||||||
|
and 3×2 in the options panel.
|
||||||
|
- **Custom art set.** Element glyphs for empty slots (no more question
|
||||||
|
marks), a four-element drop-set icon, a minimap glyph, a custom options
|
||||||
|
panel skin with rounded corners, and grounding shadow plates under the
|
||||||
|
slots. All art is generated by scripts in `tools/` — reproducible, no
|
||||||
|
binary-only assets.
|
||||||
|
- **New options:** Show duration ring · Show pulse ring · Show pulse waves ·
|
||||||
|
Show countdown text · Layout.
|
||||||
|
- **`/tb pulsecal` (dev aid):** captures pulse-timing telemetry to
|
||||||
|
`imports\totembar_pulsecal.txt` (SuperWoW) for verifying timings in-game.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Button spacing widened (4 → 10 px) to give the ring frames air; multi-row
|
||||||
|
layouts use the same tight pitch vertically.
|
||||||
|
- Options panel restyled with the custom skin and section dividers.
|
||||||
|
- The remaining-time countdown text is now optional (on by default).
|
||||||
|
|
||||||
|
## v0.1.3 — 2026-07-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Totemic Recall no longer wastes its 6-second cooldown.** Recall — via the
|
||||||
|
button, its keybinding, or the "Totems" macro's auto-recall — now only fires
|
||||||
|
when you actually have totems out. Casting it with nothing down put Recall on
|
||||||
|
its 6s cooldown, so a set placed right after couldn't be recalled for 6
|
||||||
|
seconds. With nothing out, TotemBar now skips the cast (with a brief note).
|
||||||
|
|
||||||
|
## v0.1.2 — 2026-07-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Mana cost on the drop-set tooltip** — hovering the drop-set button (and the
|
||||||
|
"Totems" macro action) shows the total mana to drop your four chosen totems.
|
||||||
|
- **Refund on the Totemic Recall tooltip** — shows the mana you'll get back,
|
||||||
|
computed live from the totems currently out (expired ones are excluded) and a
|
||||||
|
refund percentage that is learned from your real recall mana-gain (defaults to
|
||||||
|
25% until learned).
|
||||||
|
- **Totemic Mastery duration** — when the talent is skilled, helpful totems'
|
||||||
|
own-tracked timers get the +20% duration so they no longer read short.
|
||||||
|
- `/tb manadump` dev aid — writes the raw mana-cost scan (resolved rank, tooltip
|
||||||
|
lines, parsed cost) to `imports\totembar_manadump.txt`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Totem mana costs were read from **rank 1** instead of the highest rank you
|
||||||
|
actually cast, so the displayed mana was far too low. The scan now resolves
|
||||||
|
the highest known rank.
|
||||||
|
|
||||||
|
## v0.1.1 — 2026-07-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Drop-set button** on the bar (right of the Totemic Recall button): one
|
||||||
|
left-click casts all four chosen totems (the same action as the "Totems"
|
||||||
|
macro, with the 2-second double-press guard).
|
||||||
|
- **Key bindings** — a **TotemBar** section in the Esc → Key Bindings menu:
|
||||||
|
Drop all totems, Totemic Recall, Toggle bar, Toggle options, Toggle key-bind
|
||||||
|
mode, Cast *element* totem (chosen, one per element), and — under per-element
|
||||||
|
sub-sections — a binding to cast **every individual totem** (Cast Searing
|
||||||
|
Totem, Cast Windfury Totem, …).
|
||||||
|
- **Hover-bind mode** (`/tb bind`, or the options panel's "Key bind mode"
|
||||||
|
button): hover any bar button or a flyout totem and press a key to bind it
|
||||||
|
(a button binds its action; a flyout totem binds that specific totem). ESC
|
||||||
|
over a button clears its key; ESC over empty space (or the options button)
|
||||||
|
exits. A "Key-Bind Mode ACTIVE" box is shown while active. The
|
||||||
|
currently-bound key is displayed on each button at all times (abbreviated,
|
||||||
|
e.g. `N7` for Num Pad 7). Bindings are saved by the game and persist.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- Totemic Recall cast **deliberately** (the Recall button, or its keybinding)
|
||||||
|
is never guarded — only the Totems macro's built-in recall respects the 2s
|
||||||
|
double-press guard.
|
||||||
|
|
||||||
|
## v0.1.0 — 2026-07-09
|
||||||
|
|
||||||
|
Initial public release.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Totem bar: four element buttons + a Totemic Recall button. Left-click an
|
||||||
|
element casts its chosen totem; right-click clears the slot. Hover an element
|
||||||
|
for a flyout of the element's other known totems (left-click to cast once,
|
||||||
|
right-click to set as the new default).
|
||||||
|
- One-press "Totems" macro (`TotemBar.recallAndCastAll()`) with a 2-second
|
||||||
|
double-press recall guard (a rapid second press won't recall the totems you
|
||||||
|
just dropped).
|
||||||
|
- Native cooldown swipes + OmniCC-style duration timers; out-of-range red tint
|
||||||
|
(buff-presence based); the Recall button pulses when a totem is out of range.
|
||||||
|
- Real in-game spell tooltips on the element buttons and the flyout.
|
||||||
|
- Minimap button (orbit-draggable; left-click = options, right-click = toggle
|
||||||
|
bar); pfUI-safe, with `/tb options` as a fallback.
|
||||||
|
- Options panel: lock bar, auto-recall, show bar, recall guard, cycle-reset
|
||||||
|
gap, UI size (scales the bar, anchored top-left), reset position, create
|
||||||
|
"Totems" macro, and per-setting tooltips. Adopts pfUI's look when pfUI is
|
||||||
|
installed, a clean Blizzard look otherwise.
|
||||||
|
- Assignment-receiver API (`TotemBar.ReceiveAssignment`) for a future raid
|
||||||
|
totem-assignment addon — see [`docs/API.md`](docs/API.md).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 ShempError
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# TotemBar
|
||||||
|
|
||||||
|
A lightweight shaman totem bar for **TurtleWoW / WoW 1.12.1 (Vanilla)**.
|
||||||
|
|
||||||
|
Pick one totem per element, cast it with a click, cycle alternatives from a
|
||||||
|
hover flyout, or drop all four with a single press. Every slot shows **how
|
||||||
|
long the totem still stands** (outer ring, green → red) and **when it pulses
|
||||||
|
next** (inner ring in the element's color) — with timings taken from actual
|
||||||
|
server data, not guesswork. No dependencies; adopts the
|
||||||
|
[pfUI](https://github.com/shagu/pfUI) look automatically when present.
|
||||||
|
|
||||||
|
<img src="screenshots/pulse-ui.png" alt="TotemBar in action: round slots with duration and pulse rings, totems placed in the world" width="100%">
|
||||||
|
|
||||||
|
## What's new — v0.4.1 · 2026-08-31
|
||||||
|
|
||||||
|
- **A totem destroyed inside the recall attribution window stays dead.** A totem the enemy
|
||||||
|
destroyed in the ~0.4s window between a Totemic Recall wipe and its refusal being attributed
|
||||||
|
no longer has its countdown resurrected by the refused-recall restore.
|
||||||
|
|
||||||
|
**Version history** — details in [CHANGELOG.md](CHANGELOG.md):
|
||||||
|
|
||||||
|
- **v0.4.1** (2026-08-31) — a totem destroyed by the enemy inside the recall attribution
|
||||||
|
window now stays dead instead of coming back when a refused Totemic Recall restores its
|
||||||
|
timers.
|
||||||
|
- **v0.4.0** (2026-08-30) — a totem destroyed before its timer ran out is now
|
||||||
|
detected and cleared (countdown, ring, pulse and range tint together) instead
|
||||||
|
of counting down as if it still stood; a totem cast refused for lack of mana
|
||||||
|
no longer starts a countdown (the Clearcasting exemption never applied to
|
||||||
|
totems); a Totemic Recall the server refuses no longer wipes your timers.
|
||||||
|
- **v0.3.0** (2026-08-15) — element buttons and the flyout now dim totems you
|
||||||
|
can't afford; a refused cast no longer starts a phantom countdown, and one
|
||||||
|
that already started can now be taken back after the fact (narrowly, and
|
||||||
|
never at the risk of clearing a real timer). Also fixes Totemic Recall
|
||||||
|
wiping or wasting itself around a press that couldn't go out or one queued
|
||||||
|
behind the global cooldown, Sentry Totem's timer expiring early, the
|
||||||
|
seventh Air totem being unreachable, Windwall's range tint sticking on
|
||||||
|
with Strength of Earth also up, the pulse ring jumping on another
|
||||||
|
shaman's tick, the out-of-range tint missing most of a set under pfUI, and
|
||||||
|
the duration ring double-counting with Totemic Mastery talented.
|
||||||
|
- **v0.2.5** (2026-07-25) — new option **"Show drop-all button"**: hide the
|
||||||
|
drop-all-totems button if you fire it from a keybind or macro and would rather
|
||||||
|
not spend a slot on it. The keybind keeps working while it's hidden, and in the
|
||||||
|
1×6 layout the bar shrinks instead of leaving dead space.
|
||||||
|
- **v0.2.4** (2026-07-14) — Totemic Recall no longer refuses to fire after a
|
||||||
|
`/reload`: the "no totems out" guard relied on in-memory tracking that a reload
|
||||||
|
wipes, and 1.12 offers no way to re-detect totems placed beforehand. Also a
|
||||||
|
shared spellbook index, killing a multi-millisecond hitch when dropping four
|
||||||
|
totems from one macro press.
|
||||||
|
- **v0.2.3** (2026-07-13) — fixes a bug where dropping your set could make the
|
||||||
|
new totems vanish an instant later: with nampower, Totemic Recall was queued
|
||||||
|
behind the global cooldown and fired *after* the (off-GCD) totems were
|
||||||
|
placed, sweeping them. Recall now bypasses the spell queue so it can't pull a
|
||||||
|
fresh set.
|
||||||
|
- **v0.2.2** (2026-07-10) — "Button spacing" slider in the options panel;
|
||||||
|
keybind casts now feed TotemBar's own duration/pulse timers (previously
|
||||||
|
only click-casts and pfUI did); removed the dead `pulseGlow` setting.
|
||||||
|
- **v0.2.1** (2026-07-10) — tooltips now show your highest known rank (they
|
||||||
|
read rank-1 values before; the cast itself was always highest-rank) and
|
||||||
|
display the rank next to the name.
|
||||||
|
- **v0.1.3** (2026-07-09) — Totemic Recall no longer wastes its 6s cooldown
|
||||||
|
when no totems are out.
|
||||||
|
- **v0.1.2** (2026-07-09) — mana cost on the drop-set tooltip, live refund
|
||||||
|
estimate on the Recall tooltip, Totemic Mastery duration bonus, rank-aware
|
||||||
|
mana scan.
|
||||||
|
- **v0.1.1** (2026-07-09) — key bindings (Esc menu section for every action
|
||||||
|
and every single totem), drop-set bar button, hover-bind mode with
|
||||||
|
per-button key labels.
|
||||||
|
- **v0.1.0** (2026-07-09) — first public release: element buttons, hover
|
||||||
|
flyout, "Totems" macro with recall guard, minimap button, options panel,
|
||||||
|
duration timers, out-of-range tint.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| The bar & flyout | Options panel |
|
||||||
|
| :---: | :---: |
|
||||||
|
| <img src="screenshots/bar.png" alt="The totem bar with Recall tooltip" width="100%"><br><img src="screenshots/flyout.png" alt="Hover flyout with spell tooltip" width="100%"> | <img src="screenshots/options.png" alt="Options panel" width="70%"> |
|
||||||
|
|
||||||
|
| Key bindings | Hover-bind mode |
|
||||||
|
| :---: | :---: |
|
||||||
|
| <img src="screenshots/keybindings.png" alt="Esc menu key bindings section" width="100%"> | <img src="screenshots/bind-mode.png" alt="Hover-bind mode with key labels" width="100%"> |
|
||||||
|
|
||||||
|
*(Click any image for full size.)*
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
**Casting**
|
||||||
|
- One button per element (Fire / Earth / Water / Air): left-click casts the
|
||||||
|
chosen totem, right-click clears the slot.
|
||||||
|
- Hover flyout with your other known totems per element — left-click casts
|
||||||
|
once, right-click makes it the new default.
|
||||||
|
- **Drop-set button** and a one-press **"Totems" macro** cast all four chosen
|
||||||
|
totems at once, guarded against accidental double presses (so a fast second
|
||||||
|
press won't recall the set you just placed).
|
||||||
|
- Dedicated **Totemic Recall** button that only fires when totems are
|
||||||
|
actually out — never wastes Recall's 6s cooldown. Right-click toggles
|
||||||
|
auto-recall-before-redeploy.
|
||||||
|
|
||||||
|
**At-a-glance timing**
|
||||||
|
- **Duration ring:** the outer ring drains with the totem's lifetime,
|
||||||
|
colored green → yellow → orange → red by remaining time.
|
||||||
|
- **Pulse ring:** the inner arc (element color) fills toward the next pulse —
|
||||||
|
intervals and first-tick behavior derived from TurtleWoW server data, water
|
||||||
|
totems locked onto their real observed ticks.
|
||||||
|
- **Pulse waves:** an expanding ripple marks each pulse, a soft halo
|
||||||
|
announces it just before, and a spawn ripple confirms each placement.
|
||||||
|
- Optional countdown text under each slot; totems tint red when you leave
|
||||||
|
their range (buff-presence based), and the Recall button pulses while
|
||||||
|
anything is out of range.
|
||||||
|
- **Totems you cannot afford are dimmed** — buttons and flyout, from the real
|
||||||
|
cost of your highest known rank; the dim composes with the range tint instead
|
||||||
|
of overwriting it, and stands down while Clearcasting is up.
|
||||||
|
- **Countdowns only start for casts that could go out** — a press refused for
|
||||||
|
mana or a real cooldown is not tracked, so no phantom timer and no wasted
|
||||||
|
Totemic Recall on an empty board. A second check catches what that cannot
|
||||||
|
predict (movement, a stun, a server-side refusal) and takes the countdown
|
||||||
|
back after the fact — narrowly filtered to the totem just pressed, so it
|
||||||
|
stays silent whenever it can't be sure rather than risk clearing a timer
|
||||||
|
for a totem that is actually still out.
|
||||||
|
- Native cooldown swipes and real in-game spell tooltips everywhere.
|
||||||
|
|
||||||
|
**Control**
|
||||||
|
- **Key bindings:** an Esc → Key Bindings section for every action — including
|
||||||
|
a binding for every single totem you know — plus a fast **hover-bind mode**
|
||||||
|
(hover a button, press a key). Bound keys show on the buttons.
|
||||||
|
- **Layouts:** one row (1×6), two rows (2×3, elements grouped 2×2) or three
|
||||||
|
rows (3×2), switchable live.
|
||||||
|
- **Minimap button** (drag to reposition, left-click options, right-click
|
||||||
|
toggles the bar) and a skinned **options panel** for everything else.
|
||||||
|
- **Assignment seam** for future raid tools: an external addon can *suggest*
|
||||||
|
a totem set via `TotemBar.ReceiveAssignment` — shown as a one-click apply
|
||||||
|
panel, never auto-cast. See [`docs/API.md`](docs/API.md).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
**Option A — download (simplest):**
|
||||||
|
|
||||||
|
1. Grab **`TotemBar-vX.Y.Z.zip`** from
|
||||||
|
[**Releases**](https://github.com/ShempError/TotemBar/releases).
|
||||||
|
*(Use the Release zip, not "Code → Download ZIP" — that names the folder
|
||||||
|
`TotemBar-master`, which WoW won't load.)*
|
||||||
|
2. Extract the **`TotemBar`** folder into `Interface\AddOns\`.
|
||||||
|
3. Restart the client.
|
||||||
|
|
||||||
|
**Option B — git (auto-updatable):**
|
||||||
|
|
||||||
|
```
|
||||||
|
cd Interface/AddOns
|
||||||
|
git clone https://github.com/ShempError/TotemBar.git TotemBar
|
||||||
|
```
|
||||||
|
|
||||||
|
`master` is the stable release channel — git-based managers
|
||||||
|
(GitAddonsManager, OctoWoW) stay current with a `git pull`. `dev` is
|
||||||
|
work-in-progress.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
| Action | Effect |
|
||||||
|
| --- | --- |
|
||||||
|
| Left-click element button | Cast that element's chosen totem |
|
||||||
|
| Right-click element button | Clear that element's slot |
|
||||||
|
| Hover element button | Flyout with the element's other known totems |
|
||||||
|
| Left-click flyout totem | Cast it once (default unchanged) |
|
||||||
|
| Right-click flyout totem | Set it as the slot's new default |
|
||||||
|
| Left-click Recall | Cast Totemic Recall (only when totems are out) |
|
||||||
|
| Right-click Recall | Toggle auto-recall |
|
||||||
|
| Left-click drop-set button | Cast all four chosen totems |
|
||||||
|
| Minimap button | Left: options · Right: toggle bar · Drag: reposition |
|
||||||
|
|
||||||
|
**Slash commands:** `/tb` (toggle bar) · `/tb options` · `/tb lock` ·
|
||||||
|
`/tb bind` (hover-bind mode) · `/tb scan`, `/tb assign`, `/tb manadump`,
|
||||||
|
`/tb pulsecal` (dev aids).
|
||||||
|
|
||||||
|
**The "Totems" macro:** options panel → **Create 'Totems' macro**, then drag
|
||||||
|
the `Totems` macro to your action bar. One press recalls (if auto-recall is
|
||||||
|
on) and redeploys all four chosen totems.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
Open with the minimap button or `/tb options`.
|
||||||
|
|
||||||
|
| Option | Does |
|
||||||
|
| --- | --- |
|
||||||
|
| Lock bar | Disable dragging |
|
||||||
|
| Auto-recall before setting | Recall first when the macro/drop-set fires |
|
||||||
|
| Show bar | Show/hide the whole bar |
|
||||||
|
| Show drop-all button | Show/hide the drop-all-totems button; its keybind keeps working either way |
|
||||||
|
| Show duration ring | Outer remaining-time ring (traffic-light colors) |
|
||||||
|
| Show pulse ring | Inner next-pulse arc (element color) |
|
||||||
|
| Show pulse waves | Ripple + glow effects on pulses |
|
||||||
|
| Show countdown text | Remaining-seconds text under each slot |
|
||||||
|
| Layout | Cycle 1×6 / 2×3 / 3×2 |
|
||||||
|
| Recall guard (sec) | Double-press protection window |
|
||||||
|
| Cycle reset gap (sec) | When macro cycling restarts at totem 1 |
|
||||||
|
| UI size | Scales bar + flyout from the top-left corner |
|
||||||
|
| Reset position / Create macro / Key bind mode | One-click actions |
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- A WoW 1.12.1 client (TurtleWoW). **No addon dependencies.**
|
||||||
|
|
||||||
|
Optional, auto-detected: **pfUI** (skinned widgets; its `libtotem` feeds the
|
||||||
|
timers as an extra source) and **SuperWoW** (only used by the `/tb scan` /
|
||||||
|
`/tb pulsecal` dev aids for file exports).
|
||||||
|
|
||||||
|
## For developers
|
||||||
|
|
||||||
|
- Public API for companion addons: [`docs/API.md`](docs/API.md).
|
||||||
|
- Pure-logic modules under `core/` are unit-tested offline against a real
|
||||||
|
Lua 5.0.3 interpreter (`tools/luatests/`, 19 suites). WoW-API files are
|
||||||
|
syntax-checked; in-game behavior is verified on TurtleWoW.
|
||||||
|
- All textures are **generated**: `tools/gen_*.js` (zero-dependency Node)
|
||||||
|
render the ring/FX/icon/panel sheets as 1.12-safe TGAs, and
|
||||||
|
`tools/preview_*.js` composite them into PNG previews for review without
|
||||||
|
launching the client.
|
||||||
|
- `/reload` covers changes to existing `.lua` files; a client restart is
|
||||||
|
needed for `.toc` changes, new files, or changed textures (the client
|
||||||
|
caches them).
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT (see LICENSE).
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## Interface: 11200
|
||||||
|
## Title: TotemBar
|
||||||
|
## Notes: Shaman totem bar - pick one totem per element, cast it, or spam-cycle all four from a macro.
|
||||||
|
## Author: ShempError
|
||||||
|
## Version: 0.4.1
|
||||||
|
## SavedVariables: TotemBarDB
|
||||||
|
|
||||||
|
core\totemdata.lua
|
||||||
|
core\spellindex.lua
|
||||||
|
core\known.lua
|
||||||
|
core\cast.lua
|
||||||
|
core\config.lua
|
||||||
|
core\assign.lua
|
||||||
|
core\optionslogic.lua
|
||||||
|
core\bindlogic.lua
|
||||||
|
core\manacost.lua
|
||||||
|
core\pulsedata.lua
|
||||||
|
core\pulse.lua
|
||||||
|
core\pulseparse.lua
|
||||||
|
core\pulsecal.lua
|
||||||
|
ui.lua
|
||||||
|
options.lua
|
||||||
|
minimap.lua
|
||||||
|
Bindings.xml
|
||||||
|
bind.lua
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
-- TotemBar - bind.lua
|
||||||
|
-- Key-binding labels (for the Esc > Key Bindings menu) + the cast entry
|
||||||
|
-- points the Bindings.xml bodies call. Hover-bind mode is added below in a
|
||||||
|
-- later step. Loaded AFTER Bindings.xml (see the .toc).
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
|
||||||
|
|
||||||
|
-- Section + fixed-action labels.
|
||||||
|
BINDING_HEADER_TOTEMBAR = "TotemBar"
|
||||||
|
BINDING_NAME_TOTEMBAR_DROPSET = "Drop all totems"
|
||||||
|
BINDING_NAME_TOTEMBAR_RECALL = "Totemic Recall"
|
||||||
|
BINDING_NAME_TOTEMBAR_TOGGLEBAR = "Toggle bar"
|
||||||
|
BINDING_NAME_TOTEMBAR_TOGGLEOPTIONS = "Toggle options"
|
||||||
|
BINDING_NAME_TOTEMBAR_TOGGLEBIND = "Toggle key-bind mode"
|
||||||
|
|
||||||
|
-- Per-element "cast the chosen totem" labels + per-element sub-headers.
|
||||||
|
do
|
||||||
|
local elements = TotemBar.TOTEM_ELEMENTS
|
||||||
|
for i = 1, table.getn(elements) do
|
||||||
|
local e = elements[i]
|
||||||
|
setglobal("BINDING_NAME_TOTEMBAR_CAST_" .. string.upper(e), "Cast " .. e .. " totem (chosen)")
|
||||||
|
setglobal("BINDING_HEADER_TOTEMBAR_" .. string.upper(e), "TotemBar: " .. e .. " Totems")
|
||||||
|
end
|
||||||
|
-- Per-totem labels (must match Bindings.xml names via bindingSuffix).
|
||||||
|
for i = 1, table.getn(elements) do
|
||||||
|
local e = elements[i]
|
||||||
|
local list = TotemBar.TOTEMS_BY_ELEMENT[e]
|
||||||
|
for j = 1, table.getn(list) do
|
||||||
|
local totem = list[j]
|
||||||
|
setglobal("BINDING_NAME_TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(totem), "Cast " .. totem)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cast a specific totem by name (no-op in-game if not known).
|
||||||
|
function TotemBar.CastTotem(name)
|
||||||
|
if name then
|
||||||
|
CastSpellByName(name)
|
||||||
|
-- keybind casts must feed the own-tracking timers exactly like the
|
||||||
|
-- flyout click handler does (ui.lua) - without this, vanilla-only
|
||||||
|
-- setups get no duration ring/pulse for keybind-cast totems.
|
||||||
|
local element = TotemBar.elementOf(name)
|
||||||
|
if element then
|
||||||
|
TotemBar.recordCast(element, name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cast the currently-chosen totem for an element.
|
||||||
|
function TotemBar.CastElement(element)
|
||||||
|
local n = TotemBarDB and TotemBarDB.chosen and TotemBarDB.chosen[element]
|
||||||
|
if n then
|
||||||
|
CastSpellByName(n)
|
||||||
|
-- keybind casts must feed the own-tracking timers exactly like the
|
||||||
|
-- flyout click handler does (ui.lua) - without this, vanilla-only
|
||||||
|
-- setups get no duration ring/pulse for keybind-cast totems.
|
||||||
|
TotemBar.recordCast(element, n)
|
||||||
|
else
|
||||||
|
ChatOut:AddMessage("TotemBar: no totem chosen for " .. tostring(element) .. ".")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Cast Totemic Recall and clear own-tracking. Runs the exact same
|
||||||
|
-- TotemBar.manualRecall (core/cast.lua) as the Recall button's left-click, so
|
||||||
|
-- the keybind and the button can never diverge: it owns the "nothing is out"
|
||||||
|
-- gate, the "is Recall even castable right now" check (a press that cannot go
|
||||||
|
-- out must NOT wipe the timers of totems still standing), the queue-safe cast
|
||||||
|
-- that keeps nampower from firing a late Recall into a fresh set, the refund
|
||||||
|
-- snapshot and the own-tracking wipe. Only the chat feedback lives here.
|
||||||
|
function TotemBar.CastRecall()
|
||||||
|
if not TotemBar.manualRecall then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local action = TotemBar.manualRecall()
|
||||||
|
if action == "none-out" then
|
||||||
|
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
|
||||||
|
|
||||||
|
-- Stub so the TOTEMBAR_TOGGLEBIND binding never calls nil before Task 4
|
||||||
|
-- fills it in. Replaced (same name) by the real implementation below.
|
||||||
|
if not TotemBar.ToggleBindMode then
|
||||||
|
function TotemBar.ToggleBindMode()
|
||||||
|
ChatOut:AddMessage("TotemBar: key-bind mode not available.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ===== Hover-bind mode =====
|
||||||
|
-- Toggle a mode where hovering a bar button / flyout totem and pressing a
|
||||||
|
-- key binds that key to the thing's action (buttons -> CLICK binding, flyout
|
||||||
|
-- totems -> the named per-totem binding). ESC clears the hovered thing's
|
||||||
|
-- binding. Bindings persist via the client (SaveBindings), no addon SV.
|
||||||
|
|
||||||
|
local bindMode = false
|
||||||
|
local captureFrame = nil
|
||||||
|
local infoBox = nil
|
||||||
|
|
||||||
|
-- Base keys that are pure modifiers - never bind these alone.
|
||||||
|
local BARE_MODIFIERS = {
|
||||||
|
LSHIFT = true, RSHIFT = true, LCTRL = true, RCTRL = true,
|
||||||
|
LALT = true, RALT = true, UNKNOWN = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotemBar.isBindMode()
|
||||||
|
return bindMode
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Resolves the mouse-focused frame to a binding action. Uses the pure
|
||||||
|
-- TotemBar.actionForButton with the frame's global name and, for a flyout
|
||||||
|
-- icon, its current .totemName.
|
||||||
|
local function actionForFocus(focus)
|
||||||
|
if not focus then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local totemName = focus.totemName -- set on flyout icon buttons when shown
|
||||||
|
local fname = focus.GetName and focus:GetName() or nil
|
||||||
|
return TotemBar.actionForButton(fname, totemName)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensureCaptureFrame()
|
||||||
|
if captureFrame then
|
||||||
|
return captureFrame
|
||||||
|
end
|
||||||
|
local f = CreateFrame("Frame", "TotemBarBindCapture", UIParent)
|
||||||
|
f:SetAllPoints(UIParent)
|
||||||
|
f:SetFrameStrata("FULLSCREEN_DIALOG")
|
||||||
|
f:EnableKeyboard(false)
|
||||||
|
f:Hide()
|
||||||
|
f:SetScript("OnKeyUp", function()
|
||||||
|
local key = arg1 -- NOTE: OnKeyUp arg1 = key name string (matches pfUI's hoverbind capture)
|
||||||
|
if not key or BARE_MODIFIERS[key] then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local focus = GetMouseFocus()
|
||||||
|
local action = actionForFocus(focus)
|
||||||
|
if key == "ESCAPE" and not action then
|
||||||
|
-- Keyboard capture swallows all keys while bind mode is on, so
|
||||||
|
-- ESC over nothing is the keyboard exit (the options button also
|
||||||
|
-- toggles it off). ESC over a button clears that button below.
|
||||||
|
TotemBar.ToggleBindMode()
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if not action then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if key == "ESCAPE" then
|
||||||
|
local k1, k2 = GetBindingKey(action)
|
||||||
|
if k1 then SetBinding(k1) end
|
||||||
|
if k2 then SetBinding(k2) end
|
||||||
|
else
|
||||||
|
local full = TotemBar.modifierPrefix(IsAltKeyDown(), IsControlKeyDown(), IsShiftKeyDown()) .. key
|
||||||
|
SetBinding(full) -- clear whatever `full` was bound to
|
||||||
|
SetBinding(full, action) -- bind it to this action
|
||||||
|
end
|
||||||
|
SaveBindings(GetCurrentBindingSet())
|
||||||
|
if TotemBar.refreshBindOverlays then
|
||||||
|
TotemBar.refreshBindOverlays()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
captureFrame = f
|
||||||
|
return f
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Lazily builds a visible on-screen info box shown while bind mode is
|
||||||
|
-- active, so the mode (which otherwise only shows via a chat message and
|
||||||
|
-- stays on until ESC) is hard to miss. Rev 4 UI chrome: shared bespoke
|
||||||
|
-- panel skin (ui.lua's PANEL_BACKDROP), matching the rest of the addon's
|
||||||
|
-- chrome, regardless of pfUI presence.
|
||||||
|
local function ensureBindInfoBox()
|
||||||
|
if infoBox then
|
||||||
|
return infoBox
|
||||||
|
end
|
||||||
|
local f = CreateFrame("Frame", "TotemBarBindInfo", UIParent)
|
||||||
|
f:SetWidth(380)
|
||||||
|
f:SetHeight(80)
|
||||||
|
f:SetPoint("TOP", UIParent, "TOP", 0, -160)
|
||||||
|
f:SetFrameStrata("FULLSCREEN_DIALOG")
|
||||||
|
-- Cheap guard: bind.lua loads LAST per the .toc (after ui.lua), so
|
||||||
|
-- TotemBar.PANEL_BACKDROP is always set by the time this runs - the
|
||||||
|
-- fallback only protects against a future load-order change.
|
||||||
|
local backdrop = TotemBar.PANEL_BACKDROP or {
|
||||||
|
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background",
|
||||||
|
edgeFile = "Interface\\DialogFrame\\UI-DialogBox-Border",
|
||||||
|
tile = true, tileSize = 32, edgeSize = 32,
|
||||||
|
insets = { left = 11, right = 12, top = 12, bottom = 11 },
|
||||||
|
}
|
||||||
|
f:SetBackdrop(backdrop)
|
||||||
|
f:SetBackdropColor(1, 1, 1, 0.97)
|
||||||
|
local t = f:CreateFontString("TotemBarBindInfoText", "OVERLAY", "GameFontNormal")
|
||||||
|
t:SetPoint("CENTER", f, "CENTER", 0, 0)
|
||||||
|
t:SetJustifyH("CENTER")
|
||||||
|
t:SetText("|cff33ffccKey-Bind Mode ACTIVE|r\nHover a bar button or flyout totem and press a key to bind it.\nESC over a button clears its key. ESC over empty space (or the options button) exits.")
|
||||||
|
if pfUI and pfUI.font_default then
|
||||||
|
t:SetFont(pfUI.font_default, 12)
|
||||||
|
end
|
||||||
|
f:Hide()
|
||||||
|
infoBox = f
|
||||||
|
return f
|
||||||
|
end
|
||||||
|
|
||||||
|
function TotemBar.ToggleBindMode()
|
||||||
|
bindMode = not bindMode
|
||||||
|
local f = ensureCaptureFrame()
|
||||||
|
if bindMode then
|
||||||
|
f:EnableKeyboard(true)
|
||||||
|
f:Show()
|
||||||
|
ensureBindInfoBox():Show()
|
||||||
|
ChatOut:AddMessage("TotemBar: key-bind mode ON - hover a button or flyout totem and press a key to bind. ESC over a button clears its key; ESC over nothing (or the options button) exits.")
|
||||||
|
else
|
||||||
|
f:EnableKeyboard(false)
|
||||||
|
f:Hide()
|
||||||
|
if infoBox then
|
||||||
|
infoBox:Hide()
|
||||||
|
end
|
||||||
|
ChatOut:AddMessage("TotemBar: key-bind mode OFF.")
|
||||||
|
end
|
||||||
|
if TotemBar.refreshBindOverlays then
|
||||||
|
TotemBar.refreshBindOverlays()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Refresh overlays whenever the client's bindings change.
|
||||||
|
local bindEvents = CreateFrame("Frame", "TotemBarBindEventFrame", UIParent)
|
||||||
|
bindEvents:RegisterEvent("UPDATE_BINDINGS")
|
||||||
|
bindEvents:SetScript("OnEvent", function()
|
||||||
|
if TotemBar.refreshBindOverlays then
|
||||||
|
TotemBar.refreshBindOverlays()
|
||||||
|
end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
-- TotemBar - core/assign.lua
|
||||||
|
-- Assignment receiver seam: the contract + pure logic by which an external
|
||||||
|
-- assigner (future "ShamiPower") hands TotemBar a proposed totem set.
|
||||||
|
-- WoW-API-light so the logic below is offline-testable under real Lua 5.0.
|
||||||
|
-- The pending-suggestion UI lives in ui.lua and is reached through the
|
||||||
|
-- optional hook slots (ShowAssignPanel/HideAssignPanel/isTotemKnown/
|
||||||
|
-- RefreshAll) that ui.lua fills in at load time.
|
||||||
|
--
|
||||||
|
-- An "assignment set" is a table keyed by element -> totem spell name:
|
||||||
|
-- { Fire = "Searing Totem", Air = "Windfury Totem", ... }
|
||||||
|
-- Any subset of elements; a missing element means "nothing for that slot".
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
-- True if `key` is one of the four totem elements.
|
||||||
|
function TotemBar.isElement(key)
|
||||||
|
if not key then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
local elements = TotemBar.TOTEM_ELEMENTS
|
||||||
|
for i = 1, table.getn(elements) do
|
||||||
|
if elements[i] == key then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Validates an assignment set. Returns true, or false plus a reason string.
|
||||||
|
function TotemBar.validateAssignment(set)
|
||||||
|
if type(set) ~= "table" then
|
||||||
|
return false, "set must be a table"
|
||||||
|
end
|
||||||
|
local count = 0
|
||||||
|
for k, v in pairs(set) do
|
||||||
|
if not TotemBar.isElement(k) then
|
||||||
|
return false, "unknown element key: " .. tostring(k)
|
||||||
|
end
|
||||||
|
if type(v) ~= "string" or v == "" then
|
||||||
|
return false, "totem name for " .. tostring(k) .. " must be a non-empty string"
|
||||||
|
end
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
if count == 0 then
|
||||||
|
return false, "set is empty"
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Shallow copy of a set, keeping only valid element keys.
|
||||||
|
function TotemBar.copySet(set)
|
||||||
|
local out = {}
|
||||||
|
if type(set) == "table" then
|
||||||
|
for k, v in pairs(set) do
|
||||||
|
if TotemBar.isElement(k) then
|
||||||
|
out[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Fresh copy (element -> name) of the currently chosen totems.
|
||||||
|
function TotemBar.GetChosenSet()
|
||||||
|
local out = {}
|
||||||
|
local chosen = TotemBarDB and TotemBarDB.chosen
|
||||||
|
if chosen then
|
||||||
|
local elements = TotemBar.TOTEM_ELEMENTS
|
||||||
|
for i = 1, table.getn(elements) do
|
||||||
|
local e = elements[i]
|
||||||
|
if chosen[e] then
|
||||||
|
out[e] = chosen[e]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Splits a set into applied (isKnown(name) true) and skipped (false).
|
||||||
|
function TotemBar.filterKnown(set, isKnown)
|
||||||
|
local applied, skipped = {}, {}
|
||||||
|
for k, v in pairs(set) do
|
||||||
|
if isKnown(v) then
|
||||||
|
applied[k] = v
|
||||||
|
else
|
||||||
|
skipped[k] = v
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return applied, skipped
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Current pending assignment: { set = {element=name,...}, label = string } or nil.
|
||||||
|
-- In-memory only (not a SavedVariable) - an assignment is ephemeral coordination.
|
||||||
|
TotemBar.pending = nil
|
||||||
|
|
||||||
|
-- Optional hook, filled by an external assigner: called with the applied
|
||||||
|
-- set table after the player accepts. Nil by default.
|
||||||
|
TotemBar.onAssignmentApplied = nil
|
||||||
|
|
||||||
|
-- THE SEAM. An external assigner calls this to propose a set. Validates,
|
||||||
|
-- stores it as the pending suggestion (replacing any prior), and asks the
|
||||||
|
-- UI to show the panel. Does NOT apply. Returns true, or false + reason.
|
||||||
|
function TotemBar.ReceiveAssignment(set, label)
|
||||||
|
local ok, reason = TotemBar.validateAssignment(set)
|
||||||
|
if not ok then
|
||||||
|
return false, reason
|
||||||
|
end
|
||||||
|
TotemBar.pending = { set = TotemBar.copySet(set), label = label }
|
||||||
|
if TotemBar.ShowAssignPanel then
|
||||||
|
TotemBar.ShowAssignPanel()
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drops any pending suggestion and hides the panel (decline / post-apply).
|
||||||
|
function TotemBar.ClearAssignment()
|
||||||
|
TotemBar.pending = nil
|
||||||
|
if TotemBar.HideAssignPanel then
|
||||||
|
TotemBar.HideAssignPanel()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Applies the pending suggestion: sets each KNOWN totem as the chosen
|
||||||
|
-- default for its element (unknown totems are skipped), refreshes the bar,
|
||||||
|
-- clears pending, hides the panel, and fires onAssignmentApplied. No cast.
|
||||||
|
function TotemBar.ApplyPending()
|
||||||
|
local p = TotemBar.pending
|
||||||
|
if not p then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local isKnown = TotemBar.isTotemKnown or function() return true end
|
||||||
|
local applied = TotemBar.filterKnown(p.set, isKnown)
|
||||||
|
TotemBarDB.chosen = TotemBarDB.chosen or {}
|
||||||
|
for element, name in pairs(applied) do
|
||||||
|
TotemBarDB.chosen[element] = name
|
||||||
|
end
|
||||||
|
if TotemBar.RefreshAll then
|
||||||
|
TotemBar.RefreshAll()
|
||||||
|
end
|
||||||
|
TotemBar.pending = nil
|
||||||
|
if TotemBar.HideAssignPanel then
|
||||||
|
TotemBar.HideAssignPanel()
|
||||||
|
end
|
||||||
|
if TotemBar.onAssignmentApplied then
|
||||||
|
TotemBar.onAssignmentApplied(applied)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- TotemBar - core/bindlogic.lua
|
||||||
|
-- PURE helpers for keybindings + hover-bind mode (no WoW API), offline-testable.
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
-- Binding-name suffix for a totem: uppercase, non-alphanumeric runs -> "_",
|
||||||
|
-- trimmed. "Grace of Air Totem" -> "GRACE_OF_AIR_TOTEM". MUST match the
|
||||||
|
-- Bindings.xml generation and the BINDING_NAME_ globals.
|
||||||
|
function TotemBar.bindingSuffix(name)
|
||||||
|
if not name then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
local up = string.upper(name)
|
||||||
|
up = string.gsub(up, "[^A-Z0-9]+", "_")
|
||||||
|
up = string.gsub(up, "^_+", "")
|
||||||
|
up = string.gsub(up, "_+$", "")
|
||||||
|
return up
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Modifier prefix for a key string, order ALT-CTRL-SHIFT (each arg is 1/nil
|
||||||
|
-- as IsAltKeyDown()/IsControlKeyDown()/IsShiftKeyDown() return on 1.12).
|
||||||
|
function TotemBar.modifierPrefix(isAlt, isCtrl, isShift)
|
||||||
|
local p = ""
|
||||||
|
if isAlt then p = p .. "ALT-" end
|
||||||
|
if isCtrl then p = p .. "CTRL-" end
|
||||||
|
if isShift then p = p .. "SHIFT-" end
|
||||||
|
return p
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Binding COMMAND for a hovered thing, or nil. A flyout icon (totemName
|
||||||
|
-- given) -> the named per-totem binding (casts that specific totem, same
|
||||||
|
-- action as the Esc menu). A bar button (by global frame name) -> the
|
||||||
|
-- matching NAMED binding that already exists in Bindings.xml (NOT a
|
||||||
|
-- "CLICK ..." binding - those are unreliable on 1.12, see bind.lua).
|
||||||
|
function TotemBar.actionForButton(frameName, totemName)
|
||||||
|
if totemName and totemName ~= "" then
|
||||||
|
return "TOTEMBAR_TOTEM_" .. TotemBar.bindingSuffix(totemName)
|
||||||
|
end
|
||||||
|
if not frameName then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
if frameName == "TotemBarButtonRecall" then
|
||||||
|
return "TOTEMBAR_RECALL"
|
||||||
|
end
|
||||||
|
if frameName == "TotemBarButtonDropSet" then
|
||||||
|
return "TOTEMBAR_DROPSET"
|
||||||
|
end
|
||||||
|
local elements = TotemBar.TOTEM_ELEMENTS
|
||||||
|
for i = 1, table.getn(elements) do
|
||||||
|
if frameName == "TotemBarButton" .. elements[i] then
|
||||||
|
return "TOTEMBAR_CAST_" .. string.upper(elements[i])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Compact a binding key string for a small button overlay:
|
||||||
|
-- "SHIFT-NUMPAD7" -> "sN7", "BUTTON4" -> "M4", "MOUSEWHEELUP" -> "MwU".
|
||||||
|
function TotemBar.shortenKey(key)
|
||||||
|
if not key or key == "" then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
local s = key
|
||||||
|
s = string.gsub(s, "ALT%-", "a")
|
||||||
|
s = string.gsub(s, "CTRL%-", "c")
|
||||||
|
s = string.gsub(s, "SHIFT%-", "s")
|
||||||
|
s = string.gsub(s, "MOUSEWHEELUP", "MwU")
|
||||||
|
s = string.gsub(s, "MOUSEWHEELDOWN", "MwD")
|
||||||
|
s = string.gsub(s, "NUMPAD", "N")
|
||||||
|
s = string.gsub(s, "BUTTON", "M")
|
||||||
|
s = string.gsub(s, "SPACE", "Sp")
|
||||||
|
return s
|
||||||
|
end
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
-- TotemBar - core/config.lua
|
||||||
|
-- SavedVariables model. TotemBarDB is declared in the .toc as a
|
||||||
|
-- SavedVariable; ensureDefaults() fills in anything missing (first run,
|
||||||
|
-- or a saved file from an older version that lacks newer fields).
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
TotemBarDB = TotemBarDB or {}
|
||||||
|
|
||||||
|
-- TotemBarDB shape:
|
||||||
|
-- chosen[element] = totemName ("Fire" -> "Searing Totem", ...)
|
||||||
|
-- gapSeconds = spam-cycle reset gap (seconds)
|
||||||
|
-- locked = boolean, whether the bar can be dragged
|
||||||
|
-- autoRecall = boolean, whether recallAndCastAll() prepends
|
||||||
|
-- Totemic Recall (toggled via the Recall button's
|
||||||
|
-- right-click, see ui.lua); default ON
|
||||||
|
-- point/relPoint/x/y = saved frame anchor (see ui.lua)
|
||||||
|
-- showDurationRing/ringStyle/showPulseBars/showPulseWaves/
|
||||||
|
-- showTimerText = Pulse UI (see spec). showPulseBars = the countdown
|
||||||
|
-- bar (primary "when's the next pulse" readout);
|
||||||
|
-- showPulseWaves = the ripple (event feedback only);
|
||||||
|
-- independently toggleable.
|
||||||
|
-- barLayout = bar arrangement: "1x6"|"2x3"|"3x2" (rows x cols),
|
||||||
|
-- cycled via the options panel (see ui.lua's
|
||||||
|
-- ApplyBarLayout / options.lua's layout button).
|
||||||
|
-- showDropSetButton = boolean, whether the "Drop all totems" (DropSet)
|
||||||
|
-- button is shown on the bar; default ON, so existing
|
||||||
|
-- users keep it. Visibility only: the button is always
|
||||||
|
-- created, and its keybinding (BINDING_NAME_TOTEMBAR_
|
||||||
|
-- DROPSET) plus the "Totems" macro keep working while
|
||||||
|
-- it is hidden - same idea as the "Show bar" toggle.
|
||||||
|
-- buttonGap = px gap between bar buttons, range 10-30, default
|
||||||
|
-- TotemBar.DEFAULT_BUTTON_GAP (see core/cast.lua);
|
||||||
|
-- live-applied via ui.lua's TotemBar.SetButtonGap,
|
||||||
|
-- set from the options panel's "Button spacing" slider.
|
||||||
|
function TotemBar.ensureDefaults()
|
||||||
|
TotemBarDB.chosen = TotemBarDB.chosen or {}
|
||||||
|
TotemBarDB.gapSeconds = TotemBarDB.gapSeconds or TotemBar.DEFAULT_GAP_SECONDS
|
||||||
|
if TotemBarDB.locked == nil then
|
||||||
|
TotemBarDB.locked = false
|
||||||
|
end
|
||||||
|
if TotemBarDB.autoRecall == nil then
|
||||||
|
TotemBarDB.autoRecall = true
|
||||||
|
end
|
||||||
|
TotemBarDB.point = TotemBarDB.point or "CENTER"
|
||||||
|
TotemBarDB.relPoint = TotemBarDB.relPoint or "CENTER"
|
||||||
|
TotemBarDB.x = TotemBarDB.x or 0
|
||||||
|
TotemBarDB.y = TotemBarDB.y or 0
|
||||||
|
TotemBarDB.scale = TotemBarDB.scale or 1.0
|
||||||
|
TotemBarDB.minimapAngle = TotemBarDB.minimapAngle or 225
|
||||||
|
if TotemBarDB.hidden == nil then
|
||||||
|
TotemBarDB.hidden = false
|
||||||
|
end
|
||||||
|
TotemBarDB.recallGuardSeconds = TotemBarDB.recallGuardSeconds or TotemBar.DEFAULT_RECALL_GUARD
|
||||||
|
TotemBarDB.recallRefundPct = TotemBarDB.recallRefundPct or 0.25
|
||||||
|
TotemBarDB.buttonGap = TotemBarDB.buttonGap or TotemBar.DEFAULT_BUTTON_GAP
|
||||||
|
|
||||||
|
-- Pulse UI:
|
||||||
|
-- duration ring + pulse bars, all on by default; ringStyle "round" vs
|
||||||
|
-- "square" is the in-game comparison toggle.
|
||||||
|
if TotemBarDB.showDurationRing == nil then
|
||||||
|
TotemBarDB.showDurationRing = true
|
||||||
|
end
|
||||||
|
TotemBarDB.ringStyle = TotemBarDB.ringStyle or "round"
|
||||||
|
if TotemBarDB.showPulseBars == nil then
|
||||||
|
TotemBarDB.showPulseBars = true
|
||||||
|
end
|
||||||
|
if TotemBarDB.showPulseWaves == nil then
|
||||||
|
TotemBarDB.showPulseWaves = true
|
||||||
|
end
|
||||||
|
-- dead setting removed in v0.2.2, clear persisted dust
|
||||||
|
TotemBarDB.pulseGlow = nil
|
||||||
|
if TotemBarDB.showTimerText == nil then
|
||||||
|
TotemBarDB.showTimerText = true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Bar contents: the DropSet button is optional, shown by default so an
|
||||||
|
-- upgrade never makes it disappear (visibility only, see the shape note).
|
||||||
|
if TotemBarDB.showDropSetButton == nil then
|
||||||
|
TotemBarDB.showDropSetButton = true
|
||||||
|
end
|
||||||
|
|
||||||
|
TotemBarDB.barLayout = TotemBarDB.barLayout or "1x6"
|
||||||
|
end
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- TotemBar - core/known.lua
|
||||||
|
-- knownTotems() is PURE (no WoW API) and offline-testable.
|
||||||
|
-- The WoW-API spellbook scan it consumes (scanSpellbook) now lives in
|
||||||
|
-- core/spellindex.lua, alongside the other spellbook-index accessors
|
||||||
|
-- (findSpellIndex/findHighestRankSlot/highestKnownRank) -- kept minimal
|
||||||
|
-- and separate so the pure filtering logic below never has to touch the
|
||||||
|
-- real API to be tested.
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
-- Pure: given a flat list of spell names (strings) known to the player
|
||||||
|
-- and an element name ("Fire"/"Earth"/"Water"/"Air"), returns an array
|
||||||
|
-- of the subset of those names that are totems belonging to that
|
||||||
|
-- element, in the static map's order.
|
||||||
|
--
|
||||||
|
-- - Names not present in the static map (non-totem spells) are ignored.
|
||||||
|
-- - Totems in the static map that aren't in spellNames (not known) are
|
||||||
|
-- omitted from the result.
|
||||||
|
-- - An unknown/unmapped element returns an empty array.
|
||||||
|
function TotemBar.knownTotems(spellNames, element)
|
||||||
|
local result = {}
|
||||||
|
local candidates = TotemBar.TOTEMS_BY_ELEMENT[element]
|
||||||
|
if not candidates then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local known = {}
|
||||||
|
if spellNames then
|
||||||
|
for i = 1, table.getn(spellNames) do
|
||||||
|
known[spellNames[i]] = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local n = 0
|
||||||
|
for i = 1, table.getn(candidates) do
|
||||||
|
local name = candidates[i]
|
||||||
|
if known[name] then
|
||||||
|
n = n + 1
|
||||||
|
result[n] = name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
-- TotemBar.scanSpellbook() and TotemBar.highestKnownRank() used to be
|
||||||
|
-- defined here as thin WoW-API wrappers, each doing its own fresh linear
|
||||||
|
-- GetSpellName() scan. They're now cache-backed views provided by
|
||||||
|
-- core/spellindex.lua (loaded earlier in the TOC) instead, so a totem
|
||||||
|
-- drop no longer re-walks the spellbook once per caller. Same names,
|
||||||
|
-- same signatures, same return values -- callers here are unchanged.
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
-- TotemBar - core/manacost.lua
|
||||||
|
-- Mana-cost reading + refund/duration helpers. The PURE functions here are
|
||||||
|
-- offline-tested; the WoW-API pieces (hidden-tooltip scan, talent scan, refund
|
||||||
|
-- learner) are appended in a later task and are guarded so this file loads
|
||||||
|
-- fine under plain Lua for the tests.
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
-- Pure: mana cost from a tooltip line like "155 Mana" -> 155, else nil.
|
||||||
|
function TotemBar.parseManaCost(text)
|
||||||
|
if not text then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
-- Anchor to the "Mana" keyword so a numeric line like "30 sec cooldown"
|
||||||
|
-- can't be mistaken for the cost (don't rely on tooltip line ordering).
|
||||||
|
local _, _, n = string.find(text, "^(%d+) [Mm]ana")
|
||||||
|
if n then
|
||||||
|
return tonumber(n)
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure: sum costFn(chosen[element]) over the elements that have a chosen totem.
|
||||||
|
function TotemBar.sumChosenCost(chosen, elements, costFn)
|
||||||
|
local total = 0
|
||||||
|
if not chosen then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
for i = 1, table.getn(elements) do
|
||||||
|
local name = chosen[elements[i]]
|
||||||
|
if name then
|
||||||
|
local c = costFn(name)
|
||||||
|
if c then
|
||||||
|
total = total + c
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return total
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure: sum cost of active totems that are still out (remaining > 0).
|
||||||
|
function TotemBar.sumActiveCost(activeTotems, elements, now, costFn, remainingFn)
|
||||||
|
local total = 0
|
||||||
|
if not activeTotems then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
for i = 1, table.getn(elements) do
|
||||||
|
local rec = activeTotems[elements[i]]
|
||||||
|
if rec then
|
||||||
|
local rem = remainingFn(rec.start, rec.duration, now)
|
||||||
|
if rem and rem > 0 then
|
||||||
|
local c = costFn(rec.totemName)
|
||||||
|
if c then
|
||||||
|
total = total + c
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return total
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure: is a KNOWN mana shortfall in front of us? Both halves must be known
|
||||||
|
-- numbers -- an unresolved cost (the normal state until the tooltip scan runs,
|
||||||
|
-- and permanently for a totem that isn't in this character's book) is "I don't
|
||||||
|
-- know", never "too expensive". Greying a bar on "I don't know" would grey the
|
||||||
|
-- whole bar at login; refusing to record a timer on it would silently drop the
|
||||||
|
-- countdown of a totem that is standing. Both callers therefore fail OPEN here,
|
||||||
|
-- the same policy as confidentNoneOut/recallReady in core/cast.lua.
|
||||||
|
function TotemBar.notEnoughMana(cost, mana)
|
||||||
|
if not cost or not mana then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return mana < cost
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clearcasting (Elemental Focus) used to exempt every mana verdict here. That was
|
||||||
|
-- WRONG and is gone (2026-08-28, player-reported): Elemental Focus zeroes the next
|
||||||
|
-- DAMAGE spell, not a totem summon, so a totem costs full price while Clearcasting
|
||||||
|
-- is up. Because totems are the only thing this addon casts, the exemption
|
||||||
|
-- disabled the mana gate and the out-of-mana dim outright whenever the buff was
|
||||||
|
-- up. TotemBar.hasClearcasting/CLEARCAST_ICON were removed with it -- the icon was
|
||||||
|
-- never verified in-game and now has no consumer. TotemBar.hasBuffWithIcon stays:
|
||||||
|
-- the out-of-range tint still uses it.
|
||||||
|
|
||||||
|
-- Live mana verdict for one totem by name: true only when its cost is known,
|
||||||
|
-- the player's mana is below it, and no Clearcasting is up. Reads the cached
|
||||||
|
-- tooltip cost (see getTotemManaCost), so this is a table lookup plus one
|
||||||
|
-- UnitMana call -- cheap enough for the 5Hz display tick that calls it.
|
||||||
|
function TotemBar.totemOutOfMana(name)
|
||||||
|
if not name or type(UnitMana) ~= "function" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return TotemBar.notEnoughMana(TotemBar.getTotemManaCost(name), UnitMana("player"))
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure: floored refund.
|
||||||
|
function TotemBar.refundAmount(pct, activeCost)
|
||||||
|
if not pct or not activeCost then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return math.floor(pct * activeCost)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure: learn refund pct from an observed mana gain; nil if out of sane range.
|
||||||
|
function TotemBar.learnRefundPct(manaGained, activeCost)
|
||||||
|
if not manaGained or not activeCost or activeCost <= 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local pct = manaGained / activeCost
|
||||||
|
if pct < 0.05 or pct > 1.0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return pct
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure: helpful totems get Totemic Mastery's +20% duration.
|
||||||
|
function TotemBar.durationWithMastery(baseDuration, isHelpful, hasMastery)
|
||||||
|
if hasMastery and isHelpful then
|
||||||
|
return baseDuration * 1.2
|
||||||
|
end
|
||||||
|
return baseDuration
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The pure fire-DAMAGE totems do NOT get the +20% helpful-totem duration.
|
||||||
|
-- Everything else is treated as helpful. VERIFY in-game and adjust.
|
||||||
|
TotemBar.NON_HELPFUL_TOTEMS = {
|
||||||
|
["Searing Totem"] = true,
|
||||||
|
["Magma Totem"] = true,
|
||||||
|
["Fire Nova Totem"] = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotemBar.isHelpfulTotem(name)
|
||||||
|
if not name then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if TotemBar.NON_HELPFUL_TOTEMS[name] then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure: brings a pfUI-libtotem-reported duration onto the same scale as the
|
||||||
|
-- one recordCast stores. libtotem serves FLAT book durations; own tracking
|
||||||
|
-- applies Totemic Mastery's +20% to helpful totems. ui.lua's timer prefers
|
||||||
|
-- libtotem while it reports the slot active and falls back to own tracking
|
||||||
|
-- once libtotem evicts it -- so at the book duration the display flipped
|
||||||
|
-- source and the countdown jumped back UP (...3, 2, 1, 24) with the duration
|
||||||
|
-- ring re-filling, instead of running out. Scaling both sides by the same
|
||||||
|
-- factor makes them agree on the endpoint.
|
||||||
|
function TotemBar.gtiDurationWithMastery(gtiDuration, gtiName, hasMastery)
|
||||||
|
if not gtiDuration then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return TotemBar.durationWithMastery(gtiDuration, TotemBar.isHelpfulTotem(gtiName), hasMastery)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ===== WoW-API layer (not offline-executed) =====
|
||||||
|
|
||||||
|
-- TotemBar.findHighestRankSlot(name) used to be defined here as its own
|
||||||
|
-- fresh linear GetSpellName() scan per call. It's now a cache-backed
|
||||||
|
-- accessor provided by core/spellindex.lua (loaded earlier in the TOC) --
|
||||||
|
-- same name, same signature, same resolved slot (the HIGHEST known rank,
|
||||||
|
-- which is the one CastSpellByName actually casts; a first-match scan
|
||||||
|
-- would return rank 1 -> cost far too low, the bug behind "the mana
|
||||||
|
-- values are wrong"). Shared: ui.lua's tooltips resolve through this too -
|
||||||
|
-- SetSpell with a first-name-match slot shows the RANK 1 tooltip (wrong
|
||||||
|
-- mana/duration).
|
||||||
|
|
||||||
|
local scanTip = nil
|
||||||
|
local manaCache = {} -- name -> cost (only positive results cached)
|
||||||
|
|
||||||
|
-- Reads a totem's mana cost via a hidden tooltip scan (no GetSpellManaCost on
|
||||||
|
-- 1.12). Reflects cost talents (Tidal Focus, Restorative Totems) automatically.
|
||||||
|
-- Cached by name; cache cleared on SPELLS_CHANGED (talent/rank changes).
|
||||||
|
function TotemBar.getTotemManaCost(name)
|
||||||
|
if not name then return nil end
|
||||||
|
if manaCache[name] then return manaCache[name] end
|
||||||
|
local idx = TotemBar.findHighestRankSlot(name)
|
||||||
|
if not idx then return nil end
|
||||||
|
if not scanTip then
|
||||||
|
scanTip = CreateFrame("GameTooltip", "TotemBarScanTooltip", nil, "GameTooltipTemplate")
|
||||||
|
end
|
||||||
|
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
|
||||||
|
scanTip:ClearLines()
|
||||||
|
scanTip:SetSpell(idx, BOOKTYPE_SPELL)
|
||||||
|
local cost = nil
|
||||||
|
local lines = scanTip:NumLines() or 0
|
||||||
|
for i = 1, lines do
|
||||||
|
local fs = getglobal("TotemBarScanTooltipTextLeft" .. i)
|
||||||
|
local text = fs and fs:GetText()
|
||||||
|
local c = TotemBar.parseManaCost(text)
|
||||||
|
if c then
|
||||||
|
cost = c
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if cost then manaCache[name] = cost end
|
||||||
|
return cost
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Dev aid (/tb manadump): dump the RAW mana-cost scan for the chosen totems +
|
||||||
|
-- Totemic Recall to <WoW>\imports\totembar_manadump.txt, so the resolved
|
||||||
|
-- slot/rank, every tooltip line, and the parsed cost can be verified
|
||||||
|
-- off-client (ground truth for the mana-cost feature). Chat fallback if no
|
||||||
|
-- SuperWoW ExportFile.
|
||||||
|
function TotemBar.dumpManaScan()
|
||||||
|
local els = TotemBar.TOTEM_ELEMENTS
|
||||||
|
local chosen = (TotemBarDB and TotemBarDB.chosen) or {}
|
||||||
|
local list = {}
|
||||||
|
for i = 1, table.getn(els) do
|
||||||
|
local nm = chosen[els[i]]
|
||||||
|
if nm then list[table.getn(list) + 1] = nm end
|
||||||
|
end
|
||||||
|
list[table.getn(list) + 1] = "Totemic Recall"
|
||||||
|
if not scanTip then
|
||||||
|
scanTip = CreateFrame("GameTooltip", "TotemBarScanTooltip", nil, "GameTooltipTemplate")
|
||||||
|
end
|
||||||
|
local out = "TotemBar mana scan dump\n"
|
||||||
|
local total = 0
|
||||||
|
for i = 1, table.getn(list) do
|
||||||
|
local nm = list[i]
|
||||||
|
local idx = TotemBar.findHighestRankSlot(nm)
|
||||||
|
out = out .. "\n[" .. nm .. "] slot=" .. tostring(idx)
|
||||||
|
if idx then
|
||||||
|
local rn, rr = GetSpellName(idx, BOOKTYPE_SPELL)
|
||||||
|
out = out .. " resolved='" .. tostring(rn) .. "' rank='" .. tostring(rr) .. "'\n"
|
||||||
|
scanTip:SetOwner(WorldFrame, "ANCHOR_NONE")
|
||||||
|
scanTip:ClearLines()
|
||||||
|
scanTip:SetSpell(idx, BOOKTYPE_SPELL)
|
||||||
|
local lines = scanTip:NumLines() or 0
|
||||||
|
local cost = nil
|
||||||
|
for L = 1, lines do
|
||||||
|
local fs = getglobal("TotemBarScanTooltipTextLeft" .. L)
|
||||||
|
local text = fs and fs:GetText()
|
||||||
|
out = out .. " L" .. L .. ": " .. tostring(text) .. "\n"
|
||||||
|
if not cost then
|
||||||
|
local c = TotemBar.parseManaCost(text)
|
||||||
|
if c then cost = c end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
out = out .. " parsedCost=" .. tostring(cost) .. "\n"
|
||||||
|
if cost and nm ~= "Totemic Recall" then total = total + cost end
|
||||||
|
else
|
||||||
|
out = out .. " (not found in spellbook)\n"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
out = out .. "\nsumChosenCost(live)=" .. tostring(
|
||||||
|
TotemBar.sumChosenCost(chosen, els, TotemBar.getTotemManaCost)) .. "\n"
|
||||||
|
out = out .. "dumpTotal(chosen)=" .. total .. "\n"
|
||||||
|
if ExportFile then
|
||||||
|
ExportFile("totembar_manadump", out)
|
||||||
|
end
|
||||||
|
if DEFAULT_CHAT_FRAME then
|
||||||
|
DEFAULT_CHAT_FRAME:AddMessage(
|
||||||
|
"TotemBar: mana dump written (imports\\totembar_manadump.txt). chosen sum=" .. total)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Totemic Mastery (TWoW: +20% helpful-totem duration): cached scan of the
|
||||||
|
-- talent trees by name.
|
||||||
|
local masteryCached = false
|
||||||
|
local function scanMastery()
|
||||||
|
masteryCached = false
|
||||||
|
local tabs = (GetNumTalentTabs and GetNumTalentTabs()) or 0
|
||||||
|
for tab = 1, tabs do
|
||||||
|
local num = GetNumTalents(tab)
|
||||||
|
for i = 1, num do
|
||||||
|
local tname, _, _, _, rank = GetTalentInfo(tab, i)
|
||||||
|
if tname == "Totemic Mastery" and rank and rank > 0 then
|
||||||
|
masteryCached = true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TotemBar.hasTotemicMastery()
|
||||||
|
return masteryCached
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Recall-refund auto-learn. Snapshot the summed cost of the totems currently
|
||||||
|
-- out just before a DELIBERATE Totemic Recall; when the mana-gain message
|
||||||
|
-- arrives shortly after, learn the real refund %.
|
||||||
|
TotemBar.recallPendingCost = 0
|
||||||
|
local recallExpectUntil = 0
|
||||||
|
|
||||||
|
function TotemBar.snapshotRecallCost()
|
||||||
|
TotemBar.recallPendingCost = TotemBar.sumActiveCost(
|
||||||
|
TotemBar.activeTotems, TotemBar.TOTEM_ELEMENTS, GetTime(),
|
||||||
|
TotemBar.getTotemManaCost, TotemBar.remaining)
|
||||||
|
recallExpectUntil = GetTime() + 2
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Events: refresh mastery, clear the cost cache on spell changes, and learn
|
||||||
|
-- the refund % from the recall mana-gain message.
|
||||||
|
-- Guarded: CreateFrame is nil under plain offline Lua, so this whole block is
|
||||||
|
-- skipped there (loadfile/dofile must not error for the test suite).
|
||||||
|
if CreateFrame then
|
||||||
|
local mcEvents = CreateFrame("Frame", "TotemBarManaCostEventFrame", UIParent)
|
||||||
|
mcEvents:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||||
|
mcEvents:RegisterEvent("CHARACTER_POINTS_CHANGED")
|
||||||
|
mcEvents:RegisterEvent("SPELLS_CHANGED")
|
||||||
|
mcEvents:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF")
|
||||||
|
mcEvents:SetScript("OnEvent", function()
|
||||||
|
if event == "PLAYER_ENTERING_WORLD" or event == "CHARACTER_POINTS_CHANGED" then
|
||||||
|
scanMastery()
|
||||||
|
elseif event == "SPELLS_CHANGED" then
|
||||||
|
manaCache = {}
|
||||||
|
scanMastery()
|
||||||
|
elseif event == "CHAT_MSG_SPELL_SELF_BUFF" then
|
||||||
|
if GetTime() > recallExpectUntil then return end
|
||||||
|
local msg = arg1
|
||||||
|
if not msg then return end
|
||||||
|
-- Learn only from a Totemic Recall mana-gain within the window.
|
||||||
|
-- NOTE: exact message text is locale/format dependent - VERIFY in-game
|
||||||
|
-- (adjust the "Totemic Recall" + number pattern if needed).
|
||||||
|
if string.find(msg, "Totemic Recall") then
|
||||||
|
local _, _, num = string.find(msg, "(%d+)")
|
||||||
|
local gained = tonumber(num)
|
||||||
|
local pct = TotemBar.learnRefundPct(gained, TotemBar.recallPendingCost)
|
||||||
|
if pct and TotemBarDB then
|
||||||
|
TotemBarDB.recallRefundPct = pct
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- TotemBar - core/optionslogic.lua
|
||||||
|
-- PURE helpers for the options panel + minimap button (no WoW API), so the
|
||||||
|
-- fiddly bits (orbit math, slider clamping, the macro contract) are
|
||||||
|
-- offline-testable under real Lua 5.0. The UI (minimap.lua / options.lua)
|
||||||
|
-- calls these.
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
-- Orbit offset (x, y) for a minimap button at angleDeg on a circle of the
|
||||||
|
-- given radius. 0deg -> (radius,0); 90 -> (0,radius); 180 -> (-radius,0).
|
||||||
|
function TotemBar.angleToOffset(angleDeg, radius)
|
||||||
|
local rad = math.rad(angleDeg)
|
||||||
|
return math.cos(rad) * radius, math.sin(rad) * radius
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Clamp v into [minVal, maxVal].
|
||||||
|
function TotemBar.clampValue(v, minVal, maxVal)
|
||||||
|
if v < minVal then
|
||||||
|
return minVal
|
||||||
|
end
|
||||||
|
if v > maxVal then
|
||||||
|
return maxVal
|
||||||
|
end
|
||||||
|
return v
|
||||||
|
end
|
||||||
|
|
||||||
|
-- The fixed spec for the "Totems" convenience macro: name, body, and a BARE
|
||||||
|
-- icon file name (no Interface\Icons\ prefix, no extension - TurtleWoW's
|
||||||
|
-- CreateMacro/EditMacro prepend the path themselves).
|
||||||
|
function TotemBar.macroSpec()
|
||||||
|
return "Totems", "/script TotemBar.recallAndCastAll()", "Spell_Nature_TremorTotem"
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Bar-layout grid data + math (feature: selectable 1x6 / 2x3 / 3x2 bar
|
||||||
|
-- arrangement, see ui.lua's ApplyBarLayout). Pure so the button positions
|
||||||
|
-- and pixel-size math can be pinned offline instead of eyeballed in-game.
|
||||||
|
|
||||||
|
-- Explicit per-layout button positions {col, row} (0-based), button order:
|
||||||
|
-- Fire, Earth, Water, Air, Recall, DropSet. "2x3" groups the four element
|
||||||
|
-- totems as a 2x2 block in the first two columns (user request); "3x2"
|
||||||
|
-- groups them as the top 2x2 block with the utility row below.
|
||||||
|
-- DropSet is deliberately the LAST slot in every layout: when it is hidden
|
||||||
|
-- (TotemBarDB.showDropSetButton = false) the remaining five buttons keep
|
||||||
|
-- their exact slots - the element 2x2 block and Recall never move, and no
|
||||||
|
-- second positions table is needed. The multi-row layouts then leave the
|
||||||
|
-- trailing grid cell empty, which is invisible (transparent backdrop) and
|
||||||
|
-- still usable as drag surface.
|
||||||
|
TotemBar.BAR_LAYOUT_POSITIONS = {
|
||||||
|
["1x6"] = { {0,0},{1,0},{2,0},{3,0},{4,0},{5,0} },
|
||||||
|
["2x3"] = { {0,0},{1,0},{0,1},{1,1},{2,0},{2,1} },
|
||||||
|
["3x2"] = { {0,0},{1,0},{0,1},{1,1},{0,2},{1,2} },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Pixel dimensions of the bar frame for a grid of `count` buttons arranged
|
||||||
|
-- in at most `cols` columns of `size`x`size` buttons with `gap` spacing.
|
||||||
|
-- `rowPitchExtra` widens the vertical pitch between rows (multi-row layouts:
|
||||||
|
-- room for the timer text hanging below each row plus the next row's ring
|
||||||
|
-- overhang - see ui.lua's BUTTON_GAP comment); pass 0 for a single row.
|
||||||
|
-- height = top gap + rows*size + (rows-1)*(gap+extra) + bottom gap.
|
||||||
|
-- `cols` is capped at `count` because a grid can never be wider than it has
|
||||||
|
-- buttons: with the DropSet button hidden, "1x6" is a row of FIVE and the
|
||||||
|
-- frame must shrink with it instead of trailing dead space. The multi-row
|
||||||
|
-- layouts still need all of their columns at five buttons, so their size is
|
||||||
|
-- unchanged (they just leave the last grid cell empty).
|
||||||
|
function TotemBar.barDimensions(count, cols, size, gap, rowPitchExtra)
|
||||||
|
if cols > count then
|
||||||
|
cols = count
|
||||||
|
end
|
||||||
|
local rows = math.ceil(count / cols)
|
||||||
|
local width = cols * (size + gap) + gap
|
||||||
|
local height = gap + rows * size + (rows - 1) * (gap + rowPitchExtra) + gap
|
||||||
|
return width, height, rows
|
||||||
|
end
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
-- TotemBar - core/pulse.lua
|
||||||
|
-- PURE pulse/ring math for the Pulse UI (no WoW API; offline-tested via
|
||||||
|
-- tools/luatests/test_pulse.lua). ui.lua calls these from its throttled
|
||||||
|
-- timer tick - keep everything allocation-free except buildRingTexCoords
|
||||||
|
-- (called once at load).
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
-- 0..1 phase toward the next pulse. Phase origin is anchorAt (the last
|
||||||
|
-- OBSERVED pulse, stamped by ui.lua's combat-message handler) when set,
|
||||||
|
-- else placedAt (dead reckoning). Lua 5.0: math.mod, not the % operator.
|
||||||
|
function TotemBar.pulseRatio(placedAt, anchorAt, interval, now)
|
||||||
|
if not interval or interval <= 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local origin = anchorAt or placedAt
|
||||||
|
if not origin or not now then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local elapsed = now - origin
|
||||||
|
if elapsed < 0 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
return math.mod(elapsed, interval) / interval
|
||||||
|
end
|
||||||
|
|
||||||
|
-- How far before the predicted next tick an observed gain may still re-anchor
|
||||||
|
-- the phase (seconds) -- absorbs normal message/latency jitter.
|
||||||
|
TotemBar.PULSE_ANCHOR_TOLERANCE = 0.25
|
||||||
|
|
||||||
|
-- Pure: may an observed periodic self-gain re-anchor this record's pulse phase?
|
||||||
|
--
|
||||||
|
-- The periodic-gain chat lines carry NO caster (1.12's ownerless
|
||||||
|
-- POWERGAIN/PERIODICAURAHEAL ...SELFSELF templates), so a second shaman's
|
||||||
|
-- identically-named totem produces byte-identical text to our own. Accepting
|
||||||
|
-- every message re-stamped the anchor off-beat, roughly twice per interval,
|
||||||
|
-- and the ripple/pulse arc jumped backwards. Rate-limiting to at most one
|
||||||
|
-- anchor per interval locks the display onto ONE tick train instead. A record
|
||||||
|
-- with no anchor yet (fresh cast) always gets its free first stamp, so a
|
||||||
|
-- recast re-acquires phase immediately.
|
||||||
|
function TotemBar.shouldAnchorPulse(lastAnchor, interval, now)
|
||||||
|
if not lastAnchor or not now then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if not interval or interval <= 0 then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return (now - lastAnchor) >= (interval - TotemBar.PULSE_ANCHOR_TOLERANCE)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- 0..1 single fill from placement to detonation (Fire Nova). Clamped at
|
||||||
|
-- both ends; callers treat >=1 as "detonated" and hide the bar.
|
||||||
|
function TotemBar.oneshotRatio(placedAt, delay, now)
|
||||||
|
if not placedAt or not delay or delay <= 0 or not now then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local r = (now - placedAt) / delay
|
||||||
|
if r < 0 then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
if r > 1 then
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
return r
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Blueprint state D ("pulse imminent"): glow from 85% fill on.
|
||||||
|
TotemBar.PULSE_IMMINENT_THRESHOLD = 0.85
|
||||||
|
function TotemBar.pulseImminent(ratio)
|
||||||
|
if ratio and ratio >= TotemBar.PULSE_IMMINENT_THRESHOLD then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Flipbook frame index for the duration ring: 0 = empty ... frameCount-1 =
|
||||||
|
-- full, rounded to nearest so the ring reads full right after placement.
|
||||||
|
-- Generic over frameCount; callers pass the arc's actual frame count (Rev 2:
|
||||||
|
-- 62, cells 0..61 of the ring_round.tga flipbook).
|
||||||
|
function TotemBar.ringFrameIndex(remaining, duration, frameCount)
|
||||||
|
if not remaining or not duration or duration <= 0 or not frameCount then
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
local ratio = remaining / duration
|
||||||
|
if ratio < 0 then
|
||||||
|
ratio = 0
|
||||||
|
end
|
||||||
|
if ratio > 1 then
|
||||||
|
ratio = 1
|
||||||
|
end
|
||||||
|
return math.floor(ratio * (frameCount - 1) + 0.5)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Precomputed SetTexCoord rectangles for a square cols x cols grid texture,
|
||||||
|
-- row-major, 1-based array indexed by frame+1. Built ONCE at load; the
|
||||||
|
-- per-tick path just indexes it (zero allocation, zero math).
|
||||||
|
function TotemBar.buildRingTexCoords(frames, cols)
|
||||||
|
local coords = {}
|
||||||
|
local cell = 1 / cols
|
||||||
|
for i = 0, frames - 1 do
|
||||||
|
local col = math.mod(i, cols)
|
||||||
|
local row = math.floor(i / cols)
|
||||||
|
coords[i + 1] = {
|
||||||
|
l = col * cell,
|
||||||
|
r = (col + 1) * cell,
|
||||||
|
t = row * cell,
|
||||||
|
b = (row + 1) * cell,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
return coords
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Duration counterpart of core/cast.lua's resolveRemaining, SAME precedence:
|
||||||
|
-- GTI (pfUI libtotem) is authoritative while it reports the slot active;
|
||||||
|
-- otherwise fall back to own tracking. Returns the TOTAL duration whose
|
||||||
|
-- remaining time resolveRemaining would have returned, or nil.
|
||||||
|
function TotemBar.resolveDuration(gtiActive, gtiRemaining, gtiDuration, ownRemaining, ownDuration)
|
||||||
|
-- FIX 2026-07-15: mirror resolveRemaining -- trust GTI only while it reports the
|
||||||
|
-- slot active WITH positive time left; otherwise fall back to own tracking (a
|
||||||
|
-- stale-active GTI slot must not block the timer, same Fire/Earth bug).
|
||||||
|
if gtiActive and gtiRemaining and gtiRemaining > 0 then
|
||||||
|
return gtiDuration
|
||||||
|
end
|
||||||
|
if ownRemaining and ownRemaining > 0 then
|
||||||
|
return ownDuration
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Ripple wave phase: seconds since the last pulse mapped to 0..1 over the
|
||||||
|
-- wave's lifetime, or nil when no wave is showing. For tick totems the wave
|
||||||
|
-- starts AT each pulse (phase wraps via pulseRatio); waveDur is clamped to
|
||||||
|
-- half the interval so the wave always dies before the next one spawns.
|
||||||
|
function TotemBar.waveFrac(ratio, interval, waveDur)
|
||||||
|
if not ratio or not interval or interval <= 0 or not waveDur or waveDur <= 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local dur = waveDur
|
||||||
|
if dur > interval * 0.5 then
|
||||||
|
dur = interval * 0.5
|
||||||
|
end
|
||||||
|
local age = ratio * interval
|
||||||
|
if age > dur then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return age / dur
|
||||||
|
end
|
||||||
|
|
||||||
|
-- One-shot (Fire Nova): wave for waveDur seconds AFTER detonation.
|
||||||
|
function TotemBar.oneshotWaveFrac(placedAt, delay, now, waveDur)
|
||||||
|
if not placedAt or not delay or not now or not waveDur or waveDur <= 0 then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local age = (now - placedAt) - delay
|
||||||
|
if age < 0 or age > waveDur then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return age / waveDur
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Anticipation glow: eased 0..1 ramp over the last (1 - rampStart) fraction
|
||||||
|
-- of the pulse phase; nil below rampStart (no glow) or on bad inputs.
|
||||||
|
function TotemBar.glowRamp(ratio, rampStart)
|
||||||
|
if not ratio or not rampStart or rampStart >= 1 or ratio < rampStart then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local t = (ratio - rampStart) / (1 - rampStart)
|
||||||
|
if t > 1 then
|
||||||
|
t = 1
|
||||||
|
end
|
||||||
|
return t * t
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Traffic-light color for a remaining-time fraction (1 = full, 0 = empty):
|
||||||
|
-- green above 0.5, blending green->yellow->orange->red as time runs out.
|
||||||
|
-- Returns r, g, b as three numbers (no table - called per tick).
|
||||||
|
function TotemBar.timeColor(frac)
|
||||||
|
if not frac then
|
||||||
|
return 1, 1, 1
|
||||||
|
end
|
||||||
|
if frac < 0 then frac = 0 end
|
||||||
|
if frac > 1 then frac = 1 end
|
||||||
|
if frac >= 0.5 then
|
||||||
|
-- green (0.25,0.8,0.25) -> yellow (0.95,0.85,0.2) over 1.0..0.5
|
||||||
|
local t = (1 - frac) * 2
|
||||||
|
return 0.25 + 0.70 * t, 0.80 + 0.05 * t, 0.25 - 0.05 * t
|
||||||
|
elseif frac >= 0.25 then
|
||||||
|
-- yellow -> orange (1.0,0.55,0.1) over 0.5..0.25
|
||||||
|
local t = (0.5 - frac) * 4
|
||||||
|
return 0.95 + 0.05 * t, 0.85 - 0.30 * t, 0.20 - 0.10 * t
|
||||||
|
end
|
||||||
|
-- orange -> red (0.90,0.15,0.10) over 0.25..0
|
||||||
|
local t = (0.25 - frac) * 4
|
||||||
|
return 1.00 - 0.10 * t, 0.55 - 0.40 * t, 0.10
|
||||||
|
end
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
-- TotemBar - core/pulsecal.lua
|
||||||
|
-- Dev-only pulse-calibration telemetry (/tb pulsecal): captures raw
|
||||||
|
-- totem-related combat lines + our own placements into a fixed ring buffer
|
||||||
|
-- and exports them via SuperWoW ExportFile, so the dev environment can
|
||||||
|
-- measure REAL TurtleWoW pulse intervals offline and bake them back into
|
||||||
|
-- core/pulsedata.lua (verified=true).
|
||||||
|
-- Pure buffer/serializer on top (offline-tested); WoW-gated capture below.
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
TotemBar.PULSECAL_CAP = 2000
|
||||||
|
|
||||||
|
-- Dev-only diagnostic probe (/tb tprobe) for the destroyed-totem GUID-latch
|
||||||
|
-- paths in core/cast.lua ("Destroyed-totem detection (GUID liveness)"). Built
|
||||||
|
-- because two in-game measurement attempts against those latch paths were
|
||||||
|
-- both silently wiped by a /reload before their taps could be read (see
|
||||||
|
-- 2026-08-19/20 session) -- unlike pulsecal above (start, drop totems, dump
|
||||||
|
-- once by hand), this probe survives /reload on its own: it re-exports its
|
||||||
|
-- WHOLE ring buffer after every kept event (not only on demand) and re-arms
|
||||||
|
-- itself from TotemBarDB.tprobeArmed at ADDON_LOADED (see the WoW-gated
|
||||||
|
-- section below). Shares pulsecalPush/pulsecalFormat above for the ring
|
||||||
|
-- buffer itself -- only the filter and the per-line detail string are new.
|
||||||
|
TotemBar.TPROBE_CAP = 300
|
||||||
|
|
||||||
|
-- Pure: push one record into the ring buffer. state = { n = total pushed,
|
||||||
|
-- idx = next write slot 1..cap }. Record tables are REUSED on wrap (no
|
||||||
|
-- allocation growth while capturing).
|
||||||
|
function TotemBar.pulsecalPush(buf, cap, state, t, ev, msg)
|
||||||
|
local slot = state.idx
|
||||||
|
local rec = buf[slot]
|
||||||
|
if not rec then
|
||||||
|
rec = {}
|
||||||
|
buf[slot] = rec
|
||||||
|
end
|
||||||
|
rec.t = t
|
||||||
|
rec.e = ev
|
||||||
|
rec.m = msg
|
||||||
|
state.n = state.n + 1
|
||||||
|
state.idx = slot + 1
|
||||||
|
if state.idx > cap then
|
||||||
|
state.idx = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure: serialize surviving records in chronological order, one
|
||||||
|
-- "t;event;msg" line each (t with millisecond precision). Allocates - dump
|
||||||
|
-- time only, never per capture.
|
||||||
|
function TotemBar.pulsecalFormat(buf, cap, state)
|
||||||
|
local count = state.n
|
||||||
|
if count > cap then
|
||||||
|
count = cap
|
||||||
|
end
|
||||||
|
if count == 0 then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
local start = state.idx - count
|
||||||
|
if start < 1 then
|
||||||
|
start = start + cap
|
||||||
|
end
|
||||||
|
local lines = {}
|
||||||
|
for i = 0, count - 1 do
|
||||||
|
local slot = start + i
|
||||||
|
if slot > cap then
|
||||||
|
slot = slot - cap
|
||||||
|
end
|
||||||
|
local rec = buf[slot]
|
||||||
|
lines[i + 1] = string.format("%.3f;%s;%s", rec.t, rec.e, rec.m)
|
||||||
|
end
|
||||||
|
return table.concat(lines, "\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ===== /tb tprobe -- pure filter + line-builder (offline-tested) =====
|
||||||
|
|
||||||
|
-- Pure: does `name` look like a totem to a plain substring eye -- "Totem" or
|
||||||
|
-- "Searing" (the latter because Searing Totem's own pulse-cast spell is
|
||||||
|
-- suspected to be named "Searing Bolt"/"Attack" rather than "Searing Totem",
|
||||||
|
-- suspicion (a) in the probe's own brief -- a name check that only looked
|
||||||
|
-- for "Totem" would silently drop exactly the lines needed to confirm or
|
||||||
|
-- kill that suspicion). Case-sensitive plain substring, same convention the
|
||||||
|
-- WoW-gated capture below (and pulsecal's own filter above) already uses --
|
||||||
|
-- not a design decision unique to this function. nil-safe.
|
||||||
|
function TotemBar.tprobeNameHit(name)
|
||||||
|
if not name then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
if string.find(name, "Totem", 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if string.find(name, "Searing", 1, true) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Events always kept regardless of name (a death is diagnostic on its own --
|
||||||
|
-- there is no "name" to filter on for CHAT_MSG_COMBAT_*_DEATH, and UNIT_DIED
|
||||||
|
-- firing for something that never matched a totem name is itself evidence,
|
||||||
|
-- e.g. against suspicion (b)/(c) in the probe's brief).
|
||||||
|
local TPROBE_ALWAYS_EVENTS = {
|
||||||
|
UNIT_DIED = true,
|
||||||
|
CHAT_MSG_COMBAT_FRIENDLY_DEATH = true,
|
||||||
|
CHAT_MSG_COMBAT_HOSTILE_DEATH = true,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Pure: should this event be kept? nameUnit is UnitName(arg1) as resolved by
|
||||||
|
-- the caller, nameSpell is SpellInfo(arg4) -- kept if EITHER hits
|
||||||
|
-- tprobeNameHit, or if the event is one of TPROBE_ALWAYS_EVENTS above.
|
||||||
|
function TotemBar.tprobeShouldKeep(event, nameUnit, nameSpell)
|
||||||
|
if event and TPROBE_ALWAYS_EVENTS[event] then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if TotemBar.tprobeNameHit(nameUnit) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if TotemBar.tprobeNameHit(nameSpell) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure: the "msg" half of one kept line (the ring buffer's "t;event;msg"
|
||||||
|
-- shape from pulsecalPush/pulsecalFormat above supplies t and event already).
|
||||||
|
-- nameUnit and nameSpell are BOTH kept, deliberately never collapsed into a
|
||||||
|
-- single "resolved name" -- the whole point of the probe is to see whether
|
||||||
|
-- they AGREE (suspicion (a): does a Searing Totem pulse's SpellInfo name
|
||||||
|
-- ever equal "Searing Totem" at all?). Every argument may be nil; tostring()
|
||||||
|
-- turns that into the literal string "nil", never a concat error.
|
||||||
|
function TotemBar.tprobeBuildDetail(a1, a2, a3, a4, nameUnit, nameSpell, ownerName, health, latched)
|
||||||
|
return "a1=" .. tostring(a1)
|
||||||
|
.. ";a2=" .. tostring(a2)
|
||||||
|
.. ";a3=" .. tostring(a3)
|
||||||
|
.. ";a4=" .. tostring(a4)
|
||||||
|
.. ";nameUnit=" .. tostring(nameUnit)
|
||||||
|
.. ";nameSpell=" .. tostring(nameSpell)
|
||||||
|
.. ";owner=" .. tostring(ownerName)
|
||||||
|
.. ";hp=" .. tostring(health)
|
||||||
|
.. ";latched=" .. tostring(latched)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Pure: would the PRODUCTIVE handler (core/cast.lua's guidFrame) have
|
||||||
|
-- latched a GUID for this event's totem, and what does that element's
|
||||||
|
-- record show right now? nameUnit is tried first (arg1's own UnitName --
|
||||||
|
-- the identity the productive UNIT_MODEL_CHANGED path itself keys off),
|
||||||
|
-- nameSpell as a fallback (a totem that only ever surfaces through a SPELL
|
||||||
|
-- name elementOfFn doesn't recognise -- e.g. "Searing Bolt" -- correctly
|
||||||
|
-- resolves to nil here, which is itself the diagnostic: the productive
|
||||||
|
-- UNIT_CASTEVENT path (core/cast.lua's TotemBar.elementFromCastName) has
|
||||||
|
-- exactly the same blind spot). activeTotems/elementOfFn are injected (same
|
||||||
|
-- dependency-injection convention core/cast.lua's own TotemBar.elementForGuid
|
||||||
|
-- uses) so this needs no WoW globals to test. Returns nil both when no
|
||||||
|
-- element matches AND when the matched element's record has no guid yet --
|
||||||
|
-- either way there is no guid to report, matching the brief's own
|
||||||
|
-- "latched=<guid|nil>" literally.
|
||||||
|
function TotemBar.tprobeLatchedGuid(activeTotems, elementOfFn, nameUnit, nameSpell)
|
||||||
|
if not elementOfFn then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local element = elementOfFn(nameUnit) or elementOfFn(nameSpell)
|
||||||
|
if not element or not activeTotems then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local rec = activeTotems[element]
|
||||||
|
if not rec then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return rec.guid
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- WoW-gated capture (skipped entirely by the offline test runner).
|
||||||
|
if CreateFrame then
|
||||||
|
local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
|
||||||
|
|
||||||
|
local capturing = false
|
||||||
|
local buf = {}
|
||||||
|
local state = { n = 0, idx = 1 }
|
||||||
|
|
||||||
|
-- Everything that could plausibly carry a totem pulse on 1.12 (no
|
||||||
|
-- COMBAT_LOG_EVENT_UNFILTERED here - all localized text in arg1).
|
||||||
|
local CAPTURE_EVENTS = {
|
||||||
|
"CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS",
|
||||||
|
"CHAT_MSG_SPELL_PERIODIC_CREATURE_BUFFS",
|
||||||
|
"CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS",
|
||||||
|
"CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE",
|
||||||
|
"CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE",
|
||||||
|
"CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE",
|
||||||
|
"CHAT_MSG_SPELL_SELF_DAMAGE",
|
||||||
|
"CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE",
|
||||||
|
"CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS",
|
||||||
|
"CHAT_MSG_SPELL_AURA_GONE_SELF",
|
||||||
|
"CHAT_MSG_SPELL_AURA_GONE_PARTY",
|
||||||
|
}
|
||||||
|
|
||||||
|
local calFrame = CreateFrame("Frame", "TotemBarPulseCalFrame", UIParent)
|
||||||
|
calFrame:SetScript("OnEvent", function()
|
||||||
|
if not capturing then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if event == "UNIT_CASTEVENT" then
|
||||||
|
-- SuperWoW: casterGUID, targetGUID, type, spellId, castTime.
|
||||||
|
-- Only record casts whose spell name mentions Totem (SpellInfo
|
||||||
|
-- is a SuperWoW API; guarded because the event only exists there
|
||||||
|
-- anyway). arg4 is guarded too (adversarial review, 2026-08-19
|
||||||
|
-- destroyed-totem session) -- SpellInfo(nil) is unproven safe on
|
||||||
|
-- this client, and a UNIT_CASTEVENT with no spellId is possible
|
||||||
|
-- in principle (e.g. a non-cast event type on this arg3 slot).
|
||||||
|
local sname = (SpellInfo and arg4) and SpellInfo(arg4)
|
||||||
|
if sname and string.find(sname, "Totem", 1, true) then
|
||||||
|
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event,
|
||||||
|
tostring(arg1) .. ";" .. tostring(arg3) .. ";" .. tostring(sname))
|
||||||
|
end
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Plain substring guard keeps the hot path cheap; "Totem"-less buff
|
||||||
|
-- names (Mana Spring / Healing Stream gain lines) are the exception,
|
||||||
|
-- so include the "You gain" prefix too.
|
||||||
|
local msg = arg1
|
||||||
|
if msg and (string.find(msg, "Totem", 1, true) or string.find(msg, "You gain", 1, true)) then
|
||||||
|
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(), event, msg)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Record our own placements as reference marks (t0 for interval math).
|
||||||
|
-- Wrapping recordCast is safe here: core/pulsecal.lua loads AFTER
|
||||||
|
-- core/cast.lua (see .toc) and ui.lua resolves TotemBar.recordCast at
|
||||||
|
-- call time.
|
||||||
|
local origRecordCast = TotemBar.recordCast
|
||||||
|
TotemBar.recordCast = function(element, totemName)
|
||||||
|
origRecordCast(element, totemName)
|
||||||
|
if capturing then
|
||||||
|
TotemBar.pulsecalPush(buf, TotemBar.PULSECAL_CAP, state, GetTime(),
|
||||||
|
"TB_PLACED", tostring(element) .. ";" .. tostring(totemName))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TotemBar.PulseCal(sub)
|
||||||
|
if sub == "start" then
|
||||||
|
if not capturing then
|
||||||
|
capturing = true
|
||||||
|
for i = 1, table.getn(CAPTURE_EVENTS) do
|
||||||
|
calFrame:RegisterEvent(CAPTURE_EVENTS[i])
|
||||||
|
end
|
||||||
|
-- SuperWoW-only event; pcall-guarded in case a client build
|
||||||
|
-- rejects unknown event names.
|
||||||
|
pcall(function() calFrame:RegisterEvent("UNIT_CASTEVENT") end)
|
||||||
|
end
|
||||||
|
ChatOut:AddMessage("TotemBar: pulsecal capture STARTED (drop totems, then /tb pulsecal dump).")
|
||||||
|
elseif sub == "stop" then
|
||||||
|
capturing = false
|
||||||
|
calFrame:UnregisterAllEvents()
|
||||||
|
ChatOut:AddMessage("TotemBar: pulsecal capture stopped (" .. state.n .. " records kept).")
|
||||||
|
elseif sub == "dump" then
|
||||||
|
if not ExportFile then
|
||||||
|
ChatOut:AddMessage("TotemBar: pulsecal dump needs SuperWoW (ExportFile missing).")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- Filename WITHOUT extension - the client appends .txt itself.
|
||||||
|
ExportFile("totembar_pulsecal", TotemBar.pulsecalFormat(buf, TotemBar.PULSECAL_CAP, state))
|
||||||
|
ChatOut:AddMessage("TotemBar: pulsecal dump written (imports\\totembar_pulsecal.txt, "
|
||||||
|
.. state.n .. " records).")
|
||||||
|
else
|
||||||
|
local on = capturing and "ON" or "OFF"
|
||||||
|
ChatOut:AddMessage("TotemBar: pulsecal " .. on .. ", " .. state.n
|
||||||
|
.. " records. Usage: /tb pulsecal start|stop|dump")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ===== /tb tprobe -- reload-survivable diagnostic probe =====
|
||||||
|
-- Pure observation only: never touches TotemBar.activeTotems or any
|
||||||
|
-- other productive state, only READS TotemBar.activeTotems/elementOf to
|
||||||
|
-- report what the productive handler would have seen (see
|
||||||
|
-- tprobeLatchedGuid above).
|
||||||
|
local tprobeCapturing = false
|
||||||
|
local tprobeBuf = {}
|
||||||
|
local tprobeState = { n = 0, idx = 1 }
|
||||||
|
|
||||||
|
local function tprobeExport()
|
||||||
|
if ExportFile then
|
||||||
|
-- Filename WITHOUT extension - the client appends .txt itself.
|
||||||
|
ExportFile("tb_tprobe", TotemBar.pulsecalFormat(tprobeBuf, TotemBar.TPROBE_CAP, tprobeState))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Takes explicit args (not the WoW globals directly) so the pcall below
|
||||||
|
-- wraps a real function value, not a closure built fresh every event.
|
||||||
|
local function tprobeHandleEvent(ev, a1, a2, a3, a4)
|
||||||
|
local nameUnit = nil
|
||||||
|
if a1 and type(UnitName) == "function" then
|
||||||
|
nameUnit = UnitName(a1)
|
||||||
|
end
|
||||||
|
local nameSpell = nil
|
||||||
|
if a4 and type(SpellInfo) == "function" then
|
||||||
|
nameSpell = SpellInfo(a4)
|
||||||
|
end
|
||||||
|
if not TotemBar.tprobeShouldKeep(ev, nameUnit, nameSpell) then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local ownerName = nil
|
||||||
|
if a1 and type(UnitName) == "function" then
|
||||||
|
ownerName = UnitName(a1 .. "owner")
|
||||||
|
end
|
||||||
|
local health = nil
|
||||||
|
if a1 and type(UnitHealth) == "function" then
|
||||||
|
health = UnitHealth(a1)
|
||||||
|
end
|
||||||
|
local latched = TotemBar.tprobeLatchedGuid(TotemBar.activeTotems, TotemBar.elementOf, nameUnit, nameSpell)
|
||||||
|
local detail = TotemBar.tprobeBuildDetail(a1, a2, a3, a4, nameUnit, nameSpell, ownerName, health, latched)
|
||||||
|
TotemBar.pulsecalPush(tprobeBuf, TotemBar.TPROBE_CAP, tprobeState, GetTime(), ev, detail)
|
||||||
|
-- Export on EVERY kept event, not only on /tb tprobe dump - the
|
||||||
|
-- whole reason this probe exists is that two earlier measurement
|
||||||
|
-- attempts were wiped by a /reload before a manual dump could run.
|
||||||
|
tprobeExport()
|
||||||
|
end
|
||||||
|
|
||||||
|
local tprobeFrame = CreateFrame("Frame", "TotemBarTProbeFrame", UIParent)
|
||||||
|
tprobeFrame:SetScript("OnEvent", function()
|
||||||
|
if not tprobeCapturing then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- pcall-wrapped end to end (per spec): observation must never be
|
||||||
|
-- able to break anything else this client does.
|
||||||
|
pcall(tprobeHandleEvent, event, arg1, arg2, arg3, arg4)
|
||||||
|
end)
|
||||||
|
|
||||||
|
local function tprobeArm()
|
||||||
|
if tprobeCapturing then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
tprobeCapturing = true
|
||||||
|
-- Native 1.12 events, safe to register directly.
|
||||||
|
tprobeFrame:RegisterEvent("UNIT_MODEL_CHANGED")
|
||||||
|
tprobeFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH")
|
||||||
|
tprobeFrame:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH")
|
||||||
|
-- SuperWoW/nampower-only events; pcall-guarded exactly like
|
||||||
|
-- calFrame's own UNIT_CASTEVENT registration above.
|
||||||
|
pcall(function() tprobeFrame:RegisterEvent("UNIT_CASTEVENT") end)
|
||||||
|
pcall(function() tprobeFrame:RegisterEvent("UNIT_DIED") end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function tprobeDisarm()
|
||||||
|
tprobeCapturing = false
|
||||||
|
tprobeFrame:UnregisterAllEvents()
|
||||||
|
end
|
||||||
|
|
||||||
|
function TotemBar.TProbe(sub)
|
||||||
|
if sub == "on" then
|
||||||
|
tprobeArm()
|
||||||
|
if TotemBarDB then
|
||||||
|
TotemBarDB.tprobeArmed = true
|
||||||
|
end
|
||||||
|
ChatOut:AddMessage("TotemBar: tprobe capture STARTED (" .. tprobeState.n .. " records kept so far).")
|
||||||
|
elseif sub == "off" then
|
||||||
|
tprobeDisarm()
|
||||||
|
if TotemBarDB then
|
||||||
|
TotemBarDB.tprobeArmed = false
|
||||||
|
end
|
||||||
|
ChatOut:AddMessage("TotemBar: tprobe capture stopped (" .. tprobeState.n .. " records kept).")
|
||||||
|
elseif sub == "dump" then
|
||||||
|
if not ExportFile then
|
||||||
|
ChatOut:AddMessage("TotemBar: tprobe dump needs SuperWoW (ExportFile missing).")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
tprobeExport()
|
||||||
|
ChatOut:AddMessage("TotemBar: tprobe dump written (imports\\tb_tprobe.txt, "
|
||||||
|
.. tprobeState.n .. " records).")
|
||||||
|
else
|
||||||
|
local on = tprobeCapturing and "ON" or "OFF"
|
||||||
|
ChatOut:AddMessage("TotemBar: tprobe " .. on .. ", " .. tprobeState.n
|
||||||
|
.. " records. Usage: /tb tprobe on|off|dump|status")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Re-arm after /reload if it was left on. SavedVariables survive a
|
||||||
|
-- /reload; this frame's event registrations do not (a fresh Lua state
|
||||||
|
-- runs on every reload) - so an on->reload->still-on session needs its
|
||||||
|
-- own ADDON_LOADED hook to re-attach tprobeFrame's events, same "wait for
|
||||||
|
-- ADDON_LOADED before trusting TotemBarDB" convention core/config.lua's
|
||||||
|
-- ensureDefaults (called from ui.lua's own ADDON_LOADED handler) and
|
||||||
|
-- minimap.lua's build-on-load both already use, rather than assuming
|
||||||
|
-- load order against either of those.
|
||||||
|
local tprobeInitFrame = CreateFrame("Frame", "TotemBarTProbeInitFrame", UIParent)
|
||||||
|
tprobeInitFrame:RegisterEvent("ADDON_LOADED")
|
||||||
|
tprobeInitFrame:SetScript("OnEvent", function()
|
||||||
|
if event == "ADDON_LOADED" and arg1 == "TotemBar" then
|
||||||
|
tprobeInitFrame:UnregisterEvent("ADDON_LOADED")
|
||||||
|
if TotemBarDB and TotemBarDB.tprobeArmed then
|
||||||
|
tprobeArm()
|
||||||
|
ChatOut:AddMessage("TotemBar: tprobe re-armed after reload (" .. tprobeState.n .. " records kept).")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
-- TotemBar - core/pulsedata.lua
|
||||||
|
-- Pulse metadata per totem name (PURE data, no WoW API). Drives the pulse
|
||||||
|
-- progress bar in ui.lua. Totems absent from PULSE_DATA get NO pulse bar
|
||||||
|
-- (better no info than invented info) - notably Searing Totem (irregular
|
||||||
|
-- attack cadence, not a fixed pulse), Grounding (event-consumed) and every
|
||||||
|
-- aura totem (continuous aura, no verified pulse mechanic).
|
||||||
|
--
|
||||||
|
-- Values are server-dump-derived (TWoW server dump, snapshot 2024-07,
|
||||||
|
-- patch_1172 branch; tw_world.sql + SpellAuras.cpp:8647-8714), not book
|
||||||
|
-- values - source="server" records that provenance. verified=false stays
|
||||||
|
-- reserved for IN-GAME confirmation: /tb pulsecal (core/pulsecal.lua)
|
||||||
|
-- captures the real timings; once measured in-game, update the value AND
|
||||||
|
-- flip verified=true.
|
||||||
|
--
|
||||||
|
-- firstTick: "immediate" means the aura's periodic tick fires at t=0 (the
|
||||||
|
-- totem is on SpellAuras.cpp's CalculatePeriodic exception list);
|
||||||
|
-- "delayed" means the first tick lands only after one full amplitude has
|
||||||
|
-- elapsed. NOTE: the pulseRatio math is identical either way - the bar
|
||||||
|
-- reaches 1.0 at every tick instant, including the t=0 wrap for
|
||||||
|
-- "immediate" totems - so firstTick is currently documentative only (no
|
||||||
|
-- consumer branches on it yet). Kept for future use, e.g. a distinct
|
||||||
|
-- wave/flash at t=0 for "immediate" totems.
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
-- ptype "tick" -> repeating pulse every `interval` seconds
|
||||||
|
-- ptype "oneshot" -> single detonation `delay` seconds after placement
|
||||||
|
-- anchor "selfgain" -> phase re-anchors on observed periodic self-gain
|
||||||
|
-- combat messages (see core/pulseparse.lua + ui.lua)
|
||||||
|
TotemBar.PULSE_DATA = {
|
||||||
|
["Magma Totem"] = { ptype = "tick", interval = 2.0, firstTick = "delayed", source = "server", verified = false },
|
||||||
|
-- 4.0s confirmed by server dump - corrects the earlier 3.0s book value.
|
||||||
|
["Tremor Totem"] = { ptype = "tick", interval = 4.0, firstTick = "immediate", source = "server", verified = false },
|
||||||
|
["Earthbind Totem"] = { ptype = "tick", interval = 3.0, firstTick = "immediate", source = "server", verified = false },
|
||||||
|
["Poison Cleansing Totem"] = { ptype = "tick", interval = 5.0, firstTick = "immediate", source = "server", verified = false },
|
||||||
|
["Disease Cleansing Totem"] = { ptype = "tick", interval = 5.0, firstTick = "immediate", source = "server", verified = false },
|
||||||
|
["Healing Stream Totem"] = { ptype = "tick", interval = 2.0, anchor = "selfgain", firstTick = "delayed", source = "server", verified = false },
|
||||||
|
["Mana Spring Totem"] = { ptype = "tick", interval = 2.0, anchor = "selfgain", firstTick = "delayed", source = "server", verified = false },
|
||||||
|
-- server models it as a periodic 4s trigger like Magma, but with the 5s
|
||||||
|
-- totem duration exactly one nova lands at t=4s, so the oneshot model
|
||||||
|
-- is behaviorally equivalent.
|
||||||
|
["Fire Nova Totem"] = { ptype = "oneshot", delay = 4.0, firstTick = "delayed", source = "server", verified = false },
|
||||||
|
-- threat pulse every 2s.
|
||||||
|
["Stoneclaw Totem"] = { ptype = "tick", interval = 2.0, firstTick = "immediate", source = "server", verified = false },
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Periodic gain lines name the BUFF ("You gain 10 Mana from Mana Spring."),
|
||||||
|
-- not the totem spell - map those source names back to the totem. Exact
|
||||||
|
-- wording gets pinned by pulsecal telemetry; extend as measured.
|
||||||
|
TotemBar.PULSE_SOURCE_ALIASES = {
|
||||||
|
["Mana Spring"] = "Mana Spring Totem",
|
||||||
|
["Healing Stream"] = "Healing Stream Totem",
|
||||||
|
}
|
||||||
|
|
||||||
|
function TotemBar.pulseInfo(totemName)
|
||||||
|
if not totemName then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return TotemBar.PULSE_DATA[totemName]
|
||||||
|
end
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- TotemBar - core/pulseparse.lua
|
||||||
|
-- PURE parser: periodic combat-log line -> totem name (or nil). Used by
|
||||||
|
-- ui.lua's CHAT_MSG_SPELL_PERIODIC_* handler to re-anchor pulse phase on
|
||||||
|
-- REAL observed ticks. Lua 5.0: string.find with captures only (no
|
||||||
|
-- string.match / no method-call syntax). Offline-tested via
|
||||||
|
-- tools/luatests/test_pulseparse.lua; exact accepted wordings get refined
|
||||||
|
-- from /tb pulsecal telemetry.
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
-- Strips a trailing roman-numeral rank suffix (" IV", " VII", ...) - the
|
||||||
|
-- totem-as-caster shape names the ranked unit ("Healing Stream Totem VII").
|
||||||
|
local function stripRank(src)
|
||||||
|
local _, _, base = string.find(src, "^(.-)%s+[IVXLC]+$")
|
||||||
|
if base and base ~= "" then
|
||||||
|
return base
|
||||||
|
end
|
||||||
|
return src
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Maps a raw source name to a totem name: alias table first (buff names
|
||||||
|
-- like "Mana Spring"), then any source ending in " Totem".
|
||||||
|
local function resolveSource(src)
|
||||||
|
local alias = TotemBar.PULSE_SOURCE_ALIASES and TotemBar.PULSE_SOURCE_ALIASES[src]
|
||||||
|
if alias then
|
||||||
|
return alias
|
||||||
|
end
|
||||||
|
-- Every totem's name ends in " Totem" (rank suffixes already stripped),
|
||||||
|
-- so anchor at the end - a plain substring test would false-positive on
|
||||||
|
-- player names like "Totemguy".
|
||||||
|
if string.find(src, "%sTotem$") then
|
||||||
|
return src
|
||||||
|
end
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function TotemBar.parseSelfGain(msg)
|
||||||
|
if not msg then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local _, _, src = string.find(msg, "^You gain %d+ [Mm]ana from (.+)%.$")
|
||||||
|
if not src then
|
||||||
|
_, _, src = string.find(msg, "^You gain %d+ [Hh]ealth from (.+)%.$")
|
||||||
|
end
|
||||||
|
if not src then
|
||||||
|
_, _, src = string.find(msg, "^(.-) heals you for %d+%.$")
|
||||||
|
end
|
||||||
|
if not src then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return resolveSource(stripRank(src))
|
||||||
|
end
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
-- TotemBar - core/spellindex.lua
|
||||||
|
-- A single cached name -> spellbook-slot index, replacing the four
|
||||||
|
-- separate linear GetSpellName(i, BOOKTYPE_SPELL) scans that used to be
|
||||||
|
-- duplicated across ui.lua (FindSpellIndexByName), core/cast.lua
|
||||||
|
-- (findSpellIndexByName), core/manacost.lua (findHighestRankSlot) and
|
||||||
|
-- core/known.lua (scanSpellbook/highestKnownRank). Each of those walked
|
||||||
|
-- the WHOLE spellbook on every call; SPELL_UPDATE_COOLDOWN/cast events
|
||||||
|
-- fire several of them per totem drop, so a 4-totem-drop macro press
|
||||||
|
-- could re-walk the book several times in one frame (perf review
|
||||||
|
-- 2026-07-16, TotemBar finding #14: ui.lua:210 + cast.lua:175 +
|
||||||
|
-- manacost.lua:116).
|
||||||
|
--
|
||||||
|
-- buildSpellIndex() below is PURE (no WoW API) and offline-tested: given
|
||||||
|
-- a flat array of {name=, rank=} entries in spellbook slot order (array
|
||||||
|
-- index N == what GetSpellName(N, BOOKTYPE_SPELL) returned for slot N),
|
||||||
|
-- it returns a name -> record table where each record is:
|
||||||
|
-- bookIndex - the FIRST (lowest) slot with this name. Mirrors the
|
||||||
|
-- old FindSpellIndexByName/findSpellIndexByName
|
||||||
|
-- first-match behavior (icon/cooldown/"is it known"
|
||||||
|
-- lookups don't care which rank's slot they read --
|
||||||
|
-- GetSpellTexture/GetSpellCooldown are identical across
|
||||||
|
-- ranks of the same totem).
|
||||||
|
-- maxRankIndex - the slot of the HIGHEST parsed rank. Mirrors the old
|
||||||
|
-- manacost.lua findHighestRankSlot: this is the slot
|
||||||
|
-- CastSpellByName actually casts and the one
|
||||||
|
-- tooltip/mana-cost scans must read. Falls back to the
|
||||||
|
-- LAST matching slot if no entry's rank string for this
|
||||||
|
-- name ever parsed a number (matches the old
|
||||||
|
-- fallback -- ranks are listed ascending, so the last
|
||||||
|
-- slot is still the best guess).
|
||||||
|
-- maxRank - the highest parsed rank NUMBER, or nil if none of
|
||||||
|
-- this name's entries carried a parseable rank. Mirrors
|
||||||
|
-- the old known.lua highestKnownRank() return value.
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
function TotemBar.buildSpellIndex(entries)
|
||||||
|
local idx = {}
|
||||||
|
if not entries then
|
||||||
|
return idx
|
||||||
|
end
|
||||||
|
for i = 1, table.getn(entries) do
|
||||||
|
local e = entries[i]
|
||||||
|
local name = e and e.name
|
||||||
|
if name then
|
||||||
|
local rec = idx[name]
|
||||||
|
if not rec then
|
||||||
|
rec = { bookIndex = i, maxRankIndex = i, maxRank = nil }
|
||||||
|
idx[name] = rec
|
||||||
|
end
|
||||||
|
local _, _, numStr = string.find(e.rank or "", "(%d+)")
|
||||||
|
local num = numStr and tonumber(numStr)
|
||||||
|
if num and (not rec.maxRank or num > rec.maxRank) then
|
||||||
|
rec.maxRank = num
|
||||||
|
rec.maxRankIndex = i
|
||||||
|
elseif not rec.maxRank then
|
||||||
|
-- No rank number has parsed for this name in any entry
|
||||||
|
-- seen so far; keep sliding the fallback to the LAST
|
||||||
|
-- matching slot (mirrors the old findHighestRankSlot
|
||||||
|
-- fallback for names whose rank string never parses).
|
||||||
|
rec.maxRankIndex = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return idx
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ===== WoW-API layer (not offline-executed) =====
|
||||||
|
-- Guarded by CreateFrame's presence, same as manacost.lua's own event
|
||||||
|
-- block, so dofile-ing this module under plain Lua (offline tests)
|
||||||
|
-- never errors.
|
||||||
|
|
||||||
|
local cachedEntries = nil -- flat array {name=, rank=}, 1..n, book order
|
||||||
|
local cachedIndex = nil -- name -> {bookIndex, maxRankIndex, maxRank}
|
||||||
|
|
||||||
|
local function scanLiveSpellbook()
|
||||||
|
local entries = {}
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
local name, rank = GetSpellName(i, BOOKTYPE_SPELL)
|
||||||
|
if not name then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
entries[i] = { name = name, rank = rank }
|
||||||
|
i = i + 1
|
||||||
|
end
|
||||||
|
return entries
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ensureBuilt()
|
||||||
|
if cachedIndex then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
cachedEntries = scanLiveSpellbook()
|
||||||
|
cachedIndex = TotemBar.buildSpellIndex(cachedEntries)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Drops the cache; the next accessor call below rebuilds it from a
|
||||||
|
-- fresh scan (lazy -- nothing re-scans until actually asked for). Wired
|
||||||
|
-- to SPELLS_CHANGED below; also safe to call manually.
|
||||||
|
function TotemBar.invalidateSpellIndex()
|
||||||
|
cachedEntries = nil
|
||||||
|
cachedIndex = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Returns the cached name -> record index, building it lazily on first
|
||||||
|
-- access. Lazy-build matters at login/reload: the spellbook
|
||||||
|
-- (GetSpellName/GetSpellTexture) isn't reliably populated as early as
|
||||||
|
-- ADDON_LOADED fires (see ui.lua's own SPELLS_CHANGED comment) -- an
|
||||||
|
-- eager build at file-load time could cache an empty book.
|
||||||
|
function TotemBar.getSpellIndex()
|
||||||
|
ensureBuilt()
|
||||||
|
return cachedIndex
|
||||||
|
end
|
||||||
|
|
||||||
|
-- How many spellbook slots the cached scan holds (0 when the book was empty
|
||||||
|
-- at scan time). Lets a caller tell "this spell is provably NOT known" apart
|
||||||
|
-- from "we have no usable spellbook data yet" -- the book isn't reliably
|
||||||
|
-- populated at login, and the cache is built lazily, so a nil findSpellIndex
|
||||||
|
-- alone can't distinguish the two. Used by core/cast.lua's recordCast.
|
||||||
|
function TotemBar.spellbookEntryCount()
|
||||||
|
ensureBuilt()
|
||||||
|
return table.getn(cachedEntries)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mirrors old FindSpellIndexByName (ui.lua) / findSpellIndexByName
|
||||||
|
-- (cast.lua): first-match spellbook slot, or nil if unknown.
|
||||||
|
function TotemBar.findSpellIndex(name)
|
||||||
|
if not name then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local rec = TotemBar.getSpellIndex()[name]
|
||||||
|
return rec and rec.bookIndex
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mirrors old manacost.lua findHighestRankSlot: the slot of the
|
||||||
|
-- highest-known rank, or nil if unknown.
|
||||||
|
function TotemBar.findHighestRankSlot(name)
|
||||||
|
if not name then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local rec = TotemBar.getSpellIndex()[name]
|
||||||
|
return rec and rec.maxRankIndex
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mirrors old known.lua highestKnownRank: the highest known rank
|
||||||
|
-- NUMBER, or nil if unknown / no rank string ever parsed.
|
||||||
|
function TotemBar.highestKnownRank(name)
|
||||||
|
if not name then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
local rec = TotemBar.getSpellIndex()[name]
|
||||||
|
return rec and rec.maxRank
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Mirrors old known.lua scanSpellbook: flat array of every spellbook
|
||||||
|
-- entry's name, ONE PER SLOT (a multi-rank totem appears once per rank
|
||||||
|
-- it has) -- ShowFlyout's knownTotems() filter and PrintScan's raw dump
|
||||||
|
-- both rely on that shape. The by-name index above intentionally
|
||||||
|
-- collapses ranks together, so this stays a separate view over the same
|
||||||
|
-- cached scan rather than something derived from the index.
|
||||||
|
function TotemBar.scanSpellbook()
|
||||||
|
ensureBuilt()
|
||||||
|
local names = {}
|
||||||
|
for i = 1, table.getn(cachedEntries) do
|
||||||
|
names[i] = cachedEntries[i].name
|
||||||
|
end
|
||||||
|
return names
|
||||||
|
end
|
||||||
|
|
||||||
|
-- SPELLS_CHANGED is the event this codebase already relies on elsewhere
|
||||||
|
-- (manacost.lua's manaCache, ui.lua's icon refresh) for "spellbook
|
||||||
|
-- changed" -- fires on learning a new spell/rank and on talent changes.
|
||||||
|
-- LEARNED_SPELL_IN_TAB was checked and does not exist as a 1.12 client
|
||||||
|
-- event (not in this project's confirmed WoW 1.12 event list; it's a
|
||||||
|
-- later-expansion API) -- SPELLS_CHANGED alone is the correct signal.
|
||||||
|
if CreateFrame then
|
||||||
|
local siEvents = CreateFrame("Frame", "TotemBarSpellIndexEventFrame", UIParent)
|
||||||
|
siEvents:RegisterEvent("SPELLS_CHANGED")
|
||||||
|
siEvents:SetScript("OnEvent", function()
|
||||||
|
if event == "SPELLS_CHANGED" then
|
||||||
|
TotemBar.invalidateSpellIndex()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
-- TotemBar - core/totemdata.lua
|
||||||
|
-- PURE data module (no WoW API calls) - offline-testable.
|
||||||
|
--
|
||||||
|
-- Static element -> totem name map for vanilla shaman totems.
|
||||||
|
--
|
||||||
|
-- NOTE: TurtleWoW may rename or add totems relative to vanilla 1.12.
|
||||||
|
-- Use "/tb scan" in-game to print the player's actual known totem spell
|
||||||
|
-- strings and cross-check them against this map before trusting it.
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
TotemBar.TOTEM_ELEMENTS = { "Fire", "Earth", "Water", "Air" }
|
||||||
|
|
||||||
|
TotemBar.TOTEMS_BY_ELEMENT = {
|
||||||
|
Fire = {
|
||||||
|
"Searing Totem",
|
||||||
|
"Fire Nova Totem",
|
||||||
|
"Magma Totem",
|
||||||
|
"Flametongue Totem",
|
||||||
|
"Frost Resistance Totem",
|
||||||
|
},
|
||||||
|
Earth = {
|
||||||
|
"Earthbind Totem",
|
||||||
|
"Stoneclaw Totem",
|
||||||
|
"Stoneskin Totem",
|
||||||
|
"Strength of Earth Totem",
|
||||||
|
"Tremor Totem",
|
||||||
|
},
|
||||||
|
Water = {
|
||||||
|
"Fire Resistance Totem",
|
||||||
|
"Healing Stream Totem",
|
||||||
|
"Mana Spring Totem",
|
||||||
|
"Poison Cleansing Totem",
|
||||||
|
"Disease Cleansing Totem",
|
||||||
|
"Mana Tide Totem",
|
||||||
|
},
|
||||||
|
Air = {
|
||||||
|
"Grace of Air Totem",
|
||||||
|
"Grounding Totem",
|
||||||
|
"Nature Resistance Totem",
|
||||||
|
"Sentry Totem",
|
||||||
|
"Windfury Totem",
|
||||||
|
"Windwall Totem",
|
||||||
|
"Tranquil Air Totem",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Pure: the largest number of totems any single element offers. ui.lua sizes
|
||||||
|
-- the hover flyout's icon pool from this. Computed from the data on purpose:
|
||||||
|
-- the pool used to be a hard-coded 6, but Air has SEVEN totems, and with the
|
||||||
|
-- Air slot empty the flyout lists ALL known totems of the element (not "all
|
||||||
|
-- minus the chosen default") -- so the 7th, Tranquil Air Totem, was silently
|
||||||
|
-- dropped and could not be cast or made default from the bar.
|
||||||
|
function TotemBar.maxTotemsPerElement()
|
||||||
|
local maxN = 0
|
||||||
|
for i = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
|
||||||
|
local list = TotemBar.TOTEMS_BY_ELEMENT[TotemBar.TOTEM_ELEMENTS[i]]
|
||||||
|
local n = (list and table.getn(list)) or 0
|
||||||
|
if n > maxN then
|
||||||
|
maxN = n
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return maxN
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Build the reverse lookup (totem name -> element) once at load time.
|
||||||
|
local elementByName = {}
|
||||||
|
for elemIdx = 1, table.getn(TotemBar.TOTEM_ELEMENTS) do
|
||||||
|
local element = TotemBar.TOTEM_ELEMENTS[elemIdx]
|
||||||
|
local list = TotemBar.TOTEMS_BY_ELEMENT[element]
|
||||||
|
for totemIdx = 1, table.getn(list) do
|
||||||
|
elementByName[list[totemIdx]] = element
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Returns the element ("Fire"/"Earth"/"Water"/"Air") a totem spell name
|
||||||
|
-- belongs to, or nil if the name isn't in the static map.
|
||||||
|
function TotemBar.elementOf(name)
|
||||||
|
if not name then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
return elementByName[name]
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Totem lifetime durations (seconds), tuned to TurtleWoW server timers
|
||||||
|
-- (mirrors pfUI libtotem's verified values). Keyed by totem spell name;
|
||||||
|
-- any totem not listed here falls back to DEFAULT_TOTEM_DURATION.
|
||||||
|
-- Searing Totem is rank-aware (see SEARING_TOTEM_DURATIONS below) and
|
||||||
|
-- is intentionally NOT listed in this flat table.
|
||||||
|
TotemBar.DEFAULT_TOTEM_DURATION = 120
|
||||||
|
|
||||||
|
-- Cross-checked 2026-07-16 against pfUI's own libtotem.lua duration table
|
||||||
|
-- (Interface\AddOns\pfUI\libs\libtotem.lua, Spell_Fire_SearingTotem entry:
|
||||||
|
-- {[-1]=55,[1]=30,[2]=35,[3]=40,[4]=45,[5]=50,[6]=55}) -- byte-identical to
|
||||||
|
-- the vanilla values below for every rank. TurtleWoW uses vanilla Searing
|
||||||
|
-- Totem timers, not a custom table; nothing was missing here.
|
||||||
|
TotemBar.SEARING_TOTEM_DURATIONS = {
|
||||||
|
[1] = 30,
|
||||||
|
[2] = 35,
|
||||||
|
[3] = 40,
|
||||||
|
[4] = 45,
|
||||||
|
[5] = 50,
|
||||||
|
[6] = 55,
|
||||||
|
}
|
||||||
|
TotemBar.SEARING_TOTEM_MAX_RANK = 6
|
||||||
|
TotemBar.SEARING_TOTEM_DEFAULT_DURATION = 55
|
||||||
|
|
||||||
|
TotemBar.TOTEM_DURATIONS = {
|
||||||
|
-- Fire
|
||||||
|
["Magma Totem"] = 20,
|
||||||
|
["Fire Nova Totem"] = 5,
|
||||||
|
["Flametongue Totem"] = 120,
|
||||||
|
["Frost Resistance Totem"] = 120,
|
||||||
|
-- Earth
|
||||||
|
["Stoneclaw Totem"] = 15,
|
||||||
|
["Earthbind Totem"] = 45,
|
||||||
|
["Stoneskin Totem"] = 120,
|
||||||
|
["Strength of Earth Totem"] = 120,
|
||||||
|
["Tremor Totem"] = 120,
|
||||||
|
-- Water
|
||||||
|
["Mana Tide Totem"] = 12,
|
||||||
|
["Healing Stream Totem"] = 60,
|
||||||
|
["Mana Spring Totem"] = 60,
|
||||||
|
["Fire Resistance Totem"] = 120,
|
||||||
|
["Disease Cleansing Totem"] = 120,
|
||||||
|
["Poison Cleansing Totem"] = 120,
|
||||||
|
-- Air
|
||||||
|
["Grounding Totem"] = 45,
|
||||||
|
["Grace of Air Totem"] = 120,
|
||||||
|
["Nature Resistance Totem"] = 120,
|
||||||
|
-- 5 minutes (spell 6495, TurtleWoW 1.17.2 spell DB). Was missing here
|
||||||
|
-- entirely and fell through to the 120s default -- but that fallback is
|
||||||
|
-- meant as a safe OVERestimate, and for Sentry it cut the timer 180s
|
||||||
|
-- short. pfUI's libtotem has no Sentry entry either, so GetTotemInfo
|
||||||
|
-- could not correct it.
|
||||||
|
["Sentry Totem"] = 300,
|
||||||
|
["Tranquil Air Totem"] = 120,
|
||||||
|
["Windfury Totem"] = 120,
|
||||||
|
["Windwall Totem"] = 120,
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Pure: seconds a totem will remain active for, given its exact spell
|
||||||
|
-- name and (for Searing Totem only) the player's highest known rank
|
||||||
|
-- number. Unknown/unmapped totems fall back to DEFAULT_TOTEM_DURATION
|
||||||
|
-- (a safe overestimate, rather than a timer that disappears too early).
|
||||||
|
function TotemBar.totemDuration(name, highestRank)
|
||||||
|
if not name then
|
||||||
|
return TotemBar.DEFAULT_TOTEM_DURATION
|
||||||
|
end
|
||||||
|
if name == "Searing Totem" then
|
||||||
|
if not highestRank or highestRank < 1 then
|
||||||
|
return TotemBar.SEARING_TOTEM_DEFAULT_DURATION
|
||||||
|
end
|
||||||
|
local rank = highestRank
|
||||||
|
if rank > TotemBar.SEARING_TOTEM_MAX_RANK then
|
||||||
|
rank = TotemBar.SEARING_TOTEM_MAX_RANK
|
||||||
|
end
|
||||||
|
return TotemBar.SEARING_TOTEM_DURATIONS[rank] or TotemBar.SEARING_TOTEM_DEFAULT_DURATION
|
||||||
|
end
|
||||||
|
return TotemBar.TOTEM_DURATIONS[name] or TotemBar.DEFAULT_TOTEM_DURATION
|
||||||
|
end
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
-- TotemBar - minimap.lua
|
||||||
|
-- Hand-rolled minimap button (no LibDBIcon on 1.12). Orbits the minimap at a
|
||||||
|
-- saved angle, drag-repositions (angle persisted), left-click opens the
|
||||||
|
-- options panel, right-click toggles the bar. pfUI-safe: parent = Minimap,
|
||||||
|
-- name contains "Minimap", FrameStrata HIGH + level 9 (MEDIUM hides under
|
||||||
|
-- pfUI). /tb options is the guaranteed access path if pfUI collects/hides
|
||||||
|
-- the button. WoW-API file (parse-checked only).
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
local button = nil
|
||||||
|
|
||||||
|
-- Custom icon sheet (tools/gen_icons.js, same texture ui.lua's
|
||||||
|
-- ICONS_TEXTURE points at): 4x4 grid of 128px cells, cell 7 = minimap
|
||||||
|
-- totem glyph (transparent backing). core/pulse.lua loads before this
|
||||||
|
-- file (see the .toc), so buildRingTexCoords is already available; built
|
||||||
|
-- once here at load, not per-tick.
|
||||||
|
local MINIMAP_ICON_TEXTURE = "Interface\\AddOns\\TotemBar\\textures\\icons"
|
||||||
|
local MINIMAP_ICON_CELL = 7
|
||||||
|
local minimapIconCoords = TotemBar.buildRingTexCoords(16, 4)
|
||||||
|
|
||||||
|
-- Repositions the button on its orbit for the given angle (degrees).
|
||||||
|
local function PlaceButton(angleDeg)
|
||||||
|
if not button then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local radius = (Minimap:GetWidth() / 2) + 5
|
||||||
|
local x, y = TotemBar.angleToOffset(angleDeg, radius)
|
||||||
|
button:ClearAllPoints()
|
||||||
|
button:SetPoint("CENTER", Minimap, "CENTER", x, y)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function BuildMinimapButton()
|
||||||
|
if button then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local btn = CreateFrame("Button", "TotemBarMinimapButton", Minimap)
|
||||||
|
btn:SetWidth(33)
|
||||||
|
btn:SetHeight(33)
|
||||||
|
btn:SetFrameStrata("HIGH")
|
||||||
|
btn:SetFrameLevel(9)
|
||||||
|
|
||||||
|
local icon = btn:CreateTexture(nil, "ARTWORK")
|
||||||
|
icon:SetTexture(MINIMAP_ICON_TEXTURE)
|
||||||
|
local ic = minimapIconCoords[MINIMAP_ICON_CELL]
|
||||||
|
icon:SetTexCoord(ic.l, ic.r, ic.t, ic.b)
|
||||||
|
icon:SetWidth(20)
|
||||||
|
icon:SetHeight(20)
|
||||||
|
icon:SetPoint("CENTER", btn, "CENTER", 0, 0)
|
||||||
|
|
||||||
|
local border = btn:CreateTexture(nil, "OVERLAY")
|
||||||
|
border:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder")
|
||||||
|
-- Vanilla default-UI recipe (MiniMapTrackingButton in FrameXML): a 54px
|
||||||
|
-- border anchored TOPLEFT(0,0) on a 33px button. The ring is deliberately
|
||||||
|
-- off-centre WITHIN the texture, so it lands concentric with the icon at
|
||||||
|
-- this exact anchor/size - do NOT "centre" it (that pushes the ring off).
|
||||||
|
border:SetWidth(54)
|
||||||
|
border:SetHeight(54)
|
||||||
|
border:SetPoint("TOPLEFT", btn, "TOPLEFT", 0, 0)
|
||||||
|
|
||||||
|
btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight")
|
||||||
|
|
||||||
|
btn.angle = TotemBarDB.minimapAngle or 225
|
||||||
|
|
||||||
|
-- Drag (repositions around the orbit). Drag needs the movement threshold,
|
||||||
|
-- so a plain click still fires OnClick; the two coexist (verified 1.12
|
||||||
|
-- pattern).
|
||||||
|
btn:RegisterForDrag("LeftButton")
|
||||||
|
btn.isDragging = false
|
||||||
|
btn:SetScript("OnDragStart", function() this.isDragging = true end)
|
||||||
|
btn:SetScript("OnDragStop", function()
|
||||||
|
this.isDragging = false
|
||||||
|
TotemBarDB.minimapAngle = this.angle
|
||||||
|
end)
|
||||||
|
btn:SetScript("OnUpdate", function()
|
||||||
|
if not this.isDragging then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local mx, my = GetCursorPosition()
|
||||||
|
local scale = Minimap:GetEffectiveScale()
|
||||||
|
mx = mx / scale
|
||||||
|
my = my / scale
|
||||||
|
local cx, cy = Minimap:GetCenter()
|
||||||
|
this.angle = math.deg(math.atan2(my - cy, mx - cx))
|
||||||
|
PlaceButton(this.angle)
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Clicks: left = options, right = toggle bar.
|
||||||
|
btn:RegisterForClicks("LeftButtonUp", "RightButtonUp")
|
||||||
|
btn:SetScript("OnClick", function()
|
||||||
|
if arg1 == "RightButton" then
|
||||||
|
if TotemBar.ToggleBar then TotemBar.ToggleBar() end
|
||||||
|
else
|
||||||
|
if TotemBar.ToggleOptions then TotemBar.ToggleOptions() end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
btn:SetScript("OnEnter", function()
|
||||||
|
GameTooltip:SetOwner(this, "ANCHOR_LEFT")
|
||||||
|
GameTooltip:SetText("TotemBar")
|
||||||
|
GameTooltip:AddLine("Left-click: Options", 0.9, 0.9, 0.9)
|
||||||
|
GameTooltip:AddLine("Right-click: Toggle bar", 0.9, 0.9, 0.9)
|
||||||
|
GameTooltip:AddLine("Drag: reposition", 0.9, 0.9, 0.9)
|
||||||
|
GameTooltip:Show()
|
||||||
|
end)
|
||||||
|
btn:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
||||||
|
|
||||||
|
button = btn
|
||||||
|
PlaceButton(btn.angle)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Build on ADDON_LOADED (SavedVariables are populated by then, same as the
|
||||||
|
-- bar). Guarded; never on PLAYER_ENTERING_WORLD (would re-run and leak
|
||||||
|
-- textures).
|
||||||
|
local ev = CreateFrame("Frame", "TotemBarMinimapEventFrame", UIParent)
|
||||||
|
ev:RegisterEvent("ADDON_LOADED")
|
||||||
|
ev:SetScript("OnEvent", function()
|
||||||
|
if event == "ADDON_LOADED" and arg1 == "TotemBar" then
|
||||||
|
BuildMinimapButton()
|
||||||
|
ev:UnregisterEvent("ADDON_LOADED")
|
||||||
|
end
|
||||||
|
end)
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
-- TotemBar - options.lua
|
||||||
|
-- Standalone options panel (no Blizzard InterfaceOptions integration). Built
|
||||||
|
-- once (lazy) on first ToggleOptions; repopulated from TotemBarDB on show.
|
||||||
|
-- Opened by the minimap button's left-click and by /tb options. WoW-API
|
||||||
|
-- file (parse-checked only). Widget wiring uses 1.12 templates:
|
||||||
|
-- UICheckButtonTemplate (GetChecked() -> 1/nil) and OptionsSliderTemplate
|
||||||
|
-- (SetMinMaxValues before SetValue; $parentLow/$parentHigh/$parentText).
|
||||||
|
|
||||||
|
TotemBar = TotemBar or {}
|
||||||
|
|
||||||
|
local ChatOut = DEFAULT_CHAT_FRAME or ChatFrame1
|
||||||
|
local optionsFrame = nil
|
||||||
|
|
||||||
|
-- Applies pfUI's font to a FontString (the main "matches pfUI" tweak) at the
|
||||||
|
-- given size, or falls back to a small game font when pfUI is absent.
|
||||||
|
local function ApplyFont(fontString, size)
|
||||||
|
if not fontString then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if pfUI and pfUI.font_default then
|
||||||
|
local sz = size or (pfUI_config and pfUI_config.global and pfUI_config.global.font_size) or 12
|
||||||
|
fontString:SetFont(pfUI.font_default, sz)
|
||||||
|
else
|
||||||
|
fontString:SetFontObject(GameFontNormalSmall)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Adds a wrapped explanatory GameTooltip on hover. Chains any existing
|
||||||
|
-- OnEnter/OnLeave (e.g. pfUI's SkinButton hover-highlight) instead of
|
||||||
|
-- replacing it, so skinning still works. No HookScript on 1.12.
|
||||||
|
local function AddTooltip(widget, text)
|
||||||
|
if not widget or not text then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
widget.tbTip = text
|
||||||
|
local oldEnter = widget:GetScript("OnEnter")
|
||||||
|
local oldLeave = widget:GetScript("OnLeave")
|
||||||
|
widget:SetScript("OnEnter", function()
|
||||||
|
if oldEnter then oldEnter() end
|
||||||
|
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
||||||
|
GameTooltip:SetText(this.tbTip, 1, 1, 1, 1, 1)
|
||||||
|
GameTooltip:Show()
|
||||||
|
end)
|
||||||
|
widget:SetScript("OnLeave", function()
|
||||||
|
if oldLeave then oldLeave() end
|
||||||
|
GameTooltip:Hide()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Unique widget-name counters (template child labels resolve via getglobal,
|
||||||
|
-- so every widget needs a non-nil, unique frame name).
|
||||||
|
local cbIndex = 0
|
||||||
|
local slIndex = 0
|
||||||
|
|
||||||
|
-- Factory: a labeled checkbox wired to getter/setter. getter()->boolean,
|
||||||
|
-- setter(boolean). Returns the CheckButton.
|
||||||
|
local function CreateCheckbox(parent, label, getter, setter)
|
||||||
|
cbIndex = cbIndex + 1
|
||||||
|
local name = "TotemBarOptCheck" .. cbIndex
|
||||||
|
local cb = CreateFrame("CheckButton", name, parent, "UICheckButtonTemplate")
|
||||||
|
cb:SetWidth(24)
|
||||||
|
cb:SetHeight(24)
|
||||||
|
local lbl = getglobal(name .. "Text")
|
||||||
|
if lbl then
|
||||||
|
lbl:SetText(label)
|
||||||
|
ApplyFont(lbl)
|
||||||
|
end
|
||||||
|
cb.tbGet = getter
|
||||||
|
cb.tbSet = setter
|
||||||
|
cb:SetScript("OnClick", function()
|
||||||
|
this.tbSet(this:GetChecked() == 1)
|
||||||
|
end)
|
||||||
|
if pfUI and pfUI.api and pfUI.api.SkinCheckbox then
|
||||||
|
pfUI.api.SkinCheckbox(cb)
|
||||||
|
end
|
||||||
|
return cb
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Factory: a labeled slider wired to getter/setter over [minVal,maxVal] in
|
||||||
|
-- `step` increments. `fmt` is a printf pattern for the live value text
|
||||||
|
-- (e.g. "UI size: %.2f"). Returns the Slider.
|
||||||
|
local function CreateSlider(parent, label, minVal, maxVal, step, fmt, getter, setter)
|
||||||
|
slIndex = slIndex + 1
|
||||||
|
local name = "TotemBarOptSlider" .. slIndex
|
||||||
|
local sl = CreateFrame("Slider", name, parent, "OptionsSliderTemplate")
|
||||||
|
sl:SetWidth(200)
|
||||||
|
sl:SetHeight(16)
|
||||||
|
sl:SetMinMaxValues(minVal, maxVal) -- BEFORE SetValue, else clamps to 0
|
||||||
|
sl:SetValueStep(step)
|
||||||
|
local low, high, txt = getglobal(name .. "Low"), getglobal(name .. "High"), getglobal(name .. "Text")
|
||||||
|
if low then low:SetText(tostring(minVal)); ApplyFont(low) end
|
||||||
|
if high then high:SetText(tostring(maxVal)); ApplyFont(high) end
|
||||||
|
if txt then
|
||||||
|
-- Left-align the value label above the slider (the template centers it),
|
||||||
|
-- so it lines up with the left-aligned checkbox labels. pfUI font.
|
||||||
|
txt:ClearAllPoints()
|
||||||
|
txt:SetPoint("BOTTOMLEFT", sl, "TOPLEFT", 0, 2)
|
||||||
|
txt:SetJustifyH("LEFT")
|
||||||
|
ApplyFont(txt)
|
||||||
|
end
|
||||||
|
sl.tbLabel = label
|
||||||
|
sl.tbFmt = fmt
|
||||||
|
sl.tbText = txt
|
||||||
|
sl.tbSet = setter
|
||||||
|
sl.tbGet = getter
|
||||||
|
sl:SetScript("OnValueChanged", function()
|
||||||
|
local v = this:GetValue()
|
||||||
|
if this.tbText then
|
||||||
|
this.tbText:SetText(string.format(this.tbFmt, v))
|
||||||
|
end
|
||||||
|
this.tbSet(v)
|
||||||
|
end)
|
||||||
|
if pfUI and pfUI.api and pfUI.api.SkinSlider then
|
||||||
|
pfUI.api.SkinSlider(sl)
|
||||||
|
end
|
||||||
|
return sl
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Label text for the bar-layout cycle button, reflecting the current choice.
|
||||||
|
local function BarLayoutLabel()
|
||||||
|
return "Layout: " .. (TotemBarDB.barLayout or "1x6")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Factory: a labeled push button wired to an onClick. Returns the Button.
|
||||||
|
-- Named (project convention: name all frames) so the pfDebug profiler can attribute it.
|
||||||
|
local function CreateButton(parent, name, label, onClick)
|
||||||
|
local btn = CreateFrame("Button", name, parent, "UIPanelButtonTemplate")
|
||||||
|
btn:SetWidth(140)
|
||||||
|
btn:SetHeight(22)
|
||||||
|
btn:SetText(label)
|
||||||
|
btn:SetScript("OnClick", onClick)
|
||||||
|
if pfUI and pfUI.api and pfUI.api.SkinButton then
|
||||||
|
pfUI.api.SkinButton(btn)
|
||||||
|
end
|
||||||
|
return btn
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Factory: a static hairline divider spanning the panel's content width at
|
||||||
|
-- cursor position y (Rev 4 UI chrome, section-break decoration). Anonymous
|
||||||
|
-- texture - it's a static decoration, not an interactive/named widget.
|
||||||
|
local function CreateDivider(parent, y)
|
||||||
|
local d = parent:CreateTexture(nil, "ARTWORK")
|
||||||
|
d:SetTexture(TotemBar.UI_TEXTURE)
|
||||||
|
local c = TotemBar.uiCoords[TotemBar.UI_DIVIDER_CELL]
|
||||||
|
d:SetTexCoord(c.l, c.r, c.t, c.b)
|
||||||
|
d:SetPoint("TOPLEFT", parent, "TOPLEFT", 14, y)
|
||||||
|
d:SetPoint("TOPRIGHT", parent, "TOPRIGHT", -14, y)
|
||||||
|
d:SetHeight(7)
|
||||||
|
return d
|
||||||
|
end
|
||||||
|
|
||||||
|
-- All widgets, stored so OnShow can repopulate them from TotemBarDB.
|
||||||
|
local widgets = {}
|
||||||
|
|
||||||
|
local function BuildOptionsFrame()
|
||||||
|
if optionsFrame then
|
||||||
|
return optionsFrame
|
||||||
|
end
|
||||||
|
|
||||||
|
local f = CreateFrame("Frame", "TotemBarOptionsFrame", UIParent)
|
||||||
|
f:SetWidth(280)
|
||||||
|
-- 472 + 44 + 28 + 28 + 44 + 28: room for the header emblem (+16, taller
|
||||||
|
-- than the old title-only header), two section dividers (+14 each), the
|
||||||
|
-- "Show pulse waves" checkbox row (+28, the pulse ring's countdown
|
||||||
|
-- readout sits alongside the ripple), the bar-layout cycle button row
|
||||||
|
-- (+28), the "Button spacing" slider row (+44, same slider-row
|
||||||
|
-- height as guard/gap/scale above it), and the "Show drop-all button"
|
||||||
|
-- checkbox row (+28) - see the layout-cursor deltas below (same
|
||||||
|
-- mechanism as the Task-5 height bump). Not cosmetic: without the last
|
||||||
|
-- +28 the "Key bind mode" button would land on the version footer.
|
||||||
|
f:SetHeight(628)
|
||||||
|
f:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
|
||||||
|
f:SetFrameStrata("DIALOG")
|
||||||
|
-- Rev 4 UI chrome: shared bespoke panel skin (see ui.lua's PANEL_BACKDROP)
|
||||||
|
-- instead of the Blizzard dialog backdrop or pfUI's generic skin, so the
|
||||||
|
-- options panel matches the bar's own art (emblem/dividers below pair
|
||||||
|
-- with this border/bg specifically).
|
||||||
|
f:SetBackdrop(TotemBar.PANEL_BACKDROP)
|
||||||
|
f:SetBackdropColor(1, 1, 1, 0.97)
|
||||||
|
f:SetMovable(true)
|
||||||
|
f:EnableMouse(true)
|
||||||
|
f:RegisterForDrag("LeftButton")
|
||||||
|
f:SetScript("OnDragStart", function() this:StartMoving() end)
|
||||||
|
f:SetScript("OnDragStop", function() this:StopMovingOrSizing() end)
|
||||||
|
f:SetClampedToScreen(true)
|
||||||
|
|
||||||
|
-- Plain gold title, no emblem (the Rev-4 totem glyph was removed on
|
||||||
|
-- user request - the silhouette read, well, unfortunate).
|
||||||
|
local title = f:CreateFontString("TotemBarOptionsTitle", "OVERLAY", "GameFontNormal")
|
||||||
|
title:SetPoint("TOPLEFT", f, "TOPLEFT", 16, -16)
|
||||||
|
title:SetText("TotemBar Options")
|
||||||
|
ApplyFont(title, 14)
|
||||||
|
title:SetTextColor(0.85, 0.66, 0.31)
|
||||||
|
|
||||||
|
local close = CreateFrame("Button", "TotemBarOptionsClose", f, "UIPanelCloseButton")
|
||||||
|
close:SetPoint("TOPRIGHT", f, "TOPRIGHT", -8, -8)
|
||||||
|
if pfUI and pfUI.api and pfUI.api.SkinCloseButton then
|
||||||
|
pfUI.api.SkinCloseButton(close, f)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Layout cursor (back to the title-only header spacing).
|
||||||
|
local x = 24
|
||||||
|
local y = -44
|
||||||
|
local function place(w, dy)
|
||||||
|
w:SetPoint("TOPLEFT", f, "TOPLEFT", x, y)
|
||||||
|
y = y + dy
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Checkboxes.
|
||||||
|
widgets.lock = CreateCheckbox(f, "Lock bar",
|
||||||
|
function() return TotemBarDB.locked end,
|
||||||
|
function(v) TotemBarDB.locked = v end)
|
||||||
|
place(widgets.lock, -28)
|
||||||
|
AddTooltip(widgets.lock, "Locks the bar in place so you can't drag it by accident. Uncheck to reposition it.")
|
||||||
|
|
||||||
|
widgets.autoRecall = CreateCheckbox(f, "Auto-recall before setting",
|
||||||
|
function() return TotemBarDB.autoRecall end,
|
||||||
|
function(v)
|
||||||
|
TotemBarDB.autoRecall = v
|
||||||
|
if TotemBar.RefreshRecallIndicator then TotemBar.RefreshRecallIndicator() end
|
||||||
|
end)
|
||||||
|
place(widgets.autoRecall, -28)
|
||||||
|
AddTooltip(widgets.autoRecall, "When on, the Totems macro casts Totemic Recall before re-dropping your totems (refunds some mana when relocating).")
|
||||||
|
|
||||||
|
widgets.show = CreateCheckbox(f, "Show bar",
|
||||||
|
function() return not TotemBarDB.hidden end,
|
||||||
|
function(v)
|
||||||
|
if v then
|
||||||
|
TotemBarDB.hidden = false
|
||||||
|
if TotemBarFrame then TotemBarFrame:Show() end
|
||||||
|
else
|
||||||
|
TotemBarDB.hidden = true
|
||||||
|
if TotemBarFrame then TotemBarFrame:Hide() end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
place(widgets.show, -28)
|
||||||
|
AddTooltip(widgets.show, "Shows or hides the totem bar. The choice is remembered between sessions.")
|
||||||
|
|
||||||
|
-- Same kind of switch as "Show bar" above (visibility only, not a
|
||||||
|
-- feature toggle), hence the same group. ApplyBarLayout hides/shows the
|
||||||
|
-- button AND re-lays out the grid, so the bar resizes immediately -
|
||||||
|
-- no /reload needed.
|
||||||
|
widgets.dropSet = CreateCheckbox(f, "Show drop-all button",
|
||||||
|
function() return TotemBarDB.showDropSetButton end,
|
||||||
|
function(v)
|
||||||
|
TotemBarDB.showDropSetButton = v
|
||||||
|
if TotemBar.ApplyBarLayout then TotemBar.ApplyBarLayout() end
|
||||||
|
end)
|
||||||
|
place(widgets.dropSet, -40)
|
||||||
|
AddTooltip(widgets.dropSet, "Shows or hides the 'Drop all totems' button on the bar. Its key binding keeps working while it is hidden - rebind it under Key Bindings in the game menu.")
|
||||||
|
|
||||||
|
widgets.ring = CreateCheckbox(f, "Show duration ring",
|
||||||
|
function() return TotemBarDB.showDurationRing end,
|
||||||
|
function(v)
|
||||||
|
TotemBarDB.showDurationRing = v
|
||||||
|
if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
|
||||||
|
end)
|
||||||
|
place(widgets.ring, -28)
|
||||||
|
AddTooltip(widgets.ring, "Shows a colored arc on each button's ring that empties as the totem's remaining time runs out. The ring frame itself always stays visible.")
|
||||||
|
|
||||||
|
widgets.pulse = CreateCheckbox(f, "Show pulse ring",
|
||||||
|
function() return TotemBarDB.showPulseBars end,
|
||||||
|
function(v)
|
||||||
|
TotemBarDB.showPulseBars = v
|
||||||
|
if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
|
||||||
|
end)
|
||||||
|
place(widgets.pulse, -28)
|
||||||
|
AddTooltip(widgets.pulse, "Shows a thin inner ring around the icon that fills up toward the totem's next pulse, then resets.")
|
||||||
|
|
||||||
|
widgets.pulseWaves = CreateCheckbox(f, "Show pulse waves",
|
||||||
|
function() return TotemBarDB.showPulseWaves end,
|
||||||
|
function(v)
|
||||||
|
TotemBarDB.showPulseWaves = v
|
||||||
|
if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
|
||||||
|
end)
|
||||||
|
place(widgets.pulseWaves, -28)
|
||||||
|
AddTooltip(widgets.pulseWaves, "Shows an expanding ripple in the totem's color each time a pulsing totem fires.")
|
||||||
|
|
||||||
|
widgets.timerText = CreateCheckbox(f, "Show countdown text",
|
||||||
|
function() return TotemBarDB.showTimerText end,
|
||||||
|
function(v)
|
||||||
|
TotemBarDB.showTimerText = v
|
||||||
|
if TotemBar.RefreshPulseUI then TotemBar.RefreshPulseUI() end
|
||||||
|
end)
|
||||||
|
place(widgets.timerText, -28)
|
||||||
|
AddTooltip(widgets.timerText, "Shows the remaining-seconds text under each element button (the ring shows the same information graphically).")
|
||||||
|
|
||||||
|
-- Bar-layout cycle button: click steps through TotemBar.BAR_LAYOUTS
|
||||||
|
-- (wrapping), saves the choice, and re-lays out the live bar.
|
||||||
|
widgets.layout = CreateButton(f, "TotemBarOptLayoutButton", BarLayoutLabel(), function()
|
||||||
|
local layouts = TotemBar.BAR_LAYOUTS
|
||||||
|
local n = table.getn(layouts)
|
||||||
|
local current = TotemBarDB.barLayout or "1x6"
|
||||||
|
local currentIdx = 1
|
||||||
|
for i = 1, n do
|
||||||
|
if layouts[i] == current then
|
||||||
|
currentIdx = i
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local nextIdx = math.mod(currentIdx, n) + 1
|
||||||
|
TotemBarDB.barLayout = layouts[nextIdx]
|
||||||
|
this:SetText(BarLayoutLabel())
|
||||||
|
if TotemBar.ApplyBarLayout then
|
||||||
|
TotemBar.ApplyBarLayout()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
place(widgets.layout, -28)
|
||||||
|
AddTooltip(widgets.layout, "Cycles the bar arrangement: one row of six, two rows of three, or three rows of two.")
|
||||||
|
|
||||||
|
-- Section divider (Rev 4 UI chrome): checkboxes above, sliders below.
|
||||||
|
CreateDivider(f, y)
|
||||||
|
y = y - 14
|
||||||
|
|
||||||
|
-- Sliders (leave headroom below each for its low/high/value text).
|
||||||
|
widgets.guard = CreateSlider(f, "Recall guard (sec)", 0, 5, 0.5, "Recall guard: %.1fs",
|
||||||
|
function() return TotemBarDB.recallGuardSeconds end,
|
||||||
|
function(v) TotemBarDB.recallGuardSeconds = v end)
|
||||||
|
place(widgets.guard, -44)
|
||||||
|
AddTooltip(widgets.guard, "A second press of the Totems macro within this many seconds skips Totemic Recall, so a double-press won't pull the totems you just dropped. 0 = no guard.")
|
||||||
|
|
||||||
|
widgets.gap = CreateSlider(f, "Cycle reset gap (sec)", 0.5, 5, 0.5, "Cycle gap: %.1fs",
|
||||||
|
function() return TotemBarDB.gapSeconds end,
|
||||||
|
function(v) TotemBarDB.gapSeconds = v end)
|
||||||
|
place(widgets.gap, -44)
|
||||||
|
AddTooltip(widgets.gap, "How long a pause (in seconds) restarts the one-per-press cast cycle back at the first totem.")
|
||||||
|
|
||||||
|
widgets.scale = CreateSlider(f, "UI size", 0.5, 2.0, 0.05, "UI size: %.2f",
|
||||||
|
function() return TotemBarDB.scale end,
|
||||||
|
function(v)
|
||||||
|
if TotemBar.SetBarScale then
|
||||||
|
TotemBar.SetBarScale(v)
|
||||||
|
elseif TotemBarFrame then
|
||||||
|
TotemBarDB.scale = v
|
||||||
|
TotemBarFrame:SetScale(v)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
place(widgets.scale, -44)
|
||||||
|
AddTooltip(widgets.scale, "Scales the whole bar. It stays anchored at its top-left corner, and the flyout scales with it.")
|
||||||
|
|
||||||
|
widgets.buttonGap = CreateSlider(f, "Button spacing", 10, 30, 1, "Spacing: %.0fpx",
|
||||||
|
function() return TotemBarDB.buttonGap end,
|
||||||
|
function(v)
|
||||||
|
if TotemBar.SetButtonGap then
|
||||||
|
TotemBar.SetButtonGap(v)
|
||||||
|
else
|
||||||
|
TotemBarDB.buttonGap = v
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
place(widgets.buttonGap, -44)
|
||||||
|
AddTooltip(widgets.buttonGap, "Distance between the bar buttons. Applies immediately.")
|
||||||
|
|
||||||
|
-- Section divider (Rev 4 UI chrome): sliders above, action buttons below.
|
||||||
|
CreateDivider(f, y)
|
||||||
|
y = y - 14
|
||||||
|
|
||||||
|
optionsFrame = f
|
||||||
|
-- Buttons (Reset position, Create macro) are added in Task 4.
|
||||||
|
if TotemBar.BuildOptionsButtons then
|
||||||
|
TotemBar.BuildOptionsButtons(f, x, y)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Repopulate every widget from TotemBarDB whenever the panel shows.
|
||||||
|
f:SetScript("OnShow", function()
|
||||||
|
widgets.lock:SetChecked(TotemBarDB.locked and 1 or nil)
|
||||||
|
widgets.autoRecall:SetChecked(TotemBarDB.autoRecall and 1 or nil)
|
||||||
|
widgets.show:SetChecked((not TotemBarDB.hidden) and 1 or nil)
|
||||||
|
widgets.dropSet:SetChecked(TotemBarDB.showDropSetButton and 1 or nil)
|
||||||
|
widgets.ring:SetChecked(TotemBarDB.showDurationRing and 1 or nil)
|
||||||
|
widgets.pulse:SetChecked(TotemBarDB.showPulseBars and 1 or nil)
|
||||||
|
widgets.pulseWaves:SetChecked(TotemBarDB.showPulseWaves and 1 or nil)
|
||||||
|
widgets.timerText:SetChecked(TotemBarDB.showTimerText and 1 or nil)
|
||||||
|
widgets.layout:SetText(BarLayoutLabel())
|
||||||
|
widgets.guard:SetValue(TotemBar.clampValue(TotemBarDB.recallGuardSeconds, 0, 5))
|
||||||
|
widgets.gap:SetValue(TotemBar.clampValue(TotemBarDB.gapSeconds, 0.5, 5))
|
||||||
|
widgets.scale:SetValue(TotemBar.clampValue(TotemBarDB.scale, 0.5, 2.0))
|
||||||
|
widgets.buttonGap:SetValue(TotemBar.clampValue(TotemBarDB.buttonGap, 10, 30))
|
||||||
|
end)
|
||||||
|
|
||||||
|
-- Version footer (bottom-centre), read from the .toc so it always matches
|
||||||
|
-- the real addon version instead of a hardcoded duplicate.
|
||||||
|
local ver = (GetAddOnMetadata and GetAddOnMetadata("TotemBar", "Version")) or "0.1.0"
|
||||||
|
local verFS = f:CreateFontString("TotemBarOptionsVersion", "OVERLAY")
|
||||||
|
ApplyFont(verFS, 10)
|
||||||
|
verFS:SetPoint("BOTTOM", f, "BOTTOM", 0, 6)
|
||||||
|
verFS:SetText("v" .. ver)
|
||||||
|
verFS:SetTextColor(0.6, 0.6, 0.6)
|
||||||
|
|
||||||
|
-- ESC closes it.
|
||||||
|
tinsert(UISpecialFrames, "TotemBarOptionsFrame")
|
||||||
|
|
||||||
|
f:Hide()
|
||||||
|
return f
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Creates or updates the "Totems" convenience macro from TotemBar.macroSpec.
|
||||||
|
-- General (account) macro, capped at 18 in 1.12; if the cap is full and no
|
||||||
|
-- "Totems" macro exists yet, reports and does nothing. Never auto-places it
|
||||||
|
-- on the action bar (no 1.12 API for that - the player drags it).
|
||||||
|
local function ApplyTotemsMacro()
|
||||||
|
local name, body, icon = TotemBar.macroSpec()
|
||||||
|
local existing = GetMacroIndexByName(name)
|
||||||
|
if existing and existing > 0 then
|
||||||
|
EditMacro(existing, name, icon, body, 1, nil)
|
||||||
|
ChatOut:AddMessage("TotemBar: '" .. name .. "' macro updated - drag it to your action bar.")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local numGlobal = GetNumMacros() -- returns global, perChar in 1.12
|
||||||
|
if numGlobal and numGlobal >= 18 then
|
||||||
|
ChatOut:AddMessage("TotemBar: macro slots full (18) - free one and retry.")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
CreateMacro(name, icon, body, nil)
|
||||||
|
ChatOut:AddMessage("TotemBar: '" .. name .. "' macro created - drag it to your action bar.")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Adds the Reset-position and Create-macro buttons to the options frame.
|
||||||
|
-- Called by BuildOptionsFrame (Task 3) after the sliders, at layout cursor
|
||||||
|
-- (x, yStart). Kept separate so the panel body and the action buttons are
|
||||||
|
-- two focused units.
|
||||||
|
function TotemBar.BuildOptionsButtons(f, x, yStart)
|
||||||
|
-- Full content width (frame width minus the left/right margin x), so the
|
||||||
|
-- buttons read as a balanced block instead of a narrow left-biased pair.
|
||||||
|
local w = f:GetWidth() - (x * 2)
|
||||||
|
|
||||||
|
local reset = CreateButton(f, "TotemBarOptResetButton", "Reset position", function()
|
||||||
|
if TotemBar.ResetPosition then TotemBar.ResetPosition() end
|
||||||
|
end)
|
||||||
|
reset:SetWidth(w)
|
||||||
|
reset:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart)
|
||||||
|
AddTooltip(reset, "Moves the bar back to the center of the screen.")
|
||||||
|
|
||||||
|
local macro = CreateButton(f, "TotemBarOptMacroButton", "Create 'Totems' macro", function()
|
||||||
|
ApplyTotemsMacro()
|
||||||
|
end)
|
||||||
|
macro:SetWidth(w)
|
||||||
|
macro:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart - 28)
|
||||||
|
AddTooltip(macro, "Creates or updates a 'Totems' macro that drops all four chosen totems in one press. Drag it from the macro window to your action bar.")
|
||||||
|
|
||||||
|
local bind = CreateButton(f, "TotemBarOptBindButton", "Key bind mode", function()
|
||||||
|
if TotemBar.ToggleBindMode then TotemBar.ToggleBindMode() end
|
||||||
|
end)
|
||||||
|
bind:SetWidth(w)
|
||||||
|
bind:SetPoint("TOPLEFT", f, "TOPLEFT", x, yStart - 56)
|
||||||
|
AddTooltip(bind, "Toggle bind mode, then hover any bar button or a flyout totem and press a key to bind it. ESC clears. Bindings are saved automatically.")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Show/hide the options panel (builds it lazily). Left-click on the minimap
|
||||||
|
-- button and /tb options both call this.
|
||||||
|
function TotemBar.ToggleOptions()
|
||||||
|
local f = BuildOptionsFrame()
|
||||||
|
if f:IsShown() then
|
||||||
|
f:Hide()
|
||||||
|
else
|
||||||
|
f:Show()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 256 KiB |