Files
Nanami-UI/AuraTracker.lua
2026-04-09 09:46:47 +08:00

595 lines
18 KiB
Lua

SFrames.AuraTracker = SFrames.AuraTracker or {}
local AT = SFrames.AuraTracker
AT.units = AT.units or {}
AT.unitRefs = AT.unitRefs or {}
AT.durationCache = AT.durationCache or { buff = {}, debuff = {} }
AT.initialized = AT.initialized or false
local SOURCE_PRIORITY = {
player_native = 6,
superwow = 5,
nanamiplates = 4,
shagutweaks = 3,
combat_log = 2,
estimated = 1,
}
local function GetNow()
return GetTime and GetTime() or 0
end
local function IsBuffType(auraType)
return auraType == "buff"
end
local function ClampPositive(value)
value = tonumber(value) or 0
if value > 0 then
return value
end
return nil
end
local function SafeUnitGUID(unit)
if unit and UnitGUID then
local ok, guid = pcall(UnitGUID, unit)
if ok and guid and guid ~= "" then
return guid
end
end
return nil
end
local function SafeUnitName(unit)
if unit and UnitName then
local ok, name = pcall(UnitName, unit)
if ok and name and name ~= "" then
return name
end
end
return nil
end
local function GetUnitKey(unit)
local guid = SafeUnitGUID(unit)
if guid then
return guid
end
local name = SafeUnitName(unit)
if name then
return "name:" .. name
end
return unit and ("unit:" .. unit) or nil
end
local function DurationCacheKey(auraType, spellId, name, texture)
if spellId and spellId > 0 then
return auraType .. ":id:" .. tostring(spellId)
end
if name and name ~= "" then
return auraType .. ":name:" .. string.lower(name)
end
return auraType .. ":tex:" .. tostring(texture or "")
end
local function BuildStateKeys(auraType, spellId, name, texture, casterKey)
local keys = {}
if spellId and spellId > 0 then
tinsert(keys, auraType .. ":id:" .. tostring(spellId))
if casterKey and casterKey ~= "" then
tinsert(keys, auraType .. ":id:" .. tostring(spellId) .. ":caster:" .. casterKey)
end
end
if name and name ~= "" then
local lowerName = string.lower(name)
tinsert(keys, auraType .. ":name:" .. lowerName)
if casterKey and casterKey ~= "" then
tinsert(keys, auraType .. ":name:" .. lowerName .. ":caster:" .. casterKey)
end
if texture and texture ~= "" then
tinsert(keys, auraType .. ":name:" .. lowerName .. ":tex:" .. texture)
end
elseif texture and texture ~= "" then
tinsert(keys, auraType .. ":tex:" .. texture)
end
return keys
end
local function TooltipLine1()
return getglobal("SFramesScanTooltipTextLeft1")
end
local function CLMatch(msg, pattern)
if not msg or not pattern or pattern == "" then return nil end
local pat = string.gsub(pattern, "%%%d?%$?s", "(.+)")
pat = string.gsub(pat, "%%%d?%$?d", "(%%d+)")
for a, b, c in string.gfind(msg, pat) do
return a, b, c
end
return nil
end
function AT:GetUnitState(unitOrGUID)
local key = unitOrGUID
if not key then return nil end
if string.find(key, "^target") or string.find(key, "^player") or string.find(key, "^party") or string.find(key, "^raid") or string.find(key, "^pet") then
key = GetUnitKey(key)
end
if not key then return nil end
if not self.units[key] then
self.units[key] = {
guid = key,
buffs = {},
debuffs = {},
maps = { buff = {}, debuff = {} },
snapshotCount = 0,
lastSeen = 0,
name = nil,
level = 0,
}
end
return self.units[key]
end
function AT:ClearUnit(unitOrGUID)
local key = unitOrGUID
if not key then return end
if not self.units[key] then
key = GetUnitKey(unitOrGUID)
end
if not key then return end
self.units[key] = nil
for unit, guid in pairs(self.unitRefs) do
if guid == key then
self.unitRefs[unit] = nil
end
end
end
function AT:ClearCurrentTarget()
local oldGUID = self.unitRefs["target"]
self.unitRefs["target"] = nil
if oldGUID then
local state = self.units[oldGUID]
if state then
state.maps.buff = {}
state.maps.debuff = {}
end
end
end
function AT:RememberDuration(auraType, spellId, name, texture, duration)
duration = ClampPositive(duration)
if not duration then return end
self.durationCache[auraType][DurationCacheKey(auraType, spellId, name, texture)] = duration
end
function AT:GetRememberedDuration(auraType, spellId, name, texture)
return self.durationCache[auraType][DurationCacheKey(auraType, spellId, name, texture)]
end
function AT:ReadAuraName(unit, index, isBuff, auraID)
if auraID and auraID > 0 and SpellInfo then
local ok, spellName = pcall(SpellInfo, auraID)
if ok and spellName and spellName ~= "" then
return spellName
end
end
-- Tooltip scan is expensive and can crash on invalid unit/index state.
-- Only attempt if unit still exists and the aura slot is still valid.
if not SFrames.Tooltip then return nil end
if not unit or not UnitExists or not UnitExists(unit) then return nil end
local ok, text = pcall(function()
SFrames.Tooltip:SetOwner(UIParent, "ANCHOR_NONE")
SFrames.Tooltip:ClearLines()
if isBuff then
SFrames.Tooltip:SetUnitBuff(unit, index)
else
SFrames.Tooltip:SetUnitDebuff(unit, index)
end
local line = TooltipLine1()
local t = line and line.GetText and line:GetText() or nil
SFrames.Tooltip:Hide()
return t
end)
if ok and text and text ~= "" then
return text
end
return nil
end
function AT:GetPlayerAuraTime(unit, index, isBuff, texture)
if not UnitIsUnit or not UnitIsUnit(unit, "player") then
return nil, nil, nil
end
if not GetPlayerBuff or not GetPlayerBuffTexture or not GetPlayerBuffTimeLeft then
return nil, nil, nil
end
local filter = isBuff and "HELPFUL" or "HARMFUL"
for i = 0, 31 do
local buffIndex = GetPlayerBuff(i, filter)
if buffIndex and buffIndex >= 0 and GetPlayerBuffTexture(buffIndex) == texture then
local timeLeft = ClampPositive(GetPlayerBuffTimeLeft(buffIndex))
if timeLeft then
return timeLeft, nil, "player_native"
end
end
end
return nil, nil, nil
end
function AT:GetExternalTime(unit, auraType, index, state)
local isBuff = IsBuffType(auraType)
local timeLeft, duration, source
if state and state.texture then
timeLeft, duration, source = self:GetPlayerAuraTime(unit, index, isBuff, state.texture)
if timeLeft then
return timeLeft, duration, source
end
end
if not isBuff and NanamiPlates_SpellDB and NanamiPlates_SpellDB.UnitDebuff then
local effect, _, _, _, _, npDuration, npTimeLeft = NanamiPlates_SpellDB:UnitDebuff(unit, index)
npTimeLeft = ClampPositive(npTimeLeft)
npDuration = ClampPositive(npDuration)
if effect and effect ~= "" and (not state.name or state.name == "") then
state.name = effect
end
if npTimeLeft then
return npTimeLeft, npDuration, "nanamiplates"
end
end
if not isBuff and ShaguTweaks and ShaguTweaks.libdebuff and ShaguTweaks.libdebuff.UnitDebuff then
local _, _, _, _, _, shaguDuration, shaguTimeLeft = ShaguTweaks.libdebuff:UnitDebuff(unit, index)
shaguTimeLeft = ClampPositive(shaguTimeLeft)
shaguDuration = ClampPositive(shaguDuration)
if shaguTimeLeft then
return shaguTimeLeft, shaguDuration, "shagutweaks"
end
end
return nil, nil, nil
end
function AT:ApplyTiming(state, timeLeft, duration, source, now, allowWeaker)
timeLeft = ClampPositive(timeLeft)
if not timeLeft then return false end
duration = ClampPositive(duration) or state.duration or timeLeft
local newPriority = SOURCE_PRIORITY[source] or 0
local oldPriority = SOURCE_PRIORITY[state.source] or 0
if not allowWeaker and state.expirationTime and state.expirationTime > now and oldPriority > newPriority then
return false
end
state.duration = duration
state.expirationTime = now + timeLeft
state.appliedAt = state.expirationTime - duration
state.source = source
state.isEstimated = (source == "estimated") and 1 or nil
self:RememberDuration(state.auraType, state.spellId, state.name, state.texture, duration)
return true
end
function AT:CaptureAura(unit, auraType, index)
local isBuff = IsBuffType(auraType)
local texture, count, dispelType, auraID
local hasSuperWoW = SFrames.superwow_active and SpellInfo
if isBuff then
texture, auraID = UnitBuff(unit, index)
else
texture, count, dispelType, auraID = UnitDebuff(unit, index)
end
if not texture then
return nil
end
local spellId = (hasSuperWoW and type(auraID) == "number" and auraID > 0) and auraID or nil
-- Only call ReadAuraName when we have a spellId (fast path) or for the first 16 slots
-- to avoid spamming tooltip API on every aura slot every UNIT_AURA event.
local name
if spellId or index <= 16 then
name = self:ReadAuraName(unit, index, isBuff, spellId)
end
return {
auraType = auraType,
index = index,
spellId = spellId,
name = name,
texture = texture,
stacks = tonumber(count) or 0,
dispelType = dispelType,
casterKey = nil,
}
end
function AT:FindPreviousState(unitState, auraType, index, info)
local previous = unitState.maps[auraType][index]
if previous then
local keys = BuildStateKeys(auraType, info.spellId, info.name, info.texture, info.casterKey)
local prevKeys = BuildStateKeys(auraType, previous.spellId, previous.name, previous.texture, previous.casterKey)
for _, key in ipairs(keys) do
for _, prevKey in ipairs(prevKeys) do
if key == prevKey then
return previous
end
end
end
end
local states = IsBuffType(auraType) and unitState.buffs or unitState.debuffs
local keys = BuildStateKeys(auraType, info.spellId, info.name, info.texture, info.casterKey)
for _, key in ipairs(keys) do
local state = states[key]
if state then
return state
end
end
return nil
end
function AT:UpdateSnapshotState(unitState, unit, auraType, index, info, now)
local state = self:FindPreviousState(unitState, auraType, index, info)
if not state then
state = {
guid = unitState.guid,
auraType = auraType,
firstSeenAt = now,
}
end
state.guid = unitState.guid
state.auraType = auraType
state.spellId = info.spellId
state.name = info.name or state.name
state.texture = info.texture
state.stacks = info.stacks
state.dispelType = info.dispelType
state.casterKey = info.casterKey
state.lastSeen = now
state.index = index
local timeLeft, duration, source = self:GetExternalTime(unit, auraType, index, state)
if timeLeft then
self:ApplyTiming(state, timeLeft, duration, source, now)
elseif state.expirationTime and state.expirationTime <= now then
state.expirationTime = nil
state.duration = nil
state.appliedAt = nil
state.source = nil
end
return state
end
function AT:RebuildStateMaps(unitState, auraType, slots, now)
local active = {}
local newMap = {}
for index, state in pairs(slots) do
if state then
for _, key in ipairs(BuildStateKeys(auraType, state.spellId, state.name, state.texture, state.casterKey)) do
active[key] = state
end
newMap[index] = state
end
end
if IsBuffType(auraType) then
unitState.buffs = active
else
unitState.debuffs = active
end
unitState.maps[auraType] = newMap
unitState.lastSeen = now
end
function AT:ApplyCombatHint(auraType, auraName)
if not auraName or auraName == "" then return end
if auraType ~= "debuff" then return end
local guid = self.unitRefs["target"]
if not guid then return end
local unitState = self.units[guid]
if not unitState then return end
local active = IsBuffType(auraType) and unitState.buffs or unitState.debuffs
local state = active[auraType .. ":name:" .. string.lower(auraName)]
if not state then return end
local remembered = self:GetRememberedDuration(auraType, state.spellId, state.name, state.texture)
if remembered then
self:ApplyTiming(state, remembered, remembered, "combat_log", GetNow(), true)
end
end
function AT:ClearCombatHint(auraName)
if not auraName or auraName == "" then return end
local guid = self.unitRefs["target"]
if not guid then return end
local unitState = self.units[guid]
if not unitState then return end
local lowerName = string.lower(auraName)
for _, auraType in ipairs({ "buff", "debuff" }) do
local active = IsBuffType(auraType) and unitState.buffs or unitState.debuffs
local state = active[auraType .. ":name:" .. lowerName]
if state then
state.expirationTime = nil
state.duration = nil
state.appliedAt = nil
state.source = nil
end
end
end
function AT:HandleCombatMessage(msg)
if not msg or msg == "" or not UnitExists or not UnitExists("target") then return end
local targetName = SafeUnitName("target")
if not targetName then return end
local targetUnit, auraName = CLMatch(msg, AURAADDEDOTHERHARMFUL or "%s is afflicted by %s.")
if targetUnit == targetName and auraName then
self:ApplyCombatHint("debuff", auraName)
return
end
auraName, targetUnit = CLMatch(msg, AURAREMOVEDOTHER or "%s fades from %s.")
if targetUnit == targetName and auraName then
self:ClearCombatHint(auraName)
end
end
function AT:HandleAuraSnapshot(unit)
if not unit then return end
if not UnitExists or not UnitExists(unit) then
if unit == "target" then
self:ClearCurrentTarget()
end
return
end
local guid = GetUnitKey(unit)
if not guid then return end
local now = GetNow()
local unitState = self:GetUnitState(guid)
unitState.guid = guid
unitState.name = SafeUnitName(unit)
unitState.level = (UnitLevel and UnitLevel(unit)) or 0
unitState.lastSeen = now
self.unitRefs[unit] = guid
local buffSlots = {}
local debuffSlots = {}
for i = 1, 32 do
local info = self:CaptureAura(unit, "buff", i)
if info then
buffSlots[i] = self:UpdateSnapshotState(unitState, unit, "buff", i, info, now)
end
end
for i = 1, 32 do
local info = self:CaptureAura(unit, "debuff", i)
if info then
debuffSlots[i] = self:UpdateSnapshotState(unitState, unit, "debuff", i, info, now)
end
end
self:RebuildStateMaps(unitState, "buff", buffSlots, now)
self:RebuildStateMaps(unitState, "debuff", debuffSlots, now)
unitState.snapshotCount = unitState.snapshotCount + 1
end
function AT:GetAuraState(unit, auraType, index)
local unitState = self:GetUnitState(unit)
if not unitState then return nil end
local map = unitState.maps[auraType]
return map and map[index] or nil
end
function AT:GetAuraTimeLeft(unit, auraType, index)
local state = self:GetAuraState(unit, auraType, index)
if not state or not state.expirationTime then
return nil
end
local remaining = state.expirationTime - GetNow()
if remaining > 0 then
return remaining
end
return nil
end
function AT:PurgeStaleUnits()
local now = GetNow()
local activeTargetGUID = self.unitRefs["target"]
for guid, state in pairs(self.units) do
if guid ~= activeTargetGUID and state.lastSeen and (now - state.lastSeen) > 120 then
self.units[guid] = nil
end
end
end
function AT:OnEvent()
if event == "PLAYER_TARGET_CHANGED" then
if UnitExists and UnitExists("target") then
self:HandleAuraSnapshot("target")
else
self:ClearCurrentTarget()
end
self:PurgeStaleUnits()
return
end
if event == "UNIT_AURA" and arg1 == "target" then
self:HandleAuraSnapshot("target")
return
end
if event == "PLAYER_ENTERING_WORLD" then
self:ClearCurrentTarget()
self:PurgeStaleUnits()
return
end
if arg1 and string.find(event, "CHAT_MSG_SPELL") then
self:HandleCombatMessage(arg1)
end
end
function AT:Initialize()
if self.initialized then return end
self.initialized = true
local frame = CreateFrame("Frame", "SFramesAuraTracker", UIParent)
self.frame = frame
frame:RegisterEvent("PLAYER_TARGET_CHANGED")
frame:RegisterEvent("UNIT_AURA")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
local chatEvents = {
"CHAT_MSG_SPELL_SELF_DAMAGE",
"CHAT_MSG_SPELL_SELF_BUFF",
"CHAT_MSG_SPELL_PARTY_DAMAGE",
"CHAT_MSG_SPELL_PARTY_BUFF",
"CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE",
"CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF",
"CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE",
"CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF",
"CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE",
"CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE",
"CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE",
"CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF",
"CHAT_MSG_SPELL_CREATURE_VS_PARTY_BUFF",
"CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF",
}
for _, ev in ipairs(chatEvents) do
frame:RegisterEvent(ev)
end
frame:SetScript("OnEvent", function()
AT:OnEvent()
end)
end