mirror of
https://codeberg.org/Duvelcorp/MetaHunt.git
synced 2026-09-22 07:36:58 +00:00
1120 lines
40 KiB
Lua
1120 lines
40 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_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" },
|
||
}
|
||
|
||
-- ── 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
|
||
}
|
||
|
||
-- ── 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, "([^\\]+)$")
|
||
return base or s
|
||
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 base == frag 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 base == frag 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 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
|
||
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.bigCD == nil then cfg.bigCD = false end
|
||
if cfg.hideOOC == nil then cfg.hideOOC = false end
|
||
end
|
||
|
||
-- Combat state flag (avoids needing InCombatLockdown, which doesn't exist in 1.12)
|
||
local inCombat = false
|
||
|
||
-- 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 drag frame (sized to 3×1 row)
|
||
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 button cell
|
||
local cellBotIcon = nil -- action button icon texture
|
||
local cellBotCD = nil -- action button CD / "READY" text
|
||
local cellBotBlink = 0 -- blink phase accumulator
|
||
|
||
-- 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 ─────────────────────────────────────────────
|
||
local function MTH_EA_SetState(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
|
||
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")
|
||
}
|
||
|
||
-- ── Confirmed icon paths (captured in-game 2026-03-21) ────────
|
||
-- Action-button spells (spellbook scan)
|
||
local ICON_AIMED = "Interface\\Icons\\INV_Spear_07"
|
||
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 button: what spell to show / cast right now
|
||
local function MTH_EA_GetActionSpell()
|
||
if rt.state == EXPLOSIVE then
|
||
return SPELL_MULTI, ICON_MULTI, 1.00, 0.60, 0.05
|
||
elseif rt.state == POISONOUS then
|
||
return SPELL_SERPENT, ICON_SERPENT, 0.12, 0.85, 0.18
|
||
elseif rt.state == ENCHANTED then
|
||
return SPELL_ARCANE, ICON_ARCANE, 0.38, 0.12, 1.00
|
||
else
|
||
return SPELL_AIMED, ICON_AIMED, 1.00, 0.85, 0.00
|
||
end
|
||
end
|
||
|
||
-- Called from the keybind and from clicking the action button
|
||
function MTH_EA_CastActionSpell()
|
||
local spellName = MTH_EA_GetActionSpell()
|
||
if spellName then 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",
|
||
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
|
||
edgeSize = 10,
|
||
insets = { left = 2, right = 2, top = 2, bottom = 2 },
|
||
})
|
||
c:SetBackdropColor(0, 0, 0, 0.65)
|
||
c:SetBackdropBorderColor(0.3, 0.3, 0.3, 0.5)
|
||
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(true)
|
||
strip:RegisterForDrag("LeftButton")
|
||
strip:SetScript("OnDragStart", function()
|
||
if IsAltKeyDown() then this:StartMoving() end
|
||
end)
|
||
strip:SetScript("OnDragStop", function()
|
||
this:StopMovingOrSizing()
|
||
local cfg = MTH_EA_GetConfig()
|
||
local point, _, relPoint, x, y = this: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
|
||
end)
|
||
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 button ───────────────────────────
|
||
cellBot = MTH_EA_MakeCell("MTH_ExpAmmoBot", strip)
|
||
cellBot:SetPoint("TOP", cells[2], "BOTTOM", 0, -CELL_GAP)
|
||
cellBot:EnableMouse(true)
|
||
cellBot:SetScript("OnMouseUp", function()
|
||
if arg1 == "LeftButton" then MTH_EA_CastActionSpell() end
|
||
end)
|
||
cellBotIcon = cellBot.icon
|
||
cellBotCD = cellBot.cd
|
||
cellBotIcon:SetTexture(ICON_AIMED)
|
||
cellBotIcon:SetVertexColor(1, 0.85, 0, 0.6)
|
||
|
||
-- Tooltip on the action button
|
||
cellBot:SetScript("OnEnter", function()
|
||
local spellName = MTH_EA_GetActionSpell()
|
||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
||
GameTooltip:SetText(spellName or "")
|
||
GameTooltip:AddLine("Left-click or use keybind", 0.6, 0.6, 0.6)
|
||
GameTooltip:Show()
|
||
end)
|
||
cellBot:SetScript("OnLeave", function() GameTooltip:Hide() end)
|
||
|
||
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
|
||
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")
|
||
else
|
||
cellTop.icon:SetVertexColor(0.28, 0.28, 0.28, 0.40)
|
||
cellTop:SetBackdropBorderColor(0.25, 0.25, 0.25, 0.35)
|
||
cellTopCD:SetText("")
|
||
end
|
||
end
|
||
|
||
-- ── BOTTOM cell: action button ────────────────────────────
|
||
if cellBot then
|
||
local spellName, icon, r, g, b = MTH_EA_GetActionSpell()
|
||
cellBotIcon:SetTexture(icon)
|
||
|
||
if rt.state == IDLE then
|
||
-- Show Aimed Shot with CD and blink when ready
|
||
local cdRemain = 0
|
||
if type(GetSpellCooldown) == "function" and MTH_HT_GetSpellInfo then
|
||
local info = MTH_HT_GetSpellInfo(SPELL_AIMED)
|
||
if info and info.slot then
|
||
local start, duration = GetSpellCooldown(info.slot, BOOKTYPE_SPELL)
|
||
-- Ignore GCD-length cooldowns (≤1.5 s) triggered by other casts.
|
||
-- Aimed Shot's real cooldown is always longer than the GCD.
|
||
if start and duration and duration > 1.5 then
|
||
cdRemain = math.max(0, (start + duration) - now)
|
||
end
|
||
end
|
||
end
|
||
if cdRemain > 0 then
|
||
-- On cooldown: dim, show seconds
|
||
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("|cff00ff00READY|r")
|
||
end
|
||
else
|
||
-- Ammo active: show consume spell, fully lit
|
||
cellBotBlink = 0
|
||
cellBotIcon:SetVertexColor(r, g, b, 1)
|
||
cellBot:SetBackdropBorderColor(r, g, b, 1)
|
||
cellBotCD:SetText("|cffffe066USE|r")
|
||
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 bigCD = cfg.bigCD == true
|
||
local font = CELL_FONT
|
||
|
||
strip:SetWidth(sz * 3 + CELL_GAP * 2)
|
||
strip:SetHeight(sz * 3 + CELL_GAP * 2)
|
||
|
||
for i = 1, 3 do
|
||
local c = cells[i]
|
||
local fs = cdTexts[i]
|
||
c:SetWidth(sz)
|
||
c:SetHeight(sz)
|
||
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)
|
||
if cfg.showLnL ~= false then cellTop:Show() else cellTop:Hide() end
|
||
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)
|
||
if cfg.showHint ~= false then cellBot:Show() else cellBot:Hide() end
|
||
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()
|
||
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 MTH_EA_OrigCSBN = CastSpellByName
|
||
local MTH_EA_OrigCS = CastSpell
|
||
local MTH_EA_OrigUA = UseAction
|
||
|
||
local function MTH_EA_HookInstallCSBN()
|
||
CastSpellByName = function(spellText, onSelf)
|
||
if MTH_EA_InHookCSBN then
|
||
local f = MTH_EA_OrigCSBN
|
||
if f and f ~= CastSpellByName then return f(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
|
||
local upstream = MTH_EA_OrigCSBN
|
||
-- Always call the current global so Quiver/SmartAmmo's chain is respected
|
||
local cur = CastSpellByName
|
||
MTH_EA_InHookCSBN = false
|
||
if upstream and upstream ~= cur then
|
||
return upstream(spellText, onSelf)
|
||
elseif cur ~= MTH_EA_HookInstallCSBN then
|
||
-- another addon replaced us; call them
|
||
end
|
||
-- fallback: nothing more to call (we ARE the last in chain)
|
||
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
|
||
|
||
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
|
||
if type(GetActionText) == "function" and type(GetSpellName) == "function" then
|
||
-- For spell-type actions, resolve name via tooltip probe
|
||
local probe = MTH_SA_AmmoProbe -- reuse SmartAmmo's tooltip probe if available
|
||
if probe then
|
||
probe:SetOwner(UIParent, "ANCHOR_NONE")
|
||
probe:SetAction(slot)
|
||
local txt = MTH_SA_AmmoProbeTextLeft1 and MTH_SA_AmmoProbeTextLeft1:GetText()
|
||
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
|
||
|
||
-- ── Debug mode ────────────────────────────────────────────────
|
||
-- /script MTH_EA_DebugMode = true → prints every Ammunition chat event
|
||
-- /script MTH_EA_DebugMode = false → silent
|
||
MTH_EA_DebugMode = false
|
||
|
||
-- ── Broad debug sniffer ───────────────────────────────────────
|
||
-- A separate frame that registers EVERY CHAT_MSG_SPELL_* event and
|
||
-- prints any message containing "Ammunition", so we can find exactly
|
||
-- which event the server uses for the gain messages.
|
||
local MTH_EA_Sniffer = CreateFrame("Frame", "MTH_EA_SnifferFrame")
|
||
local MTH_EA_SNIFFER_EVENTS = {
|
||
"CHAT_MSG_SPELL_SELF_BUFF",
|
||
"CHAT_MSG_SPELL_AURA_GONE_SELF",
|
||
"CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS",
|
||
"CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE",
|
||
"CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF",
|
||
"CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF",
|
||
"CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF",
|
||
"CHAT_MSG_SPELL_AURA_GONE_PARTY",
|
||
"CHAT_MSG_SPELL_AURA_GONE_OTHER",
|
||
}
|
||
for _, ev in ipairs(MTH_EA_SNIFFER_EVENTS) do
|
||
MTH_EA_Sniffer:RegisterEvent(ev)
|
||
end
|
||
MTH_EA_Sniffer:SetScript("OnEvent", function()
|
||
if not MTH_EA_DebugMode then return end
|
||
local msg = arg1 or ""
|
||
if string.find(msg, "Ammunition") then
|
||
end
|
||
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: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
|
||
|
||
-- 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)
|
||
cellBotBlink = 0
|
||
end
|
||
|
||
-- LnL natural expiry (belt-and-suspenders; AURA_GONE_SELF should cover it)
|
||
if lnl.active and GetTime() >= lnl.expiresAt then
|
||
lnl.active = false
|
||
end
|
||
|
||
if this._elapsed < 0.25 then return end
|
||
this._elapsed = 0
|
||
MTH_EA_UpdateUI()
|
||
end)
|
||
|
||
frame:SetScript("OnEvent", function()
|
||
if event == "VARIABLES_LOADED" then
|
||
-- Install hooks once SavedVariables are available
|
||
MTH_EA_HookInstallCSBN()
|
||
MTH_EA_HookInstallCS()
|
||
MTH_EA_HookInstallUA()
|
||
return
|
||
end
|
||
|
||
if event == "PLAYER_ENTERING_WORLD" then
|
||
MTH_EA_EnsureConfigDefaults()
|
||
rt.state = IDLE
|
||
rt.expiresAt = 0
|
||
rt.consumed = false
|
||
rt.lastState = nil
|
||
MTH_EA_LastSpell = nil
|
||
lnl.active = false
|
||
lnlPendingCapture = false
|
||
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
|
||
|
||
MTH_EA_CreateUI()
|
||
if MTH_EA_IsEnabled() then
|
||
-- Recover any active ammo aura (survives /reload in same session)
|
||
MTH_EA_RecoverStateFromBuffs()
|
||
inCombat = UnitAffectingCombat and UnitAffectingCombat("player") and true or false
|
||
frame:Show()
|
||
MTH_EA_RefreshVisibility()
|
||
else
|
||
strip:Hide()
|
||
end
|
||
MTH_EA_UpdateUI()
|
||
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 rt.state == IDLE then
|
||
local stateKey = MTH_EA_FindAmmoStateByTexture()
|
||
if stateKey then
|
||
rt.state = stateKey
|
||
rt.expiresAt = now + CYCLE_DURATION
|
||
rt.consumed = false
|
||
end
|
||
MTH_EA_UpdateUI()
|
||
end
|
||
return
|
||
end
|
||
|
||
-- ── Primary state detection via combat log messages ───────────────
|
||
-- On TWoW/MaNGOS the event routing for debuffs may differ from
|
||
-- vanilla. We therefore check BOTH gain and loss patterns in
|
||
-- every chat_msg_spell event we receive, and match regardless of
|
||
-- which specific event name the server chose to use.
|
||
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
|
||
-- Ammo state?
|
||
local stateKey = BUFF_TO_STATE[gained]
|
||
if stateKey then
|
||
if MTH_EA_DebugMode then
|
||
end
|
||
MTH_EA_SetState(stateKey)
|
||
return true
|
||
end
|
||
-- Lock and Load? Chat message is a late-arriving fallback;
|
||
-- PLAYER_AURAS_CHANGED + tooltip scan is the primary path.
|
||
if string.find(gained, "Lock and Load") then
|
||
if not lnl.active then
|
||
-- Fallback: AURAS_CHANGED missed it; sync from tooltip now.
|
||
local rem = MTH_EA_GetLnLRemaining()
|
||
lnl.active = true
|
||
lnl.expiresAt = GetTime() + (rem or lnl.DURATION)
|
||
end
|
||
if MTH_EA_DebugMode then
|
||
end
|
||
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$") -- no trailing period
|
||
end
|
||
if lost then
|
||
-- Ammo state?
|
||
local stateKey = BUFF_TO_STATE[lost]
|
||
if stateKey then
|
||
if MTH_EA_DebugMode then
|
||
end
|
||
if rt.state == stateKey then
|
||
MTH_EA_SetState(IDLE)
|
||
end
|
||
return true
|
||
end
|
||
-- Lock and Load?
|
||
if string.find(lost, "Lock and Load") then
|
||
if MTH_EA_DebugMode then
|
||
end
|
||
lnl.active = false
|
||
lnlPendingCapture = false
|
||
MTH_EA_SnapshotBuffs() -- reset baseline; LnL no longer on player
|
||
return true
|
||
end
|
||
end
|
||
-- Debug: print any Ammunition message even if unmatched
|
||
if MTH_EA_DebugMode then
|
||
if string.find(msg, "Ammunition") then
|
||
end
|
||
end
|
||
return false
|
||
end
|
||
|
||
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 hook.
|
||
-- Also check MTH_SA_LastSpell as a fallback (SmartAmmo's capture).
|
||
local spellName = MTH_EA_LastSpell
|
||
or (type(MTH_SA_LastSpell) == "string" and MTH_SA_LastSpell)
|
||
or ""
|
||
|
||
if event == "SPELLCAST_STOP" then
|
||
if MTH_EA_DebugMode then
|
||
end
|
||
-- 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
|
||
if MTH_EA_DebugMode then
|
||
end
|
||
MTH_EA_SetState(predicted)
|
||
end
|
||
-- Consume-spell detection removed: CHAT_MSG_SPELL_AURA_GONE_SELF handles
|
||
-- state loss authoritatively. No AdvanceState here.
|
||
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 strip then
|
||
if cfg.enabled then
|
||
MTH_EA_RefreshVisibility()
|
||
else
|
||
strip:Hide()
|
||
end
|
||
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,
|
||
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,
|
||
}
|