607 lines
18 KiB
Lua
607 lines
18 KiB
Lua
if not BNP.libnameplate then return end
|
|
if not CombatLogAdd or not SpellInfo then return end
|
|
|
|
local _, playerClass = UnitClass("player")
|
|
local UI = BNP.UI or {}
|
|
local ICON_SIZE = UI.ICON_SIZE or 18
|
|
local function GetIconSize()
|
|
return (BNP.GetIconSize and BNP:GetIconSize()) or ICON_SIZE
|
|
end
|
|
local ICON_OFFSET_Y = UI.ICON_OFFSET_Y or 15
|
|
local ICON_SPACING = UI.ICON_SPACING or 2
|
|
local UPDATE_INTERVAL = 0.05
|
|
|
|
-- SuperWoW GUID cache. Auras are learned while a unit is the current target
|
|
-- and remain attached to that unit's unique GUID after the target changes.
|
|
BNP.guidAuras = BNP.guidAuras or {}
|
|
|
|
local CLASS_AURAS = {
|
|
WARLOCK = BNP.WarlockAuras,
|
|
PRIEST = BNP.PriestAuras,
|
|
WARRIOR = BNP.WarriorAuras,
|
|
ROGUE = BNP.RogueAuras,
|
|
HUNTER = BNP.HunterAuras,
|
|
MAGE = BNP.MageAuras,
|
|
DRUID = BNP.DruidAuras,
|
|
SHAMAN = BNP.ShamanAuras,
|
|
PALADIN = BNP.PaladinAuras,
|
|
}
|
|
local AURA_DEFS = CLASS_AURAS[playerClass] or {}
|
|
local MAX_VISIBLE_ICONS = 5
|
|
|
|
local function CleanText(text)
|
|
if not text then return nil end
|
|
text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "")
|
|
text = string.gsub(text, "|r", "")
|
|
return text
|
|
end
|
|
|
|
local function NameMatches(def, name)
|
|
if not name then return false end
|
|
local lower = string.lower(name)
|
|
for _, known in ipairs(def.names) do
|
|
if lower == string.lower(known) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function AuraMatches(def, effect, texture)
|
|
if NameMatches(def, effect) then return true end
|
|
if texture and def.textureMatch then
|
|
return string.find(string.lower(texture), def.textureMatch) and true or false
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function ScanSpellbook()
|
|
if table.getn(AURA_DEFS) == 0 then return end
|
|
|
|
local i = 1
|
|
while true do
|
|
local name = GetSpellName(i, BOOKTYPE_SPELL)
|
|
if not name then break end
|
|
|
|
local texture = GetSpellTexture(i, BOOKTYPE_SPELL)
|
|
local lowerTexture = texture and string.lower(texture) or ""
|
|
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
if NameMatches(def, name) or (def.textureMatch and string.find(lowerTexture, def.textureMatch)) then
|
|
def.localizedName = name
|
|
def.texture = texture or def.texture
|
|
end
|
|
end
|
|
|
|
i = i + 1
|
|
end
|
|
if BNP.RefreshSpellbookDurations then BNP:RefreshSpellbookDurations() end
|
|
end
|
|
|
|
local function CreateAuraIcon(parent, index)
|
|
local icon = CreateFrame("Frame", nil, parent)
|
|
local size = GetIconSize()
|
|
icon:SetWidth(size)
|
|
icon:SetHeight(size)
|
|
icon:SetFrameLevel(40)
|
|
|
|
local texture = icon:CreateTexture(nil, "ARTWORK")
|
|
texture:SetAllPoints(icon)
|
|
icon.texture = texture
|
|
|
|
local timer = icon:CreateFontString(nil, "OVERLAY")
|
|
timer:SetFont(UI.TIMER_FONT or "Fonts\\FRIZQT__.TTF", UI.TIMER_SIZE or 8, "OUTLINE")
|
|
timer:SetPoint("CENTER", icon, "CENTER", UI.TIMER_OFFSET_X or 0, UI.TIMER_OFFSET_Y or 0)
|
|
timer:SetTextColor(1, 1, 1)
|
|
icon.timer = timer
|
|
icon.index = index
|
|
icon:Hide()
|
|
|
|
return icon
|
|
end
|
|
|
|
local function CreateAuraContainer(plate)
|
|
if not plate then return end
|
|
BNP:RegisterPlate(plate)
|
|
if plate.BNPAuraContainer then return end
|
|
|
|
local size = GetIconSize()
|
|
local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1)
|
|
local container = CreateFrame("Frame", nil, plate)
|
|
container:SetWidth(width)
|
|
container:SetHeight(size)
|
|
container:SetFrameLevel(40)
|
|
|
|
if plate.healthbar then
|
|
container:SetPoint("BOTTOM", plate.healthbar, "TOP", 0, ICON_OFFSET_Y)
|
|
else
|
|
container:SetPoint("BOTTOM", plate, "BOTTOM", 0, 24)
|
|
end
|
|
|
|
container.icons = {}
|
|
for i = 1, MAX_VISIBLE_ICONS do
|
|
local icon = CreateAuraIcon(container, i)
|
|
if i == 1 then
|
|
icon:SetPoint("LEFT", container, "LEFT", 0, 0)
|
|
else
|
|
icon:SetPoint("LEFT", container.icons[i - 1], "RIGHT", ICON_SPACING, 0)
|
|
end
|
|
container.icons[i] = icon
|
|
end
|
|
|
|
container:Hide()
|
|
plate.BNPAuraContainer = container
|
|
end
|
|
|
|
local function GetPlateGUID(plate)
|
|
if not plate or not plate.GetName then return nil end
|
|
local token = plate:GetName(1)
|
|
if not token then return nil end
|
|
local exists, guid = UnitExists(token)
|
|
if exists and guid then return guid end
|
|
return nil
|
|
end
|
|
|
|
local function GetTargetGUID()
|
|
local exists, guid = UnitExists("target")
|
|
if exists and guid then return guid end
|
|
return nil
|
|
end
|
|
|
|
-- Only successful casts by the local player are allowed to create aura
|
|
-- entries. This prevents target scans from importing debuffs cast by other
|
|
-- players. SuperWoW supplies caster GUID, target GUID and spell ID.
|
|
local function GetPlayerGUID()
|
|
local exists, guid = UnitExists("player")
|
|
if exists and guid then return guid end
|
|
return nil
|
|
end
|
|
|
|
local function FindAuraDef(spellID)
|
|
if not spellID or not SpellInfo then return nil, nil end
|
|
local spellName, _, texture = SpellInfo(spellID)
|
|
if not spellName and not texture then return nil, nil end
|
|
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
if AuraMatches(def, spellName, texture) then
|
|
return def, texture, spellID
|
|
end
|
|
end
|
|
|
|
return nil, nil
|
|
end
|
|
|
|
|
|
-- Turtle WoW Dark Harvest (spell 52552).
|
|
-- It is reported by SuperWoW as CHANNEL; arg5 is channel length in ms.
|
|
-- A 30% shorter tick interval means aura time advances at 1/0.70 speed.
|
|
local DARK_HARVEST_SPELL_ID = 52552
|
|
local DARK_HARVEST_SPEED = 1 / 0.70
|
|
local DARK_HARVEST_AURAS = {
|
|
corruption=true, curse_of_agony=true, siphon_life=true,
|
|
curse_of_doom=true, drain_life=true, drain_soul=true,
|
|
}
|
|
BNP.darkHarvest = nil
|
|
|
|
local function StartDarkHarvest(guid, durationMS)
|
|
local now=GetTime()
|
|
local duration=tonumber(durationMS) or 8000
|
|
if duration > 100 then duration=duration/1000 end
|
|
BNP.darkHarvest={guid=guid, lastUpdate=now, ends=now+duration}
|
|
end
|
|
|
|
local function UpdateDarkHarvest(now)
|
|
local st=BNP.darkHarvest
|
|
if not st then return end
|
|
local untilTime=now
|
|
if untilTime > st.ends then untilTime=st.ends end
|
|
local delta=untilTime-st.lastUpdate
|
|
if delta > 0 then
|
|
local cache=BNP.guidAuras[st.guid]
|
|
if cache then
|
|
local bonus=delta*(DARK_HARVEST_SPEED-1)
|
|
local key,aura
|
|
for key,aura in pairs(cache) do
|
|
if DARK_HARVEST_AURAS[key] and type(aura)=="table" and aura.expires and aura.expires > st.lastUpdate then
|
|
aura.expires=aura.expires-bonus
|
|
end
|
|
end
|
|
end
|
|
st.lastUpdate=untilTime
|
|
end
|
|
if now >= st.ends then BNP.darkHarvest=nil end
|
|
end
|
|
|
|
|
|
-- Cast confirmation ---------------------------------------------------------
|
|
-- UNIT_CASTEVENT "CAST" means the player finished the cast, but it does not
|
|
-- guarantee that the hostile aura actually landed. Resist/Miss/Immune must
|
|
-- therefore never create a visible timer.
|
|
--
|
|
-- We first remember the cast as "pending". It becomes a real aura only after
|
|
-- UnitDebuff confirms the same spell ID on that exact SuperWoW GUID.
|
|
BNP.pendingAuras = BNP.pendingAuras or {}
|
|
local PENDING_TIMEOUT = 2.0
|
|
|
|
local function HasPendingAuras()
|
|
local guid, entries, key, pending
|
|
for guid, entries in pairs(BNP.pendingAuras) do
|
|
for key, pending in pairs(entries) do
|
|
if type(pending) == "table" then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function GetUnitTokenForPlate(plate)
|
|
if not plate or not plate.GetName then return nil end
|
|
return plate:GetName(1)
|
|
end
|
|
|
|
local function ConfirmedSpellOnUnit(unit, spellID)
|
|
if not unit or not spellID then return false end
|
|
local i
|
|
for i = 1, 32 do
|
|
local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i)
|
|
if not texture then break end
|
|
if auraSpellID == spellID then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function CommitPendingAura(guid, key, pending)
|
|
if not guid or not pending then return end
|
|
|
|
local def = pending.def
|
|
local cache = BNP.guidAuras[guid]
|
|
if not cache then
|
|
cache = {}
|
|
BNP.guidAuras[guid] = cache
|
|
end
|
|
|
|
-- Only replace mutually-exclusive curses after the new curse is confirmed
|
|
-- on the target. A resisted curse must never remove the existing one.
|
|
if def.category == "CURSE" and def.curseExclusive ~= false then
|
|
local _, oldDef
|
|
for _, oldDef in ipairs(AURA_DEFS) do
|
|
if oldDef.category == "CURSE" and oldDef.curseExclusive ~= false then
|
|
cache[oldDef.key] = nil
|
|
end
|
|
end
|
|
end
|
|
|
|
cache._nextOrder = (cache._nextOrder or 0) + 1
|
|
local aura = cache[key] or {}
|
|
aura.order = cache._nextOrder
|
|
aura.duration = pending.duration
|
|
aura.expires = GetTime() + pending.duration
|
|
aura.spellID = pending.spellID
|
|
aura.texture = pending.texture or def.texture
|
|
aura.stacks = 0
|
|
aura.confirmedAt = GetTime()
|
|
cache[key] = aura
|
|
end
|
|
|
|
local function ConfirmPendingForUnit(unit, guid, now)
|
|
if not unit or not guid then return end
|
|
local pendingForGUID = BNP.pendingAuras[guid]
|
|
if not pendingForGUID then return end
|
|
|
|
local key, pending
|
|
for key, pending in pairs(pendingForGUID) do
|
|
if type(pending) == "table" then
|
|
if ConfirmedSpellOnUnit(unit, pending.spellID) then
|
|
CommitPendingAura(guid, key, pending)
|
|
pendingForGUID[key] = nil
|
|
elseif now - pending.created > PENDING_TIMEOUT then
|
|
-- Nothing landed: resist, miss, immune, evade or otherwise failed.
|
|
pendingForGUID[key] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function CleanupPending(now)
|
|
local guid, entries, key, pending
|
|
for guid, entries in pairs(BNP.pendingAuras) do
|
|
local any = false
|
|
for key, pending in pairs(entries) do
|
|
if type(pending) == "table" then
|
|
if now - pending.created > PENDING_TIMEOUT then
|
|
entries[key] = nil
|
|
else
|
|
any = true
|
|
end
|
|
end
|
|
end
|
|
if not any then BNP.pendingAuras[guid] = nil end
|
|
end
|
|
end
|
|
|
|
local castEvents = CreateFrame("Frame")
|
|
castEvents:RegisterEvent("UNIT_CASTEVENT")
|
|
castEvents:SetScript("OnEvent", function()
|
|
local casterGUID = arg1
|
|
local targetGUID = arg2
|
|
local eventType = arg3
|
|
local spellID = arg4
|
|
local playerGUID = GetPlayerGUID()
|
|
|
|
if not playerGUID or casterGUID ~= playerGUID or not targetGUID then return end
|
|
|
|
-- Dark Harvest is a CHANNEL event, not CAST. Handle only this exact
|
|
-- Turtle spell here so normal aura creation remains CAST-only.
|
|
if eventType == "CHANNEL" and spellID == DARK_HARVEST_SPELL_ID then
|
|
StartDarkHarvest(targetGUID, arg5)
|
|
return
|
|
end
|
|
|
|
if eventType ~= "CAST" then return end
|
|
|
|
-- Shadow audit only: this never changes the existing lookup or renderer.
|
|
if BNP.AuditSpellDB then BNP:AuditSpellDB(spellID) end
|
|
|
|
local def, texture = FindAuraDef(spellID)
|
|
if not def then return end
|
|
|
|
local duration = nil
|
|
if BNP.GetSpellbookDurationForDef then
|
|
duration = BNP:GetSpellbookDurationForDef(def)
|
|
end
|
|
duration = duration or (def.durations and def.durations[spellID]) or def.duration
|
|
|
|
local pendingForGUID = BNP.pendingAuras[targetGUID]
|
|
if not pendingForGUID then
|
|
pendingForGUID = {}
|
|
BNP.pendingAuras[targetGUID] = pendingForGUID
|
|
end
|
|
|
|
pendingForGUID[def.key] = {
|
|
def = def,
|
|
spellID = spellID,
|
|
texture = texture or def.texture,
|
|
duration = duration,
|
|
created = GetTime(),
|
|
}
|
|
end)
|
|
|
|
|
|
-- Live aura removal ---------------------------------------------------------
|
|
-- Timers alone are not enough for CC/roots/traps: effects may be dispelled,
|
|
-- broken by damage, removed by trinkets, or otherwise end early.
|
|
--
|
|
-- We NEVER create auras from this scan. It may only remove already-confirmed
|
|
-- own auras from the GUID cache when their exact spell ID is no longer present.
|
|
local REMOVAL_SCAN_INTERVAL = 0.10
|
|
local REMOVAL_GRACE = 0.35
|
|
|
|
local function CacheHasTrackedAuras(cache)
|
|
if not cache then return false end
|
|
local key, aura
|
|
for key, aura in pairs(cache) do
|
|
if type(aura) == "table" and aura.spellID and aura.expires then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function SyncAuraRemoval(unit, guid, now)
|
|
if not unit or not guid then return end
|
|
local cache = BNP.guidAuras[guid]
|
|
if not CacheHasTrackedAuras(cache) then return end
|
|
|
|
local present = {}
|
|
local i
|
|
for i = 1, 32 do
|
|
local texture, stacks, dtype, auraSpellID = UnitDebuff(unit, i)
|
|
if not texture then break end
|
|
if auraSpellID then
|
|
present[auraSpellID] = true
|
|
end
|
|
end
|
|
|
|
local key, aura
|
|
for key, aura in pairs(cache) do
|
|
if type(aura) == "table" and aura.spellID and aura.expires then
|
|
-- Give the client a tiny grace period immediately after confirmation so
|
|
-- transient aura-list updates cannot remove a freshly-landed effect.
|
|
local age = now - (aura.confirmedAt or 0)
|
|
if age >= REMOVAL_GRACE and not present[aura.spellID] then
|
|
cache[key] = nil
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function FormatTimer(remaining)
|
|
if not remaining or remaining <= 0 then return "" end
|
|
|
|
-- Keep long durations compact on the small nameplate icons.
|
|
-- Examples: 300s -> 5m, 61s -> 2m, 59s -> 59.
|
|
if remaining >= 3600 then
|
|
return math.ceil(remaining / 3600) .. "h"
|
|
elseif remaining >= 60 then
|
|
return math.ceil(remaining / 60) .. "m"
|
|
end
|
|
|
|
return tostring(math.ceil(remaining))
|
|
end
|
|
|
|
local function UpdatePlate(plate)
|
|
if not plate or not plate:IsShown() then return end
|
|
if not plate.BNPAuraContainer then CreateAuraContainer(plate) end
|
|
|
|
local container = plate.BNPAuraContainer
|
|
if not container then return end
|
|
|
|
local guid = GetPlateGUID(plate)
|
|
local cache = guid and BNP.guidAuras[guid] or nil
|
|
local now = GetTime()
|
|
|
|
-- Reuse one small table per plate. Active auras are packed without gaps and
|
|
-- sorted by the order in which they were first applied to this GUID.
|
|
local active = container.activeAuras
|
|
if not active then
|
|
active = {}
|
|
container.activeAuras = active
|
|
end
|
|
local activePool = container.activeAuraPool
|
|
if not activePool then
|
|
activePool = {}
|
|
for i = 1, MAX_VISIBLE_ICONS do activePool[i] = {} end
|
|
container.activeAuraPool = activePool
|
|
end
|
|
local previousCount = table.getn(active)
|
|
for i = 1, previousCount do active[i] = nil end
|
|
local activeCount = 0
|
|
|
|
for _, def in ipairs(AURA_DEFS) do
|
|
local aura = cache and cache[def.key]
|
|
local remaining = aura and aura.expires and (aura.expires - now) or nil
|
|
|
|
if remaining and remaining > 0 then
|
|
if activeCount < MAX_VISIBLE_ICONS then
|
|
activeCount = activeCount + 1
|
|
local entry = activePool[activeCount]
|
|
active[activeCount] = entry
|
|
entry.def = def
|
|
entry.aura = aura
|
|
entry.remaining = remaining
|
|
end
|
|
elseif cache and aura then
|
|
cache[def.key] = nil
|
|
end
|
|
end
|
|
|
|
table.sort(active, function(a, b)
|
|
return (a.aura.order or 0) < (b.aura.order or 0)
|
|
end)
|
|
|
|
local visibleCount = table.getn(active)
|
|
local iconSize = GetIconSize()
|
|
local maxWidth = iconSize * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1)
|
|
local rowWidth = 0
|
|
if visibleCount > 0 then
|
|
rowWidth = iconSize * visibleCount + ICON_SPACING * (visibleCount - 1)
|
|
end
|
|
local startX = (maxWidth - rowWidth) / 2
|
|
|
|
for i = 1, table.getn(container.icons) do
|
|
local icon = container.icons[i]
|
|
local entry = active[i]
|
|
|
|
if entry then
|
|
icon:ClearAllPoints()
|
|
icon:SetPoint("LEFT", container, "LEFT", startX + (i - 1) * (iconSize + ICON_SPACING), 0)
|
|
icon.texture:SetTexture(entry.aura.texture or entry.def.texture)
|
|
|
|
local timerText = FormatTimer(entry.remaining)
|
|
if icon.lastTimerText ~= timerText then
|
|
icon.timer:SetText(timerText)
|
|
icon.lastTimerText = timerText
|
|
end
|
|
|
|
icon:Show()
|
|
else
|
|
icon.lastTimerText = nil
|
|
icon.timer:SetText("")
|
|
icon:Hide()
|
|
end
|
|
end
|
|
|
|
if visibleCount > 0 then
|
|
container:Show()
|
|
else
|
|
container:Hide()
|
|
end
|
|
end
|
|
|
|
|
|
function BNP:RefreshAuraLayout(plate)
|
|
if not plate or not plate.BNPAuraContainer then return end
|
|
|
|
local container = plate.BNPAuraContainer
|
|
local size = GetIconSize()
|
|
local width = size * MAX_VISIBLE_ICONS + ICON_SPACING * (MAX_VISIBLE_ICONS - 1)
|
|
|
|
container:SetWidth(width)
|
|
container:SetHeight(size)
|
|
|
|
local i
|
|
for i = 1, table.getn(container.icons) do
|
|
container.icons[i]:SetWidth(size)
|
|
container.icons[i]:SetHeight(size)
|
|
end
|
|
end
|
|
|
|
function BNP:RefreshAllAuraLayouts()
|
|
local plate
|
|
for plate in pairs(BNP.plates or {}) do
|
|
self:RefreshAuraLayout(plate)
|
|
end
|
|
end
|
|
|
|
ScanSpellbook()
|
|
table.insert(BNP.libnameplate.OnInit, CreateAuraContainer)
|
|
table.insert(BNP.libnameplate.OnShow, CreateAuraContainer)
|
|
|
|
-- Keep the proven central update loop and the existing icon implementation.
|
|
-- No CooldownFrame templates and no frames or tables are created here.
|
|
local renderer = CreateFrame("Frame")
|
|
local elapsedTotal = 0
|
|
local removalElapsed = 0
|
|
renderer:SetScript("OnUpdate", function()
|
|
elapsedTotal = elapsedTotal + arg1
|
|
removalElapsed = removalElapsed + arg1
|
|
if elapsedTotal < UPDATE_INTERVAL then return end
|
|
elapsedTotal = 0
|
|
|
|
local now = GetTime()
|
|
UpdateDarkHarvest(now)
|
|
|
|
local hasPending = HasPendingAuras()
|
|
local doRemovalScan = removalElapsed >= REMOVAL_SCAN_INTERVAL
|
|
if doRemovalScan then removalElapsed = 0 end
|
|
|
|
local targetGUID = nil
|
|
if hasPending or doRemovalScan then
|
|
targetGUID = GetTargetGUID()
|
|
end
|
|
|
|
if hasPending and targetGUID then
|
|
-- Current target is the most reliable confirmation source.
|
|
ConfirmPendingForUnit("target", targetGUID, now)
|
|
end
|
|
|
|
if doRemovalScan and targetGUID then
|
|
SyncAuraRemoval("target", targetGUID, now)
|
|
end
|
|
|
|
-- The normal renderer always runs. Extra GUID/token/UnitDebuff work is only
|
|
-- performed while pending casts exist or during the throttled removal scan.
|
|
for plate in pairs(BNP.plates) do
|
|
if plate:IsShown() and (hasPending or doRemovalScan) then
|
|
local guid = GetPlateGUID(plate)
|
|
local token = GetUnitTokenForPlate(plate)
|
|
|
|
if guid and token then
|
|
if hasPending then
|
|
ConfirmPendingForUnit(token, guid, now)
|
|
end
|
|
if doRemovalScan then
|
|
SyncAuraRemoval(token, guid, now)
|
|
end
|
|
end
|
|
end
|
|
|
|
UpdatePlate(plate)
|
|
end
|
|
|
|
if hasPending then
|
|
CleanupPending(now)
|
|
end
|
|
end)
|