mirror of
https://codeberg.org/Duvelcorp/MetaHunt.git
synced 2026-09-22 07:36:58 +00:00
66ff1ae711
- Shared FX toolkit in fx-celebrate.lua: mth_shake tween type, MTH_FX_Flash glow helper, MTH_CelebrateRunaway red shaking banner, gold title reset. - pet.runaway, pet.happiness, pet.loyaltyup, pet.feed - combat.ammoswap, combat.rangedswap, combat.mmprocs - ui.zbar, ui.stable, ui.minimapping, ui.optionsclose - book.model, book.search - Options > Animations panel now uses a balanced two-column layout. - CHANGELOG Animations entry expanded (no version bump).
1632 lines
59 KiB
Lua
1632 lines
59 KiB
Lua
------------------------------------------------------
|
|
-- MetaHunt: Experimental Ammunition Tracker
|
|
-- TWoW 1.18.1 — "Nightmares of Ursol"
|
|
------------------------------------------------------
|
|
--
|
|
-- Mechanic (confirmed from live 1.18.1 talent tooltip):
|
|
-- Aimed Shot triggers a cyclic 3-state ammo sequence:
|
|
-- EXPLOSIVE (60s) → POISONOUS (60s) → ENCHANTED (60s)
|
|
-- Each Aimed Shot (re)starts the cycle at EXPLOSIVE.
|
|
-- Each state is consumed early by the matching shot:
|
|
-- EXPLOSIVE → Multi-Shot (AoE fire explosion)
|
|
-- POISONOUS → Serpent Sting (corrosive poison 15s)
|
|
-- ENCHANTED → Arcane Shot (magic resist debuff 6s)
|
|
-- Whether consumed or expired, the next state begins
|
|
-- automatically. After ENCHANTED the cycle ends; the
|
|
-- next Aimed Shot will restart it.
|
|
--
|
|
-- Requires: Experimental Ammunition talent (MM, 1 rank).
|
|
-- Module auto-hides if player does not have the talent.
|
|
------------------------------------------------------
|
|
|
|
local MODULE_NAME = "expammo"
|
|
local CYCLE_DURATION = 60 -- seconds per ammo state
|
|
local TALENT_NAME = "Experimental Ammunition"
|
|
|
|
-- ── Spell names (English) ────────────────────────────────────
|
|
local SPELL_AIMED = "Aimed Shot"
|
|
local SPELL_STEADY = "Steady Shot"
|
|
local SPELL_MULTI = "Multi-Shot"
|
|
local SPELL_ARCANE = "Arcane Shot"
|
|
local SPELL_SERPENT = "Serpent Sting"
|
|
|
|
-- ── Buff names (English, as shown in buff bar) ───────────────
|
|
-- These match the talent tooltip text for each state.
|
|
local BUFF_EXPLOSIVE = "Explosive Ammunition"
|
|
local BUFF_POISONOUS = "Poisonous Ammunition"
|
|
local BUFF_ENCHANTED = "Enchanted Ammunition"
|
|
|
|
-- ── State IDs ────────────────────────────────────────────────
|
|
local IDLE = "IDLE"
|
|
local EXPLOSIVE = "EXPLOSIVE"
|
|
local POISONOUS = "POISONOUS"
|
|
local ENCHANTED = "ENCHANTED"
|
|
|
|
local STATE_ORDER = { EXPLOSIVE, POISONOUS, ENCHANTED }
|
|
|
|
-- Buff name for each state (for recovery scan on login)
|
|
local STATE_BUFF = {
|
|
[EXPLOSIVE] = BUFF_EXPLOSIVE,
|
|
[POISONOUS] = BUFF_POISONOUS,
|
|
[ENCHANTED] = BUFF_ENCHANTED,
|
|
}
|
|
|
|
-- Spell that consumes each state
|
|
local CONSUME_SPELL = {
|
|
[EXPLOSIVE] = SPELL_MULTI,
|
|
[POISONOUS] = SPELL_SERPENT,
|
|
[ENCHANTED] = SPELL_ARCANE,
|
|
}
|
|
|
|
-- Reverse lookup: buff name → state key (used for CHAT_MSG matching)
|
|
-- Built at load time so hot-path matching is a simple table index.
|
|
local BUFF_TO_STATE = {}
|
|
for _, sk in ipairs(STATE_ORDER) do
|
|
BUFF_TO_STATE[STATE_BUFF[sk]] = sk
|
|
end
|
|
|
|
-- State that follows each state (nil = IDLE)
|
|
local NEXT_STATE = {
|
|
[EXPLOSIVE] = POISONOUS,
|
|
[POISONOUS] = ENCHANTED,
|
|
[ENCHANTED] = nil, -- cycle ends; restart on next Aimed Shot
|
|
}
|
|
|
|
-- Prediction: which state will the server send next time Aimed Shot fires?
|
|
-- The server tracks cycle position: EXPLOSIVE→POISONOUS→ENCHANTED, then restarts.
|
|
-- After ENCHANTED (or no prior cycle) the next state is EXPLOSIVE.
|
|
local PREDICT_NEXT = {
|
|
[EXPLOSIVE] = POISONOUS,
|
|
[POISONOUS] = ENCHANTED,
|
|
[ENCHANTED] = EXPLOSIVE, -- full cycle done: restart
|
|
}
|
|
|
|
-- ── Per-cell appearance ───────────────────────────────────────
|
|
local CELL_DEF = {
|
|
{ state = EXPLOSIVE, label = "Explosive", r = 1.00, g = 0.38, b = 0.05,
|
|
icon = "Interface\\Icons\\Ability_SearingArrow" },
|
|
{ state = POISONOUS, label = "Poisonous", r = 0.12, g = 0.85, b = 0.18,
|
|
icon = "Interface\\Icons\\Ability_PoisonArrow" },
|
|
{ state = ENCHANTED, label = "Enchanted", r = 0.38, g = 0.12, b = 1.00,
|
|
icon = "Interface\\Icons\\Ability_TheBlackArrow" },
|
|
}
|
|
|
|
-- ── Creature-type immunity rules ──────────────────────────────
|
|
-- Maps UnitCreatureType("target") → set of ammo states the target resists.
|
|
-- Used by PLAYER_TARGET_CHANGED to proactively flag blocked states.
|
|
local CREATURE_TYPE_IMMUNITIES = {
|
|
["Elemental"] = { [POISONOUS] = true }, -- Elementals are Nature-immune
|
|
}
|
|
|
|
-- ── Talent gate ──────────────────────────────────────────────
|
|
-- The module only shows if the player has the talent.
|
|
-- Checked on login and whenever MTH_HT_OnScanComplete fires.
|
|
local MTH_EA_HasTalent = false
|
|
|
|
-- ── Volatile runtime state (not persisted) ────────────────────
|
|
local rt = {
|
|
state = IDLE,
|
|
expiresAt = 0,
|
|
consumed = false,
|
|
lastState = nil, -- ammo state that most recently ended; used for predictive display
|
|
}
|
|
|
|
local MTH_EA_PendingAuraRescan = 0
|
|
|
|
-- Prediction verification timer. When SPELLCAST_STOP sets a predictive
|
|
-- ammo state, we record GetTime()+1.5 here. OnUpdate checks this and,
|
|
-- if the state is still the predicted one (no server confirmation arrived),
|
|
-- performs a hard aura scan. If the aura isn't present, the phantom state
|
|
-- is cleared to IDLE. Set to 0 when no verification is pending.
|
|
local MTH_EA_PredictVerifyAt = 0
|
|
|
|
local function MTH_EA_RequestAuraRescan(delay)
|
|
delay = tonumber(delay) or 0.05
|
|
if delay < 0 then
|
|
delay = 0
|
|
end
|
|
if MTH_EA_PendingAuraRescan <= 0 or delay < MTH_EA_PendingAuraRescan then
|
|
MTH_EA_PendingAuraRescan = delay
|
|
end
|
|
end
|
|
|
|
-- ── NamPower integration ─────────────────────────────────────
|
|
-- When NamPower ≥ 2.40.0 is present, VARIABLES_LOADED unregisters
|
|
-- the 5 chat events + PLAYER_AURAS_CHANGED and registers the 4 exact
|
|
-- spellId aura events instead. Falls back to the old path otherwise.
|
|
local MTH_EA_UseNamPower = false
|
|
local MTH_EA_SpellIdToState = {} -- [spellId] → EXPLOSIVE/POISONOUS/ENCHANTED (lazy)
|
|
local MTH_EA_LnLSpellId = nil -- Lock and Load spellId (resolved on first gain)
|
|
|
|
-- ── Aura probe for state recovery and PLAYER_AURAS_CHANGED ──
|
|
-- Combat log shows "You are afflicted by Explosive Ammunition"
|
|
-- meaning the ammo state is implemented as a player DEBUFF, not
|
|
-- a buff. We scan both pools so we work either way.
|
|
local MTH_EA_BuffProbe = nil
|
|
local function MTH_EA_EnsureProbe()
|
|
if not MTH_EA_BuffProbe then
|
|
MTH_EA_BuffProbe = CreateFrame("GameTooltip", "MTH_EA_BuffProbeTooltip", UIParent, "GameTooltipTemplate")
|
|
MTH_EA_BuffProbe:SetOwner(UIParent, "ANCHOR_NONE")
|
|
end
|
|
return MTH_EA_BuffProbe
|
|
end
|
|
|
|
-- Get the name of buff (isDebuff=false) or debuff (isDebuff=true) at slot index.
|
|
local function MTH_EA_GetAuraName(isDebuff, index)
|
|
local probe = MTH_EA_EnsureProbe()
|
|
if not probe then return nil end
|
|
local ok
|
|
if isDebuff then
|
|
ok = pcall(function() probe:SetUnitDebuff("player", index) end)
|
|
else
|
|
ok = pcall(function() probe:SetUnitBuff("player", index) end)
|
|
end
|
|
if not ok then return nil end
|
|
local text = getglobal("MTH_EA_BuffProbeTooltipTextLeft1")
|
|
local name = text and text:GetText() or nil
|
|
if name == "" then return nil end
|
|
return name
|
|
end
|
|
|
|
-- Scan the player's buff list for "Lock and Load" and return its
|
|
-- actual remaining seconds by reading the tooltip duration line.
|
|
-- Returns nil if the buff is not found or duration can't be parsed.
|
|
local function MTH_EA_GetLnLRemaining()
|
|
local probe = MTH_EA_EnsureProbe()
|
|
if not probe then return nil end
|
|
for i = 1, 32 do
|
|
local ok = pcall(function() probe:SetUnitBuff("player", i) end)
|
|
if not ok then return nil end
|
|
local t1 = getglobal("MTH_EA_BuffProbeTooltipTextLeft1")
|
|
local name = t1 and t1:GetText() or nil
|
|
if not name or name == "" then return nil end
|
|
if string.find(name, "Lock and Load", 1, true) then
|
|
-- The game tooltip shows duration on subsequent lines,
|
|
-- e.g. "8 sec remaining" or "Remaining: 8 sec"
|
|
for ln = 2, 5 do
|
|
local lt = getglobal("MTH_EA_BuffProbeTooltipTextLeft" .. ln)
|
|
local ls = lt and lt:GetText() or nil
|
|
if ls and ls ~= "" then
|
|
local _, _, n = string.find(ls, "(%d+%.?%d*)%s+sec")
|
|
if n then return tonumber(n) end
|
|
end
|
|
local rt2 = getglobal("MTH_EA_BuffProbeTooltipTextRight" .. ln)
|
|
local rs = rt2 and rt2:GetText() or nil
|
|
if rs and rs ~= "" then
|
|
local _, _, n = string.find(rs, "(%d+%.?%d*)%s+sec")
|
|
if n then return tonumber(n) end
|
|
end
|
|
end
|
|
-- Buff found but no parseable duration text.
|
|
return nil
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- ── Fast texture-based aura scanners (no tooltip, no pcall) ─────────────────
|
|
-- UnitBuff("player", i) → icon texture path for buff slot i (nil = end)
|
|
-- UnitDebuff("player", i) → icon texture path for debuff slot i (nil = end)
|
|
-- We match by icon basename instead of buff name: zero rendering cost.
|
|
|
|
-- Known icon basenames (lower-case, no path prefix, no extension):
|
|
local MTH_EA_AMMO_TEX = {
|
|
[EXPLOSIVE] = "ability_searingarrow",
|
|
[POISONOUS] = "ability_poisonarrow",
|
|
[ENCHANTED] = "ability_theblackarrow",
|
|
}
|
|
local MTH_EA_LNL_TEX = "lockandload" -- matches "ability_hunter_lockandload"
|
|
|
|
local function MTH_EA_IconBasename(texturePath)
|
|
if not texturePath then return "" end
|
|
local s = string.lower(tostring(texturePath))
|
|
local _, _, base = string.find(s, "([^\\]+)$")
|
|
base = base or s
|
|
-- strip extension (.blp, .tga, etc.) if present
|
|
base = string.gsub(base, "%.[a-z]+$", "")
|
|
return base
|
|
end
|
|
|
|
-- Scan player buffs+debuffs for an active ammo state via texture comparison.
|
|
local function MTH_EA_FindAmmoStateByTexture()
|
|
for i = 1, 32 do
|
|
local t = UnitBuff("player", i)
|
|
if not t then break end
|
|
local base = MTH_EA_IconBasename(t)
|
|
for stateKey, frag in pairs(MTH_EA_AMMO_TEX) do
|
|
if string.find(base, frag, 1, true) then return stateKey end
|
|
end
|
|
end
|
|
for i = 1, 16 do
|
|
local t = UnitDebuff("player", i)
|
|
if not t then break end
|
|
local base = MTH_EA_IconBasename(t)
|
|
for stateKey, frag in pairs(MTH_EA_AMMO_TEX) do
|
|
if string.find(base, frag, 1, true) then return stateKey end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- Returns true if LnL buff is currently on the player.
|
|
local function MTH_EA_HasLnLByTexture()
|
|
for i = 1, 32 do
|
|
local t = UnitBuff("player", i)
|
|
if not t then break end
|
|
if string.find(MTH_EA_IconBasename(t), MTH_EA_LNL_TEX, 1, true) then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Scan all player auras (buffs then debuffs) for an active ammo state.
|
|
-- Returns the matching STATE_ORDER key, or nil if none found.
|
|
-- (Delegates to the fast texture path; tooltip path kept for debug use only.)
|
|
local MTH_EA_SetState
|
|
local function MTH_EA_FindAmmoAura()
|
|
return MTH_EA_FindAmmoStateByTexture()
|
|
end
|
|
|
|
-- Restore cycle state from active auras (survives /reload within session).
|
|
local function MTH_EA_RecoverStateFromBuffs()
|
|
local stateKey = MTH_EA_FindAmmoAura()
|
|
if stateKey then
|
|
rt.state = stateKey
|
|
rt.expiresAt = GetTime() + CYCLE_DURATION
|
|
rt.consumed = false
|
|
MTH_EA_PredictVerifyAt = 0 -- server confirmed; cancel pending verify
|
|
elseif rt.state ~= IDLE then
|
|
MTH_EA_SetState(IDLE)
|
|
end
|
|
end
|
|
|
|
-- ── Saved config access ───────────────────────────────────────
|
|
local function MTH_EA_GetConfig()
|
|
if type(MTH_SavedVariables) ~= "table" then
|
|
return {}
|
|
end
|
|
if type(MTH_SavedVariables.modules) ~= "table" then
|
|
MTH_SavedVariables.modules = {}
|
|
end
|
|
if type(MTH_SavedVariables.modules[MODULE_NAME]) ~= "table" then
|
|
MTH_SavedVariables.modules[MODULE_NAME] = {}
|
|
end
|
|
return MTH_SavedVariables.modules[MODULE_NAME]
|
|
end
|
|
|
|
function MTH_EA_IsEnabled()
|
|
local cfg = MTH_EA_GetConfig()
|
|
if cfg.enabled == nil then cfg.enabled = false end
|
|
return cfg.enabled == true
|
|
end
|
|
|
|
local function MTH_EA_EnsureConfigDefaults()
|
|
local cfg = MTH_EA_GetConfig()
|
|
if cfg.enabled == nil then cfg.enabled = false end
|
|
if cfg.showLnL == nil then cfg.showLnL = true end
|
|
if cfg.showHint == nil then cfg.showHint = true end
|
|
if cfg.cellSize == nil then cfg.cellSize = 40 end
|
|
if cfg.cellGap == nil then cfg.cellGap = 2 end
|
|
if cfg.bigCD == nil then cfg.bigCD = false end
|
|
if cfg.hideOOC == nil then cfg.hideOOC = false end
|
|
if cfg.useQuiverNoClip == nil then cfg.useQuiverNoClip = false end
|
|
if cfg.blockExplosive == nil then cfg.blockExplosive = false end
|
|
if cfg.blockPoisonous == nil then cfg.blockPoisonous = false end
|
|
if cfg.blockEnchanted == nil then cfg.blockEnchanted = false end
|
|
end
|
|
|
|
-- Combat state flag (avoids needing InCombatLockdown, which doesn't exist in 1.12)
|
|
local inCombat = false
|
|
|
|
-- ── Runtime immunity / block tracking ─────────────────────────
|
|
-- Volatile: refreshed on PLAYER_TARGET_CHANGED and combat-log "immune" messages.
|
|
local MTH_EA_BlockedStates = {} -- [state] = true if creature-type or reactive immunity
|
|
|
|
function MTH_EA_IsStateBlocked(state)
|
|
if not state or state == IDLE then return false end
|
|
local cfg = MTH_EA_GetConfig()
|
|
if state == EXPLOSIVE and cfg.blockExplosive then return true end
|
|
if state == POISONOUS and cfg.blockPoisonous then return true end
|
|
if state == ENCHANTED and cfg.blockEnchanted then return true end
|
|
if MTH_EA_BlockedStates[state] then return true end
|
|
return false
|
|
end
|
|
|
|
-- Throttle for PLAYER_AURAS_CHANGED: even with the fast texture path this event
|
|
-- fires 10-50+/sec in combat, so cap processing at ~6/sec.
|
|
local MTH_EA_AurasChangedLastTime = 0
|
|
|
|
-- UI frame handles — declared here so MTH_EA_RefreshVisibility (and every
|
|
-- function defined below) all close over the SAME locals.
|
|
local strip = nil -- root positioning frame (sized to cross bounding box)
|
|
local cells = {} -- [1..3] ammo state cells
|
|
local cdTexts = {} -- [1..3] countdown fontstrings in ammo cells
|
|
local cellTop = nil -- Lock and Load cell
|
|
local cellTopCD = nil -- L&L countdown fontstring
|
|
local cellBot = nil -- action hint cell (display only, click-through)
|
|
local cellBotIcon = nil -- action button icon texture
|
|
local cellBotCD = nil -- action button CD / "READY" text
|
|
local cellBotBlink = 0 -- blink phase accumulator
|
|
local anchorFrames = {} -- [1..4] = BOTTOM, TOP, LEFT, RIGHT drag handles
|
|
local MTH_EA_SnapAnchor -- forward declaration (defined after ApplyLayout)
|
|
|
|
-- Show/hide the strip according to enabled + hideOOC + inCombat
|
|
local function MTH_EA_RefreshVisibility()
|
|
if not strip then return end
|
|
local cfg = MTH_EA_GetConfig()
|
|
local enabled = MTH_EA_IsEnabled()
|
|
local hideOOC = cfg.hideOOC == true
|
|
local shouldShow = enabled and (not hideOOC or inCombat)
|
|
if shouldShow then strip:Show() else strip:Hide() end
|
|
end
|
|
|
|
-- ── Forward declarations ────────────────────────────────────────────
|
|
local MTH_EA_UpdateUI
|
|
local MTH_EA_ApplyLayout
|
|
|
|
-- ── State machine ─────────────────────────────────────────────
|
|
MTH_EA_SetState = function(newState)
|
|
-- Track which state just ended so the predictive display knows what comes next.
|
|
if newState == IDLE and rt.state ~= IDLE then
|
|
rt.lastState = rt.state
|
|
end
|
|
rt.state = newState
|
|
rt.consumed = false
|
|
rt.expiresAt = (newState ~= IDLE) and (GetTime() + CYCLE_DURATION) or 0
|
|
-- Any authoritative state transition cancels a pending prediction verify,
|
|
-- because the state is now confirmed (or corrected) by the server.
|
|
MTH_EA_PredictVerifyAt = 0
|
|
MTH_EA_UpdateUI()
|
|
end
|
|
|
|
local function MTH_EA_AdvanceState()
|
|
local next = NEXT_STATE[rt.state]
|
|
MTH_EA_SetState(next or IDLE)
|
|
end
|
|
|
|
-- ── UI: Cross layout ─────────────────────────────────────────
|
|
--
|
|
-- [TOP] ← Lock and Load tracker
|
|
-- [EXP][POI][ENC] ← 3 ammo-state cells
|
|
-- [BOT] ← Action button: consume-spell or Aimed Shot
|
|
--
|
|
-- TOP and BOT are aligned with the centre cell (Poisonous, index 2).
|
|
--
|
|
local CELL_SIZE = 40
|
|
local CELL_GAP = 2
|
|
local CELL_FONT = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF" -- reused by ApplyLayout
|
|
|
|
-- Lock and Load tracking state (independent of ammo cycle)
|
|
local lnl = {
|
|
active = false,
|
|
expiresAt = 0,
|
|
DURATION = 10, -- Lock and Load real duration (tooltip: "Lasts 10 sec or until Aimed Shot is cast")
|
|
}
|
|
local MTH_EA_LnLWasActive = false -- for detecting the proc rising edge (animations)
|
|
|
|
-- ── Confirmed icon paths (captured in-game 2026-03-21) ────────
|
|
-- Action-button spells (spellbook scan)
|
|
local ICON_AIMED = "Interface\\Icons\\INV_Spear_07"
|
|
local ICON_STEADY = "Interface\\Icons\\Ability_Hunter_SteadyShot"
|
|
local ICON_MULTI = "Interface\\Icons\\Ability_UpgradeMoonGlaive"
|
|
local ICON_SERPENT = "Interface\\Icons\\Ability_Hunter_QuickShot"
|
|
local ICON_ARCANE = "Interface\\Icons\\Ability_ImpalingBolt"
|
|
-- Aura/effect textures (aura-dump debug capture)
|
|
local ICON_EXP = "Interface\\Icons\\Ability_SearingArrow" -- Explosive Ammunition debuff
|
|
local ICON_POI = "Interface\\Icons\\Ability_PoisonArrow" -- Poisonous Ammunition debuff
|
|
local ICON_ENC = "Interface\\Icons\\Ability_TheBlackArrow" -- Enchanted Ammunition debuff
|
|
local ICON_LNL = "Interface\\Icons\\ability_hunter_lockandload" -- Lock and Load buff
|
|
|
|
-- ── LnL icon capture via aura diff ───────────────────────────
|
|
-- Lock and Load is a proc buff, not in the spellbook.
|
|
-- Strategy: snapshot all current buff textures while idle; when the
|
|
-- GAIN chat-message fires we flag a pending capture; the next
|
|
-- PLAYER_AURAS_CHANGED confirms the buff is on the unit and we diff
|
|
-- to extract the new texture.
|
|
local lnlBaselineBuffs = {} -- kept for compat; aura-diff no longer used
|
|
local lnlPendingCapture = false
|
|
|
|
local function MTH_EA_SnapshotBuffs()
|
|
-- No-op: ICON_LNL is hardcoded. Kept so call sites don't error.
|
|
end
|
|
|
|
local function MTH_EA_TryCaptureNewBuffIcon()
|
|
-- No-op: ICON_LNL is hardcoded. The aura-diff path was removed because it
|
|
-- caused incorrect icons (e.g. Quick Shots) when other buffs landed at the
|
|
-- same time as Lock and Load.
|
|
lnlPendingCapture = false
|
|
end
|
|
|
|
-- ── Action-bar slot cache ────────────────────────────────────
|
|
-- Maps spell names to action bar slot numbers via icon texture comparison.
|
|
-- Much more reliable than tooltip probing in 1.12 clients.
|
|
local MTH_EA_ActionSlotCache = {} -- [spellName] = slot or false
|
|
|
|
-- Known spell icon textures (lowercase) for action bar matching.
|
|
-- These are the SPELLBOOK icons, which match what GetActionTexture returns.
|
|
local MTH_EA_SPELL_ICONS = {
|
|
[SPELL_AIMED] = string.lower(ICON_AIMED),
|
|
[SPELL_STEADY] = string.lower(ICON_STEADY),
|
|
[SPELL_MULTI] = string.lower(ICON_MULTI),
|
|
[SPELL_ARCANE] = string.lower(ICON_ARCANE),
|
|
[SPELL_SERPENT] = string.lower(ICON_SERPENT),
|
|
}
|
|
|
|
-- Invalidate the slot cache (call on login and action bar changes).
|
|
local function MTH_EA_ClearActionSlotCache()
|
|
MTH_EA_ActionSlotCache = {}
|
|
end
|
|
|
|
-- Find the action bar slot for a spell by comparing action icon textures.
|
|
-- Returns slot number or nil. Result is cached until invalidated.
|
|
local function MTH_EA_FindActionSlot(spellName)
|
|
if not spellName then return nil end
|
|
local cached = MTH_EA_ActionSlotCache[spellName]
|
|
if cached ~= nil then
|
|
return cached or nil -- false → nil (scanned, not found)
|
|
end
|
|
local wantIcon = MTH_EA_SPELL_ICONS[spellName]
|
|
if not wantIcon then
|
|
MTH_EA_ActionSlotCache[spellName] = false
|
|
return nil
|
|
end
|
|
for slot = 1, 120 do
|
|
if HasAction(slot) and not GetActionText(slot) then
|
|
local tex = GetActionTexture(slot)
|
|
if tex and string.lower(tex) == wantIcon then
|
|
MTH_EA_ActionSlotCache[spellName] = slot
|
|
return slot
|
|
end
|
|
end
|
|
end
|
|
MTH_EA_ActionSlotCache[spellName] = false
|
|
return nil
|
|
end
|
|
|
|
-- Cooldown via action bar slot (secondary path).
|
|
local function MTH_EA_ActionSpellCD(spellName)
|
|
local slot = MTH_EA_FindActionSlot(spellName)
|
|
if not slot then return 0 end
|
|
if type(GetActionCooldown) ~= "function" then return 0 end
|
|
local start, duration = GetActionCooldown(slot)
|
|
if not start or not duration or duration <= 1.5 then return 0 end
|
|
return math.max(0, (start + duration) - GetTime())
|
|
end
|
|
|
|
-- Cooldown via spellbook (primary, always reliable).
|
|
-- Uses the hunter talent scanner's cached spellbook slot.
|
|
local function MTH_EA_SpellbookCD(spellName)
|
|
if type(MTH_HT_GetSpellInfo) ~= "function" then return 0 end
|
|
if type(GetSpellCooldown) ~= "function" then return 0 end
|
|
local info = MTH_HT_GetSpellInfo(spellName)
|
|
if not info or not info.slot then return 0 end
|
|
local start, duration = GetSpellCooldown(info.slot, "spell")
|
|
if not start or not duration or duration <= 1.5 then return 0 end
|
|
return math.max(0, (start + duration) - GetTime())
|
|
end
|
|
|
|
-- Returns remaining cooldown of a spell in seconds (0 = ready).
|
|
-- Primary: spellbook slot (always available, no action bar needed).
|
|
-- Secondary: action bar slot via icon texture matching.
|
|
local function MTH_EA_GetSpellCD(spellName)
|
|
local cd = MTH_EA_SpellbookCD(spellName)
|
|
if cd > 0 then return cd end
|
|
return MTH_EA_ActionSpellCD(spellName)
|
|
end
|
|
|
|
-- Action button: what spell to show and what spell to cast right now.
|
|
-- Returns displaySpell, icon, r, g, b, cooldownRemaining, castSpell
|
|
local function MTH_EA_GetActionSpell()
|
|
-- If current ammo state is blocked (creature immune or rotation toggle),
|
|
-- skip the consume-spell and fall through to Aimed/Steady instead.
|
|
if rt.state ~= IDLE and MTH_EA_IsStateBlocked(rt.state) then
|
|
local cd = MTH_EA_GetSpellCD(SPELL_AIMED)
|
|
return SPELL_AIMED, ICON_AIMED, 1.00, 0.85, 0.00, cd, (cd > 0 and SPELL_STEADY or SPELL_AIMED)
|
|
end
|
|
|
|
if rt.state == EXPLOSIVE then
|
|
local cd = MTH_EA_GetSpellCD(SPELL_MULTI)
|
|
return SPELL_MULTI, ICON_MULTI, 1.00, 0.60, 0.05, cd, (cd > 0 and SPELL_STEADY or SPELL_MULTI)
|
|
elseif rt.state == POISONOUS then
|
|
local cd = MTH_EA_GetSpellCD(SPELL_SERPENT)
|
|
return SPELL_SERPENT, ICON_SERPENT, 0.12, 0.85, 0.18, cd, (cd > 0 and SPELL_STEADY or SPELL_SERPENT)
|
|
elseif rt.state == ENCHANTED then
|
|
local cd = MTH_EA_GetSpellCD(SPELL_ARCANE)
|
|
return SPELL_ARCANE, ICON_ARCANE, 0.38, 0.12, 1.00, cd, (cd > 0 and SPELL_STEADY or SPELL_ARCANE)
|
|
else
|
|
-- IDLE: cast Steady Shot while Aimed is on CD, Aimed when ready
|
|
local cd = MTH_EA_GetSpellCD(SPELL_AIMED)
|
|
return SPELL_AIMED, ICON_AIMED, 1.00, 0.85, 0.00, cd, (cd > 0 and SPELL_STEADY or SPELL_AIMED)
|
|
end
|
|
end
|
|
|
|
-- Called from the keybind and from clicking the action button
|
|
function MTH_EA_CastActionSpell()
|
|
local _, _, _, _, _, _, spellName = MTH_EA_GetActionSpell()
|
|
if not spellName then return end
|
|
local cfg = MTH_EA_GetConfig()
|
|
if cfg.useQuiverNoClip
|
|
and type(Quiver) == "table"
|
|
and type(Quiver.GetSecondsRemainingShoot) == "function"
|
|
and type(Quiver.CastNoClip) == "function"
|
|
then
|
|
local a, b = Quiver.GetSecondsRemainingShoot()
|
|
if a and b < -0.25 then
|
|
CastSpellByName(spellName)
|
|
else
|
|
Quiver.CastNoClip(spellName)
|
|
end
|
|
else
|
|
CastSpellByName(spellName)
|
|
end
|
|
end
|
|
|
|
-- Build a styled cell frame parented to `parent`
|
|
local function MTH_EA_MakeCell(name, parent, w, h)
|
|
local c = CreateFrame("Frame", name, parent)
|
|
c:SetWidth(w or CELL_SIZE)
|
|
c:SetHeight(h or CELL_SIZE)
|
|
c:SetBackdrop({
|
|
bgFile = "Interface\\Buttons\\WHITE8X8",
|
|
edgeSize = 0,
|
|
insets = { left = 0, right = 0, top = 0, bottom = 0 },
|
|
})
|
|
c:SetBackdropColor(0, 0, 0, 0.65)
|
|
c:EnableMouse(false)
|
|
local icon = c:CreateTexture(nil, "ARTWORK")
|
|
icon:SetPoint("TOPLEFT", c, "TOPLEFT", 3, -3)
|
|
icon:SetPoint("BOTTOMRIGHT", c, "BOTTOMRIGHT", -3, 3)
|
|
c.icon = icon
|
|
local fs = c:CreateFontString(nil, "OVERLAY")
|
|
fs:SetFont(CELL_FONT or "Fonts\\FRIZQT__.TTF", 10, nil)
|
|
fs:SetPoint("BOTTOM", c, "BOTTOM", 0, 3)
|
|
fs:SetText("")
|
|
c.cd = fs
|
|
return c
|
|
end
|
|
|
|
local function MTH_EA_CreateUI()
|
|
if strip then return end
|
|
|
|
local totalW = CELL_SIZE * 3 + CELL_GAP * 2
|
|
local totalH = CELL_SIZE * 3 + CELL_GAP * 2 -- 3 rows for cross
|
|
|
|
-- Root frame sized to the full cross bounding box
|
|
strip = CreateFrame("Frame", "MTH_ExpAmmoStrip", UIParent)
|
|
strip:SetWidth(totalW)
|
|
strip:SetHeight(totalH)
|
|
strip:SetMovable(true)
|
|
strip:EnableMouse(false)
|
|
strip:SetFrameStrata("MEDIUM")
|
|
|
|
local cfg = MTH_EA_GetConfig()
|
|
if cfg.pos and cfg.pos.point then
|
|
strip:SetPoint(cfg.pos.point, UIParent, cfg.pos.relPoint or "CENTER",
|
|
cfg.pos.x or 0, cfg.pos.y or -200)
|
|
else
|
|
strip:SetPoint("CENTER", UIParent, "CENTER", 0, -200)
|
|
end
|
|
|
|
-- ── Middle row: 3 ammo-state cells ──────────────────────
|
|
for i = 1, 3 do
|
|
local def = CELL_DEF[i]
|
|
local c = MTH_EA_MakeCell("MTH_ExpAmmoCell"..i, strip)
|
|
if i == 1 then
|
|
c:SetPoint("LEFT", strip, "LEFT", 0, 0)
|
|
else
|
|
c:SetPoint("LEFT", cells[i-1], "RIGHT", CELL_GAP, 0)
|
|
end
|
|
c.icon:SetTexture(def.icon)
|
|
c.icon:SetVertexColor(0.30, 0.30, 0.30, 0.50)
|
|
cells[i] = c
|
|
cdTexts[i] = c.cd
|
|
end
|
|
|
|
-- ── TOP cell: Lock and Load ──────────────────────────────
|
|
cellTop = MTH_EA_MakeCell("MTH_ExpAmmoTop", strip)
|
|
-- Align with centre cell (cells[2])
|
|
cellTop:SetPoint("BOTTOM", cells[2], "TOP", 0, CELL_GAP)
|
|
cellTop.icon:SetTexture(ICON_LNL)
|
|
cellTop.icon:SetVertexColor(0.30, 0.30, 0.30, 0.40)
|
|
cellTopCD = cellTop.cd
|
|
|
|
-- ── BOTTOM cell: action hint (display only, click-through) ─
|
|
cellBot = MTH_EA_MakeCell("MTH_ExpAmmoBot", strip)
|
|
cellBot:SetPoint("TOP", cells[2], "BOTTOM", 0, -CELL_GAP)
|
|
cellBotIcon = cellBot.icon
|
|
cellBotCD = cellBot.cd
|
|
cellBotIcon:SetTexture(ICON_AIMED)
|
|
cellBotIcon:SetVertexColor(1, 0.85, 0, 0.6)
|
|
|
|
MTH_EA_ApplyLayout()
|
|
end
|
|
|
|
MTH_EA_UpdateUI = function()
|
|
if not (strip and strip:IsShown()) then return end
|
|
local now = GetTime()
|
|
|
|
-- ── Ammo state cells ─────────────────────────────────────
|
|
for i = 1, 3 do
|
|
local c = cells[i]
|
|
local def = CELL_DEF[i]
|
|
|
|
if rt.state == IDLE then
|
|
-- Hint: highlight the border of the predicted next ammo cell
|
|
local hintState = PREDICT_NEXT[rt.lastState] or EXPLOSIVE
|
|
if def.state == hintState then
|
|
-- Use full color at moderate alpha so every hue is equally visible
|
|
c.icon:SetVertexColor(def.r, def.g, def.b, 0.70)
|
|
c:SetBackdropBorderColor(def.r, def.g, def.b, 1.0)
|
|
else
|
|
c.icon:SetVertexColor(0.28, 0.28, 0.28, 0.45)
|
|
c:SetBackdropBorderColor(def.r * 0.20, def.g * 0.20, def.b * 0.20, 0.35)
|
|
end
|
|
cdTexts[i]:SetText("")
|
|
|
|
elseif rt.state == def.state then
|
|
local remaining = math.max(0, rt.expiresAt - now)
|
|
local secs = math.floor(remaining + 0.5)
|
|
c.icon:SetVertexColor(def.r, def.g, def.b, 1)
|
|
c:SetBackdropBorderColor(def.r, def.g, def.b, 1)
|
|
cdTexts[i]:SetText("|cffffffff" .. secs .. "|r")
|
|
|
|
else
|
|
local activeIdx, thisIdx = 0, 0
|
|
for j = 1, 3 do
|
|
if STATE_ORDER[j] == rt.state then activeIdx = j end
|
|
if STATE_ORDER[j] == def.state then thisIdx = j end
|
|
end
|
|
if thisIdx > activeIdx then
|
|
c.icon:SetVertexColor(def.r * 0.45, def.g * 0.45, def.b * 0.45, 0.70)
|
|
c:SetBackdropBorderColor(def.r * 0.40, def.g * 0.40, def.b * 0.40, 0.60)
|
|
else
|
|
c.icon:SetVertexColor(0.22, 0.22, 0.22, 0.38)
|
|
c:SetBackdropBorderColor(0.18, 0.18, 0.18, 0.30)
|
|
end
|
|
cdTexts[i]:SetText("")
|
|
end
|
|
|
|
-- Red border override for blocked (immune / rotation-disabled) states
|
|
if MTH_EA_IsStateBlocked(def.state) then
|
|
c:SetBackdropBorderColor(1, 0, 0, 0.85)
|
|
end
|
|
end
|
|
|
|
-- ── TOP cell: Lock and Load ───────────────────────────────
|
|
if cellTop then
|
|
if lnl.active then
|
|
local remaining = math.max(0, lnl.expiresAt - now)
|
|
local secs = math.floor(remaining + 0.5)
|
|
cellTop.icon:SetVertexColor(1.00, 0.82, 0.00, 1)
|
|
cellTop:SetBackdropBorderColor(1.00, 0.82, 0.00, 1)
|
|
cellTopCD:SetText("|cffffffff" .. secs .. "|r")
|
|
if not MTH_EA_LnLWasActive then
|
|
if type(MTH_FX_Flash) == "function" and type(MTH_FX_IsEnabled) == "function"
|
|
and MTH_FX_IsEnabled("combat.mmprocs") then
|
|
MTH_FX_Flash(cellTop, 1.00, 0.82, 0.20, 0.55)
|
|
end
|
|
MTH_EA_LnLWasActive = true
|
|
end
|
|
else
|
|
cellTop.icon:SetVertexColor(0.28, 0.28, 0.28, 0.40)
|
|
cellTop:SetBackdropBorderColor(0.25, 0.25, 0.25, 0.35)
|
|
cellTopCD:SetText("")
|
|
MTH_EA_LnLWasActive = false
|
|
end
|
|
end
|
|
|
|
-- ── BOTTOM cell: action button ────────────────────────────
|
|
if cellBot then
|
|
local displaySpell, icon, r, g, b, cdRemain = MTH_EA_GetActionSpell()
|
|
|
|
if rt.state == IDLE then
|
|
-- Always display Aimed Shot icon + its CD.
|
|
-- The action (keybind/click) casts Steady Shot while Aimed is on CD.
|
|
cellBotIcon:SetTexture(ICON_AIMED)
|
|
if cdRemain > 0 then
|
|
-- On CD: dim icon, show seconds countdown
|
|
cellBotBlink = 0
|
|
cellBotIcon:SetVertexColor(r * 0.45, g * 0.45, b * 0.45, 0.70)
|
|
cellBot:SetBackdropBorderColor(r * 0.35, g * 0.35, b * 0.35, 0.55)
|
|
cellBotCD:SetText("|cffaaaaaa" .. math.floor(cdRemain + 0.5) .. "|r")
|
|
else
|
|
-- Ready: full colour + blink border
|
|
local pulse = math.abs(math.sin(cellBotBlink * 3))
|
|
cellBotIcon:SetVertexColor(r, g, b, 0.75 + pulse * 0.25)
|
|
cellBot:SetBackdropBorderColor(r, g * pulse, b * 0.2, 0.7 + pulse * 0.3)
|
|
cellBotCD:SetText("|cffffe066USE|r")
|
|
end
|
|
else
|
|
-- Ammo active: show consume spell icon, fully lit
|
|
cellBotBlink = 0
|
|
cellBotIcon:SetTexture(icon)
|
|
cellBotIcon:SetVertexColor(r, g, b, 1)
|
|
if cdRemain > 0 then
|
|
cellBot:SetBackdropBorderColor(r * 0.35, g * 0.35, b * 0.35, 0.55)
|
|
cellBotCD:SetText("|cffaaaaaa" .. math.floor(cdRemain + 0.5) .. "|r")
|
|
else
|
|
cellBot:SetBackdropBorderColor(r, g, b, 1)
|
|
cellBotCD:SetText("|cffffe066USE|r")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ── Layout / appearance manager ──────────────────────────────────────────────
|
|
-- Called once after CreateUI (with saved settings) and on every option change.
|
|
MTH_EA_ApplyLayout = function()
|
|
if not strip then return end
|
|
local cfg = MTH_EA_GetConfig()
|
|
local sz = math.max(20, math.min(80, cfg.cellSize or 40))
|
|
local gap = math.max(0, math.min(20, cfg.cellGap or 2))
|
|
local bigCD = cfg.bigCD == true
|
|
local font = CELL_FONT
|
|
|
|
strip:SetWidth(sz * 3 + gap * 2)
|
|
strip:SetHeight(sz * 3 + gap * 2)
|
|
|
|
local iconPad = math.min(gap, 3)
|
|
|
|
for i = 1, 3 do
|
|
local c = cells[i]
|
|
local fs = cdTexts[i]
|
|
c:SetWidth(sz)
|
|
c:SetHeight(sz)
|
|
c:ClearAllPoints()
|
|
if i == 1 then
|
|
c:SetPoint("LEFT", strip, "LEFT", 0, 0)
|
|
else
|
|
c:SetPoint("LEFT", cells[i-1], "RIGHT", gap, 0)
|
|
end
|
|
c.icon:ClearAllPoints()
|
|
c.icon:SetPoint("TOPLEFT", c, "TOPLEFT", iconPad, -iconPad)
|
|
c.icon:SetPoint("BOTTOMRIGHT", c, "BOTTOMRIGHT", -iconPad, iconPad)
|
|
fs:ClearAllPoints()
|
|
if bigCD then
|
|
fs:SetPoint("CENTER", c, "CENTER", 0, 0)
|
|
fs:SetFont(font, math.floor(sz * 0.55 + 0.5), "OUTLINE")
|
|
else
|
|
fs:SetPoint("BOTTOM", c, "BOTTOM", 0, 3)
|
|
fs:SetFont(font, 10, nil)
|
|
end
|
|
end
|
|
|
|
if cellTop then
|
|
cellTop:SetWidth(sz)
|
|
cellTop:SetHeight(sz)
|
|
cellTop:ClearAllPoints()
|
|
cellTop:SetPoint("BOTTOM", cells[2], "TOP", 0, gap)
|
|
if cfg.showLnL ~= false then cellTop:Show() else cellTop:Hide() end
|
|
cellTop.icon:ClearAllPoints()
|
|
cellTop.icon:SetPoint("TOPLEFT", cellTop, "TOPLEFT", iconPad, -iconPad)
|
|
cellTop.icon:SetPoint("BOTTOMRIGHT", cellTop, "BOTTOMRIGHT", -iconPad, iconPad)
|
|
if cellTopCD then
|
|
cellTopCD:ClearAllPoints()
|
|
if bigCD then
|
|
cellTopCD:SetPoint("CENTER", cellTop, "CENTER", 0, 0)
|
|
cellTopCD:SetFont(font, math.floor(sz * 0.55 + 0.5), "OUTLINE")
|
|
else
|
|
cellTopCD:SetPoint("BOTTOM", cellTop, "BOTTOM", 0, 3)
|
|
cellTopCD:SetFont(font, 10, nil)
|
|
end
|
|
end
|
|
end
|
|
|
|
if cellBot then
|
|
cellBot:SetWidth(sz)
|
|
cellBot:SetHeight(sz)
|
|
cellBot:ClearAllPoints()
|
|
cellBot:SetPoint("TOP", cells[2], "BOTTOM", 0, -gap)
|
|
if cfg.showHint ~= false then cellBot:Show() else cellBot:Hide() end
|
|
cellBot.icon:ClearAllPoints()
|
|
cellBot.icon:SetPoint("TOPLEFT", cellBot, "TOPLEFT", iconPad, -iconPad)
|
|
cellBot.icon:SetPoint("BOTTOMRIGHT", cellBot, "BOTTOMRIGHT", -iconPad, iconPad)
|
|
if cellBotCD then
|
|
cellBotCD:ClearAllPoints()
|
|
if bigCD then
|
|
cellBotCD:SetPoint("CENTER", cellBot, "CENTER", 0, 0)
|
|
cellBotCD:SetFont(font, math.floor(sz * 0.45 + 0.5), "OUTLINE")
|
|
else
|
|
cellBotCD:SetPoint("BOTTOM", cellBot, "BOTTOM", 0, 3)
|
|
cellBotCD:SetFont(font, 10, nil)
|
|
end
|
|
end
|
|
end
|
|
|
|
MTH_EA_UpdateUI()
|
|
MTH_EA_SnapAnchor()
|
|
end
|
|
|
|
-- ── Anchor frames for repositioning ────────────────────────────────────────
|
|
-- 4 handles placed outside each edge of the cross so the widget can be dragged
|
|
-- from any side and positioned flush against any screen edge.
|
|
|
|
local ANCHOR_SIDES = {
|
|
{ name = "Bot", attachPoint = "TOP", stripPoint = "BOTTOM", ox = 0, oy = -1, isWide = true },
|
|
{ name = "Top", attachPoint = "BOTTOM", stripPoint = "TOP", ox = 0, oy = 1, isWide = true },
|
|
{ name = "Lft", attachPoint = "RIGHT", stripPoint = "LEFT", ox = -1, oy = 0, isWide = false },
|
|
{ name = "Rgt", attachPoint = "LEFT", stripPoint = "RIGHT", ox = 1, oy = 0, isWide = false },
|
|
}
|
|
|
|
MTH_EA_SnapAnchor = function()
|
|
if not strip then return end
|
|
local cfg = MTH_EA_GetConfig()
|
|
local gap = math.max(0, math.min(20, cfg.cellGap or 2))
|
|
local sz = math.max(20, math.min(80, cfg.cellSize or 40))
|
|
local thin = math.max(8, math.floor(sz * 0.35 + 0.5))
|
|
for i, side in ipairs(ANCHOR_SIDES) do
|
|
local f = anchorFrames[i]
|
|
if f then
|
|
if side.isWide then
|
|
f:SetWidth(sz * 3 + gap * 2)
|
|
f:SetHeight(thin)
|
|
else
|
|
f:SetWidth(thin)
|
|
f:SetHeight(sz * 3 + gap * 2)
|
|
end
|
|
f:ClearAllPoints()
|
|
f:SetPoint(side.attachPoint, strip, side.stripPoint,
|
|
side.ox * gap, side.oy * gap)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function MTH_EA_MakeAnchorFrame(idx, side)
|
|
local f = CreateFrame("Button", "MTH_ExpAmmoAnchor"..side.name, UIParent)
|
|
f:SetFrameStrata("HIGH")
|
|
f:EnableMouse(true)
|
|
f:RegisterForDrag("LeftButton")
|
|
f:SetBackdrop({
|
|
bgFile = "Interface\\Buttons\\WHITE8X8",
|
|
edgeSize = 0,
|
|
insets = { left = 0, right = 0, top = 0, bottom = 0 },
|
|
})
|
|
f:SetBackdropColor(0.15, 0.45, 0.85, 0.75)
|
|
local label = f:CreateFontString(nil, "OVERLAY")
|
|
label:SetAllPoints()
|
|
label:SetFont(CELL_FONT or "Fonts\\FRIZQT__.TTF", 8, "OUTLINE")
|
|
label:SetJustifyH("CENTER")
|
|
label:SetText("|cffffffffMOVE|r")
|
|
f:SetScript("OnDragStart", function()
|
|
strip:StartMoving()
|
|
end)
|
|
f:SetScript("OnDragStop", function()
|
|
strip:StopMovingOrSizing()
|
|
local cfg = MTH_EA_GetConfig()
|
|
local point, _, relPoint, x, y = strip:GetPoint()
|
|
if point and x and y then
|
|
cfg.pos = { point = point, relPoint = relPoint,
|
|
x = math.floor(x + 0.5), y = math.floor(y + 0.5) }
|
|
end
|
|
MTH_EA_SnapAnchor()
|
|
end)
|
|
f:SetScript("OnEnter", function()
|
|
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
|
GameTooltip:SetText("|cffffff00MM Widget — Move Anchor|r")
|
|
GameTooltip:AddLine("Drag to reposition the widget", 0.6, 0.6, 0.6)
|
|
GameTooltip:Show()
|
|
end)
|
|
f:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
|
return f
|
|
end
|
|
|
|
function MTH_EA_ToggleAnchor()
|
|
if not strip then return end
|
|
local showing = false
|
|
-- Show if none exist or all are hidden
|
|
for i = 1, 4 do
|
|
if anchorFrames[i] and anchorFrames[i]:IsShown() then
|
|
showing = true
|
|
break
|
|
end
|
|
end
|
|
if showing then
|
|
for i = 1, 4 do
|
|
if anchorFrames[i] then anchorFrames[i]:Hide() end
|
|
end
|
|
else
|
|
for i, side in ipairs(ANCHOR_SIDES) do
|
|
if not anchorFrames[i] then
|
|
anchorFrames[i] = MTH_EA_MakeAnchorFrame(i, side)
|
|
end
|
|
anchorFrames[i]:Show()
|
|
end
|
|
MTH_EA_SnapAnchor()
|
|
end
|
|
end
|
|
|
|
-- ── Spell-name capture hook ───────────────────────────────────
|
|
-- We hook CastSpellByName / CastSpell / UseAction to know which
|
|
-- spell is about to be cast before SPELLCAST_STOP fires (which
|
|
-- in 1.12 carries no spell-name argument itself).
|
|
-- Guards prevent re-entrancy; we chain into whatever was already
|
|
-- installed (Quiver, SmartAmmo, etc.) at the end of our hook.
|
|
local MTH_EA_LastSpell = nil
|
|
|
|
local MTH_EA_InHookCSBN = false
|
|
local MTH_EA_InHookCS = false
|
|
local MTH_EA_InHookUA = false
|
|
|
|
local function MTH_EA_HookInstallCSBN()
|
|
-- Capture the full current chain at install time (VARIABLES_LOADED fires after all
|
|
-- addons — including Quiver — have loaded and installed their own hooks). Using a
|
|
-- file-parse-time snapshot here would skip every hook installed after this file was
|
|
-- parsed (e.g. Quiver), causing their CastSpellByName hooks to be silently bypassed.
|
|
local upstream = CastSpellByName
|
|
CastSpellByName = function(spellText, onSelf)
|
|
if MTH_EA_InHookCSBN then
|
|
if upstream then return upstream(spellText, onSelf) end
|
|
return
|
|
end
|
|
MTH_EA_InHookCSBN = true
|
|
local _, _, name = string.find(tostring(spellText or ""), "([%w%'%s%-]+)")
|
|
MTH_EA_LastSpell = name and (string.gsub(name, "^%s*(.-)%s*$", "%1")) or nil
|
|
MTH_EA_InHookCSBN = false
|
|
if upstream then
|
|
return upstream(spellText, onSelf)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function MTH_EA_HookInstallCS()
|
|
local origCS = CastSpell
|
|
CastSpell = function(spellId, bookType)
|
|
if MTH_EA_InHookCS then
|
|
if origCS then return origCS(spellId, bookType) end
|
|
return
|
|
end
|
|
MTH_EA_InHookCS = true
|
|
if type(GetSpellName) == "function" then
|
|
local n = GetSpellName(spellId, bookType or "spell")
|
|
if n and n ~= "" then MTH_EA_LastSpell = n end
|
|
end
|
|
local result
|
|
if origCS then result = origCS(spellId, bookType) end
|
|
MTH_EA_InHookCS = false
|
|
return result
|
|
end
|
|
end
|
|
|
|
-- Own tooltip probe for UseAction hook (created on first use).
|
|
-- Uses GameTooltipTemplate for reliable SetAction text population.
|
|
local MTH_EA_UAProbe = nil
|
|
local function MTH_EA_EnsureUAProbe()
|
|
if MTH_EA_UAProbe then return MTH_EA_UAProbe end
|
|
MTH_EA_UAProbe = CreateFrame("GameTooltip", "MTH_EA_UAProbe", UIParent, "GameTooltipTemplate")
|
|
MTH_EA_UAProbe:SetOwner(UIParent, "ANCHOR_NONE")
|
|
return MTH_EA_UAProbe
|
|
end
|
|
|
|
local function MTH_EA_HookInstallUA()
|
|
local origUA = UseAction
|
|
UseAction = function(slot, checkCursor, onSelf)
|
|
if MTH_EA_InHookUA then
|
|
if origUA then return origUA(slot, checkCursor, onSelf) end
|
|
return
|
|
end
|
|
MTH_EA_InHookUA = true
|
|
-- For spell-type actions (not macros), resolve spell name via icon texture
|
|
-- or tooltip probe. Texture match is tried first (zero cost).
|
|
if not GetActionText(slot) then
|
|
local tex = GetActionTexture(slot)
|
|
local matched = false
|
|
if tex then
|
|
local lower = string.lower(tex)
|
|
for name, icon in pairs(MTH_EA_SPELL_ICONS) do
|
|
if lower == icon then
|
|
MTH_EA_LastSpell = name
|
|
matched = true
|
|
break
|
|
end
|
|
end
|
|
end
|
|
if not matched then
|
|
local probe = MTH_EA_EnsureUAProbe()
|
|
probe:SetOwner(UIParent, "ANCHOR_NONE")
|
|
probe:ClearLines()
|
|
probe:SetAction(slot)
|
|
local L1 = getglobal("MTH_EA_UAProbeTextLeft1")
|
|
local txt = L1 and L1:GetText() or nil
|
|
if txt and txt ~= "" then MTH_EA_LastSpell = txt end
|
|
end
|
|
end
|
|
local result
|
|
if origUA then result = origUA(slot, checkCursor, onSelf) end
|
|
MTH_EA_InHookUA = false
|
|
return result
|
|
end
|
|
end
|
|
|
|
-- ── NamPower helper functions ────────────────────────────────
|
|
local function MTH_EA_CheckNamPower()
|
|
if type(GetNampowerVersion) ~= "function" then return false end
|
|
local maj, min = GetNampowerVersion()
|
|
maj = tonumber(maj) or 0
|
|
min = tonumber(min) or 0
|
|
return maj > 2 or (maj == 2 and min >= 40)
|
|
end
|
|
|
|
-- Resolve spellId → state key via name lookup on first encounter; cache result.
|
|
local function MTH_EA_CacheSpellId(spellId)
|
|
if MTH_EA_SpellIdToState[spellId] or spellId == MTH_EA_LnLSpellId then return end
|
|
if type(GetSpellRecField) ~= "function" then return end
|
|
local name = GetSpellRecField(spellId, "name")
|
|
if not name or name == "" then return end
|
|
local stateKey = BUFF_TO_STATE[name]
|
|
if stateKey then
|
|
MTH_EA_SpellIdToState[spellId] = stateKey
|
|
return
|
|
end
|
|
if string.find(name, "Lock and Load", 1, true) then
|
|
MTH_EA_LnLSpellId = spellId
|
|
end
|
|
end
|
|
|
|
-- Query the authoritative remaining LnL duration from NamPower.
|
|
-- Returns seconds (float) or nil if LnL isn't active / API unavailable.
|
|
local function MTH_EA_GetLnLDurationNP()
|
|
if type(GetPlayerAuraDuration) ~= "function" then return nil end
|
|
if type(GetUnitField) ~= "function" then return nil end
|
|
local auras = GetUnitField("player", "aura")
|
|
if type(auras) ~= "table" then return nil end
|
|
-- Buffs occupy slots 1-32 (1-based in the table, 0-based for the API).
|
|
for i = 1, 32 do
|
|
local sid = tonumber(auras[i])
|
|
if sid and sid == MTH_EA_LnLSpellId then
|
|
local _, remainMs = GetPlayerAuraDuration(i - 1)
|
|
remainMs = tonumber(remainMs)
|
|
if remainMs and remainMs > 0 then
|
|
return remainMs / 1000
|
|
end
|
|
return nil -- slot found but no usable duration
|
|
end
|
|
end
|
|
return nil -- LnL not in aura array
|
|
end
|
|
|
|
-- Recovery via NamPower's GetUnitField aura array (replaces texture scan).
|
|
local function MTH_EA_RecoverStateFromAuraArray()
|
|
if type(GetUnitField) ~= "function" then
|
|
MTH_EA_RecoverStateFromBuffs()
|
|
return
|
|
end
|
|
local auras = GetUnitField("player", "aura")
|
|
if type(auras) ~= "table" then
|
|
MTH_EA_RecoverStateFromBuffs()
|
|
return
|
|
end
|
|
local foundState = nil
|
|
local foundLnL = false
|
|
for _, spellId in pairs(auras) do
|
|
local sid = tonumber(spellId)
|
|
if sid and sid > 0 then
|
|
MTH_EA_CacheSpellId(sid)
|
|
local stateKey = MTH_EA_SpellIdToState[sid]
|
|
if stateKey and not foundState then
|
|
foundState = stateKey
|
|
rt.state = stateKey
|
|
rt.expiresAt = GetTime() + CYCLE_DURATION
|
|
rt.consumed = false
|
|
MTH_EA_PredictVerifyAt = 0 -- server confirmed; cancel pending verify
|
|
end
|
|
if sid == MTH_EA_LnLSpellId then
|
|
foundLnL = true
|
|
-- Always resync from authoritative NamPower duration.
|
|
local rem = MTH_EA_GetLnLDurationNP()
|
|
if rem then
|
|
lnl.active = true
|
|
lnl.expiresAt = GetTime() + rem
|
|
elseif not lnl.active then
|
|
lnl.active = true
|
|
lnl.expiresAt = GetTime() + lnl.DURATION
|
|
end
|
|
end
|
|
end
|
|
end
|
|
-- Authoritative LnL clear: if the buff is no longer in the aura array,
|
|
-- it was consumed (Aimed Shot) or expired. Clear immediately instead
|
|
-- of waiting for the hardcoded 10 s OnUpdate timer.
|
|
if not foundLnL and lnl.active then
|
|
lnl.active = false
|
|
end
|
|
if not foundState and rt.state ~= IDLE then
|
|
MTH_EA_SetState(IDLE)
|
|
end
|
|
end
|
|
-- ── Chat message handler (module-level to stay within Lua 5.0's
|
|
-- 32-upvalue-per-closure limit; OnEvent closes over too many locals
|
|
-- to host this as a nested function).
|
|
local function MTH_EA_TryHandleChatMsg(msg)
|
|
if not MTH_EA_IsEnabled() then return false end
|
|
msg = msg or ""
|
|
-- ── State GAIN patterns ─────────────────────────────────
|
|
local _, _, gained = string.find(msg, "^You are afflicted by (.+)%.$")
|
|
if not gained then
|
|
_, _, gained = string.find(msg, "^You gain (.+)%.$")
|
|
end
|
|
if not gained then
|
|
_, _, gained = string.find(msg, "^You gain the effect of (.+)%.$")
|
|
end
|
|
if gained then
|
|
local stateKey = BUFF_TO_STATE[gained]
|
|
if stateKey then
|
|
MTH_EA_SetState(stateKey)
|
|
return true
|
|
end
|
|
if string.find(gained, "Lock and Load") then
|
|
local rem = MTH_EA_GetLnLRemaining()
|
|
lnl.active = true
|
|
lnl.expiresAt = GetTime() + (rem or lnl.DURATION)
|
|
return true
|
|
end
|
|
end
|
|
-- ── State LOSS patterns ────────────────────────────────────
|
|
local _, _, lost = string.find(msg, "^(.+) fades from you%.$")
|
|
if not lost then
|
|
_, _, lost = string.find(msg, "^(.+) fades from you$")
|
|
end
|
|
if lost then
|
|
local stateKey = BUFF_TO_STATE[lost]
|
|
if stateKey then
|
|
if rt.state == stateKey then
|
|
MTH_EA_SetState(IDLE)
|
|
end
|
|
return true
|
|
end
|
|
if string.find(lost, "Lock and Load") then
|
|
lnl.active = false
|
|
lnlPendingCapture = false
|
|
MTH_EA_SnapshotBuffs()
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- ── Event frame ───────────────────────────────────────────────────
|
|
local frame = CreateFrame("Frame", "MTH_ExpAmmoFrame")
|
|
frame:Hide()
|
|
frame._elapsed = 0
|
|
|
|
frame:RegisterEvent("VARIABLES_LOADED")
|
|
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
|
|
frame:RegisterEvent("PLAYER_REGEN_DISABLED") -- entering combat
|
|
frame:RegisterEvent("PLAYER_REGEN_ENABLED") -- combat ends (cycle position preserved)
|
|
frame:RegisterEvent("PLAYER_AURAS_CHANGED") -- /reload recovery
|
|
frame:RegisterEvent("CHAT_MSG_SPELL_SELF_BUFF") -- gain fallback
|
|
frame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE") -- TWoW: debuff gain ("afflicted by")
|
|
frame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_SELF") -- loss (confirmed)
|
|
frame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS") -- loss fallback
|
|
frame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF")
|
|
frame:RegisterEvent("SPELLCAST_STOP")
|
|
frame:RegisterEvent("SPELLCAST_FAILED")
|
|
frame:RegisterEvent("SPELLCAST_INTERRUPTED")
|
|
frame:RegisterEvent("ACTIONBAR_SLOT_CHANGED") -- invalidate Aimed Shot slot cache
|
|
frame:RegisterEvent("PLAYER_TARGET_CHANGED") -- proactive creature-type immunity
|
|
frame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") -- reactive "immune" detection
|
|
|
|
frame:SetScript("OnUpdate", function()
|
|
if not MTH_EA_IsEnabled() then return end
|
|
local dt = arg1 or 0
|
|
this._elapsed = (this._elapsed or 0) + dt
|
|
cellBotBlink = cellBotBlink + dt -- accumulate for blink pulse
|
|
|
|
if MTH_EA_PendingAuraRescan > 0 then
|
|
MTH_EA_PendingAuraRescan = MTH_EA_PendingAuraRescan - dt
|
|
if MTH_EA_PendingAuraRescan <= 0 then
|
|
MTH_EA_PendingAuraRescan = 0
|
|
if MTH_EA_UseNamPower then
|
|
MTH_EA_RecoverStateFromAuraArray()
|
|
else
|
|
MTH_EA_RecoverStateFromBuffs()
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Natural state expiry: the game doesn't auto-advance on expiry,
|
|
-- so we go to IDLE. If the player consumed it, CHAT_MSG_AURA_GONE_SELF
|
|
-- already handled it; this path only fires on genuine timeout.
|
|
if rt.state ~= IDLE and GetTime() >= rt.expiresAt then
|
|
MTH_EA_SetState(IDLE)
|
|
MTH_EA_PredictVerifyAt = 0
|
|
cellBotBlink = 0
|
|
end
|
|
|
|
-- Prediction verification: after SPELLCAST_STOP sets a predictive state,
|
|
-- confirm the debuff actually arrived via a hard aura scan. If not found,
|
|
-- clear the phantom state. This catches stale spell-name captures, server
|
|
-- packet loss, and any other desync between the predicted and actual state.
|
|
if MTH_EA_PredictVerifyAt > 0 and rt.state ~= IDLE and GetTime() >= MTH_EA_PredictVerifyAt then
|
|
MTH_EA_PredictVerifyAt = 0
|
|
local actual = MTH_EA_FindAmmoStateByTexture()
|
|
if not actual then
|
|
-- Predicted debuff never showed up — phantom state; clear it.
|
|
MTH_EA_SetState(IDLE)
|
|
elseif actual ~= rt.state then
|
|
-- Server sent a different state than predicted; correct it.
|
|
rt.state = actual
|
|
rt.expiresAt = GetTime() + CYCLE_DURATION
|
|
rt.consumed = false
|
|
end
|
|
-- If actual == rt.state, prediction was correct — no action needed.
|
|
end
|
|
|
|
-- LnL: authoritative resync from NamPower every frame.
|
|
if lnl.active and MTH_EA_UseNamPower then
|
|
local rem = MTH_EA_GetLnLDurationNP()
|
|
if rem then
|
|
lnl.expiresAt = GetTime() + rem
|
|
else
|
|
-- Buff gone from aura array → expired or consumed.
|
|
lnl.active = false
|
|
end
|
|
elseif lnl.active and GetTime() >= lnl.expiresAt then
|
|
-- Legacy path: no NamPower, use hardcoded 10 s timer.
|
|
lnl.active = false
|
|
end
|
|
|
|
if this._elapsed < 0.25 then return end
|
|
this._elapsed = 0
|
|
|
|
MTH_EA_UpdateUI()
|
|
end)
|
|
|
|
-- ── Per-event helpers (module-level to keep OnEvent under Lua 5.0's
|
|
-- 32-upvalue-per-closure limit). They close over only the locals
|
|
-- each one actually needs, not the full module scope.
|
|
local function MTH_EA_HandleVarsLoaded()
|
|
MTH_EA_HookInstallCSBN()
|
|
MTH_EA_HookInstallCS()
|
|
MTH_EA_HookInstallUA()
|
|
if MTH and MTH.nampower then
|
|
MTH_EA_UseNamPower = true
|
|
frame:UnregisterEvent("PLAYER_AURAS_CHANGED")
|
|
frame:UnregisterEvent("CHAT_MSG_SPELL_SELF_BUFF")
|
|
frame:UnregisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE")
|
|
frame:UnregisterEvent("CHAT_MSG_SPELL_AURA_GONE_SELF")
|
|
frame:UnregisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS")
|
|
frame:UnregisterEvent("CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF")
|
|
frame:RegisterEvent("BUFF_ADDED_SELF")
|
|
frame:RegisterEvent("BUFF_REMOVED_SELF")
|
|
frame:RegisterEvent("DEBUFF_ADDED_SELF")
|
|
frame:RegisterEvent("DEBUFF_REMOVED_SELF")
|
|
end
|
|
end
|
|
|
|
local function MTH_EA_HandlePlayerEnteringWorld()
|
|
MTH_EA_EnsureConfigDefaults()
|
|
|
|
-- Class gate: only hunters may use Experimental Ammunition.
|
|
if MTH and MTH.IsClassGateBlocked and MTH:IsClassGateBlocked() then
|
|
if strip then strip:Hide() end
|
|
return
|
|
end
|
|
|
|
rt.state = IDLE
|
|
rt.expiresAt = 0
|
|
rt.consumed = false
|
|
rt.lastState = nil
|
|
MTH_EA_LastSpell = nil
|
|
lnl.active = false
|
|
lnlPendingCapture = false
|
|
MTH_EA_ClearActionSlotCache()
|
|
MTH_EA_SnapshotBuffs()
|
|
|
|
-- Update talent flag from whatever the scanner has so far.
|
|
-- We do NOT gate strip visibility on this: GetTalentInfo can return
|
|
-- stale data briefly on login before the client finishes loading
|
|
-- talent data. Show the strip if enabled; MTH_HT_OnScanComplete
|
|
-- will hide it later if the talent is truly absent after a proper scan.
|
|
MTH_EA_HasTalent = MTH_HT_HasTalent and MTH_HT_HasTalent(TALENT_NAME) or false
|
|
|
|
if not MTH_EA_IsEnabled() then return end
|
|
|
|
MTH_EA_CreateUI()
|
|
-- Recover any active ammo aura (survives /reload in same session)
|
|
if MTH_EA_UseNamPower then
|
|
MTH_EA_RecoverStateFromAuraArray()
|
|
else
|
|
MTH_EA_RecoverStateFromBuffs()
|
|
end
|
|
inCombat = UnitAffectingCombat and UnitAffectingCombat("player") and true or false
|
|
frame:Show()
|
|
MTH_EA_RefreshVisibility()
|
|
MTH_EA_UpdateUI()
|
|
end
|
|
|
|
frame:SetScript("OnEvent", function()
|
|
if event == "VARIABLES_LOADED" then
|
|
MTH_EA_HandleVarsLoaded()
|
|
return
|
|
end
|
|
|
|
if event == "PLAYER_ENTERING_WORLD" then
|
|
MTH_EA_HandlePlayerEnteringWorld()
|
|
return
|
|
end
|
|
|
|
if event == "ACTIONBAR_SLOT_CHANGED" then
|
|
MTH_EA_ClearActionSlotCache()
|
|
return
|
|
end
|
|
|
|
if event == "PLAYER_TARGET_CHANGED" then
|
|
MTH_EA_BlockedStates = {}
|
|
if UnitExists("target") then
|
|
local creatureType = UnitCreatureType("target")
|
|
if creatureType and CREATURE_TYPE_IMMUNITIES[creatureType] then
|
|
for st, _ in pairs(CREATURE_TYPE_IMMUNITIES[creatureType]) do
|
|
MTH_EA_BlockedStates[st] = true
|
|
end
|
|
end
|
|
end
|
|
MTH_EA_UpdateUI()
|
|
return
|
|
end
|
|
|
|
if event == "CHAT_MSG_SPELL_SELF_DAMAGE" then
|
|
local msg = arg1 or ""
|
|
if string.find(msg, "immune", 1, true) then
|
|
for state, spell in pairs(CONSUME_SPELL) do
|
|
if string.find(msg, spell, 1, true) then
|
|
MTH_EA_BlockedStates[state] = true
|
|
MTH_EA_UpdateUI()
|
|
break
|
|
end
|
|
end
|
|
end
|
|
return
|
|
end
|
|
|
|
if event == "PLAYER_REGEN_DISABLED" then
|
|
-- Entering combat
|
|
inCombat = true
|
|
MTH_EA_RefreshVisibility()
|
|
return
|
|
end
|
|
|
|
if event == "PLAYER_REGEN_ENABLED" then
|
|
-- Combat ended. The server does NOT reset the ammo sequence — it resumes
|
|
-- exactly where it left off. Keep rt.state and rt.lastState as-is.
|
|
inCombat = false
|
|
MTH_EA_RefreshVisibility()
|
|
return
|
|
end
|
|
|
|
if event == "PLAYER_AURAS_CHANGED" then
|
|
if not MTH_EA_IsEnabled() then return end
|
|
|
|
-- Throttle: cap processing at ~6/sec. Chat-message handlers cover all
|
|
-- real-time state transitions; this path is only for /reload recovery
|
|
-- and LnL sync, so a 150 ms gate is perfectly safe.
|
|
local now = GetTime()
|
|
if (now - MTH_EA_AurasChangedLastTime) < 0.15 then return end
|
|
MTH_EA_AurasChangedLastTime = now
|
|
|
|
-- Pending LnL icon capture (no-op stub; kept to avoid errors).
|
|
if lnlPendingCapture then
|
|
MTH_EA_TryCaptureNewBuffIcon()
|
|
end
|
|
|
|
-- ── LnL sync: texture-based, zero tooltip cost ───────────────────
|
|
-- We only SET active here (initial gain / missed chat message).
|
|
-- LOSS is authoritative from the AURA_GONE_SELF chat handler + OnUpdate
|
|
-- expiry, so we deliberately do NOT clear lnl.active when not found —
|
|
-- AURAS_CHANGED fires for every unrelated buff change and a transient
|
|
-- miss during slot reordering would prematurely kill the timer.
|
|
if MTH_EA_HasLnLByTexture() then
|
|
if not lnl.active then
|
|
lnl.active = true
|
|
lnl.expiresAt = now + lnl.DURATION
|
|
end
|
|
lnlPendingCapture = false
|
|
end
|
|
|
|
-- ── Recovery: resync ammo state after /reload ─────────────────────
|
|
if MTH_EA_UseNamPower then
|
|
MTH_EA_RecoverStateFromAuraArray()
|
|
else
|
|
MTH_EA_RecoverStateFromBuffs()
|
|
end
|
|
MTH_EA_UpdateUI()
|
|
return
|
|
end
|
|
|
|
-- ── NamPower aura events ──────────────────────────────────────────
|
|
if event == "BUFF_ADDED_SELF" or event == "DEBUFF_ADDED_SELF" then
|
|
local spellId = tonumber(arg3)
|
|
if spellId and spellId > 0 then
|
|
MTH_EA_CacheSpellId(spellId)
|
|
-- LnL proc / re-proc: get authoritative remaining time.
|
|
if spellId == MTH_EA_LnLSpellId then
|
|
lnl.active = true
|
|
local rem = MTH_EA_GetLnLDurationNP()
|
|
lnl.expiresAt = GetTime() + (rem or lnl.DURATION)
|
|
end
|
|
end
|
|
MTH_EA_RequestAuraRescan(0.05)
|
|
return
|
|
end
|
|
|
|
if event == "BUFF_REMOVED_SELF" or event == "DEBUFF_REMOVED_SELF" then
|
|
local spellId = tonumber(arg3)
|
|
if spellId and spellId > 0 then
|
|
MTH_EA_CacheSpellId(spellId)
|
|
-- LnL consumed or expired: clear immediately, don't wait for rescan.
|
|
if spellId == MTH_EA_LnLSpellId then
|
|
lnl.active = false
|
|
end
|
|
end
|
|
MTH_EA_RequestAuraRescan(0.05)
|
|
return
|
|
end
|
|
|
|
-- ── Primary state detection via combat log messages ───────────────
|
|
-- (MTH_EA_TryHandleChatMsg is defined at module level to stay under
|
|
-- Lua 5.0's 32-upvalue-per-closure limit.)
|
|
|
|
if event == "CHAT_MSG_SPELL_SELF_BUFF"
|
|
or event == "CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE"
|
|
or event == "CHAT_MSG_SPELL_AURA_GONE_SELF"
|
|
or event == "CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS"
|
|
or event == "CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF" then
|
|
MTH_EA_TryHandleChatMsg(arg1)
|
|
return
|
|
end
|
|
|
|
-- ───────────────────────────────────────────────────────────
|
|
|
|
if not MTH_EA_IsEnabled() then return end
|
|
|
|
-- We rely on MTH_EA_LastSpell captured in our CastSpellByName / CastSpell /
|
|
-- UseAction hooks. Do NOT fall back to MTH_SA_LastSpell — SmartAmmo clears
|
|
-- its capture on SPELLCAST_STOP in undefined order relative to us, making it
|
|
-- either stale (previous cast's name) or nil depending on frame event order.
|
|
local spellName = MTH_EA_LastSpell or ""
|
|
|
|
if event == "SPELLCAST_STOP" then
|
|
-- Predictive: show the expected next ammo state immediately when Aimed Shot
|
|
-- fires from IDLE, before the server debuff message arrives (~1s later).
|
|
-- We predict based on lastState so we show the correct one (not always EXPLOSIVE):
|
|
-- no prior cycle / after ENCHANTED → EXPLOSIVE
|
|
-- after EXPLOSIVE → POISONOUS
|
|
-- after POISONOUS → ENCHANTED
|
|
if spellName == SPELL_AIMED and rt.state == IDLE then
|
|
local predicted = PREDICT_NEXT[rt.lastState] or EXPLOSIVE
|
|
MTH_EA_SetState(predicted)
|
|
-- Schedule a verification rescan: if the predicted debuff never arrives
|
|
-- from the server within 1.5 s, the hard aura check will catch it and
|
|
-- clear back to IDLE. This prevents phantom states from persisting for
|
|
-- up to 60 s when the spell name capture was stale or the server did not
|
|
-- apply the expected debuff.
|
|
MTH_EA_PredictVerifyAt = GetTime() + 1.5
|
|
end
|
|
-- Consume-spell advancement (NamPower path):
|
|
-- Do not advance immediately here. The aura can still be present for a
|
|
-- short moment after the cast completes, so we defer to a live aura
|
|
-- rescan and only update the state when the player aura actually changes.
|
|
if MTH_EA_UseNamPower and rt.state ~= IDLE then
|
|
local consumeSpell = CONSUME_SPELL[rt.state]
|
|
if consumeSpell and spellName == consumeSpell then
|
|
MTH_EA_PendingAuraRescan = 0.20
|
|
end
|
|
end
|
|
MTH_EA_LastSpell = nil
|
|
|
|
elseif event == "SPELLCAST_FAILED" or event == "SPELLCAST_INTERRUPTED" then
|
|
-- Aimed Shot interrupted: roll back any predictive state set within the last 0.5s.
|
|
if spellName == SPELL_AIMED and rt.state ~= IDLE then
|
|
local elapsed = GetTime() - (rt.expiresAt - CYCLE_DURATION)
|
|
if elapsed < 0.5 then
|
|
MTH_EA_SetState(IDLE)
|
|
end
|
|
end
|
|
MTH_EA_LastSpell = nil
|
|
end
|
|
end)
|
|
|
|
-- ── Talent re-check on respec ───────────────────────────────
|
|
-- MTH_HT_OnScanComplete is called by core-scan-huntertalents.lua
|
|
-- after every talent/spellbook scan (login, respec, PLAYER_ENTERING_WORLD).
|
|
-- We piggyback on it to update the talent gate without polling.
|
|
local MTH_EA_PrevOnScanComplete = nil
|
|
local function MTH_EA_InstallScanHook()
|
|
MTH_EA_PrevOnScanComplete = MTH_HT_OnScanComplete -- chain any existing handler
|
|
MTH_HT_OnScanComplete = function()
|
|
if MTH_EA_PrevOnScanComplete then
|
|
MTH_EA_PrevOnScanComplete()
|
|
end
|
|
local hasTalent = MTH_HT_HasTalent and MTH_HT_HasTalent(TALENT_NAME) or false
|
|
if hasTalent == MTH_EA_HasTalent then return end -- no change
|
|
MTH_EA_HasTalent = hasTalent
|
|
if not strip then return end
|
|
if MTH_EA_IsEnabled() and hasTalent then
|
|
MTH_EA_RefreshVisibility()
|
|
else
|
|
if not hasTalent then MTH_EA_SetState(IDLE) end
|
|
strip:Hide()
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Install the hook after VARIABLES_LOADED (globals are guaranteed by then).
|
|
-- The event frame already handles VARIABLES_LOADED, so we add a secondary
|
|
-- one-shot frame here to avoid ordering issues.
|
|
do
|
|
local hookFrame = CreateFrame("Frame")
|
|
hookFrame:RegisterEvent("VARIABLES_LOADED")
|
|
hookFrame:SetScript("OnEvent", function()
|
|
MTH_EA_InstallScanHook()
|
|
hookFrame:UnregisterAllEvents()
|
|
end)
|
|
end
|
|
|
|
-- ── Public API ────────────────────────────────────────────────
|
|
MTH_ExpAmmo = {
|
|
SetEnabled = function(enabled)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.enabled = enabled and true or false
|
|
if cfg.enabled then
|
|
MTH_EA_CreateUI()
|
|
frame:Show()
|
|
MTH_EA_RefreshVisibility()
|
|
elseif strip then
|
|
strip:Hide()
|
|
end
|
|
end,
|
|
IsEnabled = MTH_EA_IsEnabled,
|
|
GetState = function() return rt.state end,
|
|
ResetCycle = function() MTH_EA_SetState(IDLE) end,
|
|
SetShowLnL = function(v)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.showLnL = v and true or false
|
|
MTH_EA_ApplyLayout()
|
|
end,
|
|
SetShowHint = function(v)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.showHint = v and true or false
|
|
MTH_EA_ApplyLayout()
|
|
end,
|
|
SetCellSize = function(v)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.cellSize = math.max(20, math.min(80, tonumber(v) or 40))
|
|
MTH_EA_ApplyLayout()
|
|
end,
|
|
SetCellGap = function(v)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.cellGap = math.max(0, math.min(20, tonumber(v) or 2))
|
|
MTH_EA_ApplyLayout()
|
|
end,
|
|
SetBigCD = function(v)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.bigCD = v and true or false
|
|
MTH_EA_ApplyLayout()
|
|
end,
|
|
SetHideOOC = function(v)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.hideOOC = v and true or false
|
|
MTH_EA_RefreshVisibility()
|
|
end,
|
|
SetUseQuiverNoClip = function(v)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.useQuiverNoClip = v and true or false
|
|
end,
|
|
SetBlockExplosive = function(v)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.blockExplosive = v and true or false
|
|
MTH_EA_UpdateUI()
|
|
end,
|
|
SetBlockPoisonous = function(v)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.blockPoisonous = v and true or false
|
|
MTH_EA_UpdateUI()
|
|
end,
|
|
SetBlockEnchanted = function(v)
|
|
local cfg = MTH_EA_GetConfig()
|
|
cfg.blockEnchanted = v and true or false
|
|
MTH_EA_UpdateUI()
|
|
end,
|
|
ToggleAnchor = MTH_EA_ToggleAnchor,
|
|
}
|