Files
HailMaryGuildTools/Modules/Tracker/TrackerManager.lua
Torsten Brendgen fc5a8aa361 initial commit
2026-04-10 21:30:31 +02:00

804 lines
26 KiB
Lua

-- Modules/Tracker/TrackerManager.lua
-- Generic tracker manager for category-driven tracker frames.
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT then return end
local Manager = {}
HMGT.TrackerManager = Manager
Manager.frames = Manager.frames or {}
Manager.perPlayerFrames = Manager.perPlayerFrames or {}
Manager.activeOrders = Manager.activeOrders or {}
Manager.unitByPlayer = Manager.unitByPlayer or {}
Manager.anchorLayoutSignatures = Manager.anchorLayoutSignatures or {}
Manager.nextAnchorRetryAt = Manager.nextAnchorRetryAt or {}
Manager.enabled = false
Manager.visualTicker = nil
Manager.lastEntryCount = 0
Manager._shared = Manager._shared or {}
Manager._trackerCache = Manager._trackerCache or nil
Manager._trackerCacheSignature = Manager._trackerCacheSignature or nil
Manager._displaySignatures = Manager._displaySignatures or {}
Manager._layoutDirty = Manager._layoutDirty == true
local function GetTrackerFrameKey(trackerId)
return "tracker:" .. tostring(tonumber(trackerId) or 0)
end
local function GetTrackerFrameName(trackerId)
return "GenericTracker_" .. tostring(tonumber(trackerId) or 0)
end
local function GetTrackerPlayerFrameName(trackerId, playerName)
local token = tostring(playerName or "Unknown"):gsub("[^%w_]", "_")
return string.format("%s_%s", GetTrackerFrameName(trackerId), token)
end
local function ShortName(name)
if not name then
return ""
end
local short = name:match("^[^-]+")
return short or name
end
local function IsUsableAnchorFrame(frame)
return frame
and frame.IsObjectType
and (frame:IsObjectType("Frame") or frame:IsObjectType("Button"))
end
local function GetFrameUnit(frame)
if not frame then
return nil
end
local unit = frame.unit
if not unit and frame.GetAttribute then
unit = frame:GetAttribute("unit")
end
return unit
end
local function FrameMatchesUnit(frame, unitId)
if not IsUsableAnchorFrame(frame) then
return false
end
if not unitId then
return true
end
return GetFrameUnit(frame) == unitId
end
local PLAYER_FRAME_CANDIDATES = {
"PlayerFrame",
"ElvUF_Player",
"NephUI_PlayerFrame",
"NephUIPlayerFrame",
"oUF_NephUI_Player",
"SUFUnitplayer",
}
local PARTY_FRAME_PATTERNS = {
"PartyMemberFrame%d",
"CompactPartyFrameMember%d",
"ElvUF_PartyGroup1UnitButton%d",
"ElvUF_PartyUnitButton%d",
"NephUI_PartyUnitButton%d",
"NephUI_PartyFrame%d",
"NephUIPartyFrame%d",
"oUF_NephUI_PartyUnitButton%d",
"SUFUnitparty%d",
}
local unitFrameCache = {}
local function EntryNeedsVisualTicker(entry)
if type(entry) ~= "table" then
return false
end
local remaining = tonumber(entry.remaining) or 0
if remaining > 0 then
return true
end
local maxCharges = tonumber(entry.maxCharges) or 0
local currentCharges = tonumber(entry.currentCharges)
if maxCharges > 0 and currentCharges ~= nil and currentCharges < maxCharges then
return true
end
return false
end
local function BuildAnchorLayoutSignature(settings, ordered, unitByPlayer)
local parts = {
settings.attachToPartyFrame == true and "attach" or "stack",
tostring(settings.partyAttachSide or "RIGHT"),
tostring(tonumber(settings.partyAttachOffsetX) or 8),
tostring(tonumber(settings.partyAttachOffsetY) or 0),
tostring(settings.showBar and "bar" or "icon"),
tostring(settings.growDirection or "DOWN"),
tostring(settings.width or 250),
tostring(settings.barHeight or 20),
tostring(settings.iconSize or 32),
tostring(settings.iconCols or 6),
tostring(settings.barSpacing or 2),
tostring(settings.locked),
tostring(settings.anchorTo or "UIParent"),
tostring(settings.anchorPoint or "TOPLEFT"),
tostring(settings.anchorRelPoint or "TOPLEFT"),
tostring(settings.anchorX or settings.posX or 0),
tostring(settings.anchorY or settings.posY or 0),
}
for _, playerName in ipairs(ordered or {}) do
parts[#parts + 1] = tostring(playerName)
parts[#parts + 1] = tostring(unitByPlayer and unitByPlayer[playerName] or "")
end
return table.concat(parts, "|")
end
local function IsGroupTracker(tracker)
return type(tracker) == "table" and tracker.trackerType == "group"
end
local function ResolveNamedUnitFrame(unitId)
if unitId == "player" then
for _, frameName in ipairs(PLAYER_FRAME_CANDIDATES) do
local frame = _G[frameName]
if FrameMatchesUnit(frame, unitId) or (frameName == "PlayerFrame" and IsUsableAnchorFrame(frame)) then
return frame
end
end
return nil
end
local idx = type(unitId) == "string" and unitId:match("^party(%d+)$")
if not idx then
return nil
end
idx = tonumber(idx)
for _, pattern in ipairs(PARTY_FRAME_PATTERNS) do
local frame = _G[pattern:format(idx)]
if FrameMatchesUnit(frame, unitId) then
return frame
end
end
return nil
end
local function ScanUnitFrame(unitId)
local frame = EnumerateFrames()
local scanned = 0
while frame and scanned < 8000 do
if IsUsableAnchorFrame(frame) and GetFrameUnit(frame) == unitId then
HMGT:Debug("verbose", "TrackerAttach scan unit=%s scanned=%d found=true", tostring(unitId), scanned)
return frame
end
scanned = scanned + 1
frame = EnumerateFrames(frame)
end
HMGT:Debug("verbose", "TrackerAttach scan unit=%s scanned=%d found=false", tostring(unitId), scanned)
return nil
end
local function ResolveUnitAnchorFrame(unitId)
if not unitId then
return nil
end
local now = GetTime()
local cached = unitFrameCache[unitId]
if cached and now < (cached.expires or 0) then
if cached.frame and cached.frame:IsShown() then
return cached.frame
end
return nil
end
local frame = ResolveNamedUnitFrame(unitId)
if not frame then
frame = ScanUnitFrame(unitId)
end
local expiresIn = 1.0
if frame and frame:IsShown() then
expiresIn = 10.0
end
unitFrameCache[unitId] = {
frame = frame,
expires = now + expiresIn,
}
if frame and frame:IsShown() then
return frame
end
return nil
end
local function GetGroupPlayers(tracker)
local players = {}
local ownName = HMGT:NormalizePlayerName(UnitName("player"))
local ownClass = select(2, UnitClass("player"))
local includeOwnPlayer = true
if IsGroupTracker(tracker) then
includeOwnPlayer = tracker.includeSelfFrame == true
end
if includeOwnPlayer then
players[#players + 1] = {
name = ownName,
class = ownClass,
isOwn = true,
unitId = "player",
}
end
if IsInRaid() then
for i = 1, GetNumGroupMembers() do
local unitId = "raid" .. i
local name = HMGT:NormalizePlayerName(UnitName(unitId))
local class = select(2, UnitClass(unitId))
if name and name ~= ownName then
players[#players + 1] = {
name = name,
class = class,
unitId = unitId,
}
end
end
elseif IsInGroup() then
for i = 1, GetNumGroupMembers() - 1 do
local unitId = "party" .. i
local name = HMGT:NormalizePlayerName(UnitName(unitId))
local class = select(2, UnitClass(unitId))
if name and name ~= ownName then
players[#players + 1] = {
name = name,
class = class,
unitId = unitId,
}
end
end
end
return players
end
local function GetTrackerLabel(tracker)
if type(tracker) ~= "table" then
return "Tracker"
end
local name = tostring(tracker.name or ""):gsub("^%s+", ""):gsub("%s+$", "")
local trackerId = tonumber(tracker.id) or 0
if name ~= "" then
return name
end
if trackerId > 0 then
return string.format("Tracker %d", trackerId)
end
return "Tracker"
end
local function GetTrackerSpellPool(categories)
if HMGT_SpellData and type(HMGT_SpellData.GetSpellPoolForCategories) == "function" then
return HMGT_SpellData.GetSpellPoolForCategories(categories)
end
return {}
end
local function GetTrackerSpellsForPlayer(classToken, specIndex, categories)
if HMGT_SpellData and type(HMGT_SpellData.GetSpellsForCategories) == "function" then
return HMGT_SpellData.GetSpellsForCategories(classToken, specIndex, categories)
end
return {}
end
local function CollectEntriesForPlayer(tracker, playerInfo)
local entries = {}
if type(tracker) ~= "table" or type(playerInfo) ~= "table" then
return entries
end
local playerName = playerInfo.name
if not playerName then
return entries
end
local pData = HMGT.playerData[playerName]
local classToken = pData and pData.class or playerInfo.class
if not classToken then
return entries
end
local specIndex
if playerInfo.isOwn then
specIndex = GetSpecialization()
if not specIndex or specIndex == 0 then
return entries
end
else
specIndex = pData and pData.specIndex or nil
if not specIndex or tonumber(specIndex) <= 0 then
return entries
end
end
local talents = pData and pData.talents or {}
local spells = GetTrackerSpellsForPlayer(classToken, specIndex, tracker.categories)
for _, spellEntry in ipairs(spells) do
if tracker.enabledSpells[spellEntry.spellId] ~= false then
local remaining, total, currentCharges, maxCharges = HMGT:GetCooldownInfo(playerName, spellEntry.spellId)
local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents)
local isAvailabilitySpell = HMGT:IsAvailabilitySpell(spellEntry)
local include = HMGT:ShouldDisplayEntry(tracker, remaining, currentCharges, maxCharges, spellEntry)
local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(playerName, spellEntry.spellId)
local hasPartialCharges = (tonumber(maxCharges) or 0) > 0
and (tonumber(currentCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0)
local hasActiveCd = ((remaining or 0) > 0) or hasPartialCharges
if not spellKnown and not hasActiveCd then
include = false
end
if isAvailabilitySpell and not spellKnown then
include = false
end
if not playerInfo.isOwn and isAvailabilitySpell and not HMGT:HasAvailabilityState(playerName, spellEntry.spellId) then
include = false
end
if include then
entries[#entries + 1] = {
playerName = playerName,
class = classToken,
spellEntry = spellEntry,
remaining = remaining,
total = total > 0 and total or effectiveCd,
currentCharges = currentCharges,
maxCharges = maxCharges,
}
end
end
end
return entries
end
local function CopyEntriesForPreview(entries, playerName)
local copies = {}
for _, entry in ipairs(entries or {}) do
local nextEntry = {}
for key, value in pairs(entry) do
nextEntry[key] = value
end
nextEntry.playerName = playerName
copies[#copies + 1] = nextEntry
end
return copies
end
local function GetAvailablePartyPreviewUnits()
local units = {}
for index = 1, 4 do
local unitId = "party" .. index
if ResolveUnitAnchorFrame(unitId) then
units[#units + 1] = {
playerName = string.format("Party %d", index),
unitId = unitId,
}
end
end
return units
end
local function BuildPartyPreviewEntries(entries)
local byPlayer = {}
local order = {}
local unitByPlayer = {}
local previewUnits = GetAvailablePartyPreviewUnits()
for _, previewUnit in ipairs(previewUnits) do
local playerName = previewUnit.playerName
local playerEntries = CopyEntriesForPreview(entries, playerName)
if #playerEntries > 0 then
byPlayer[playerName] = playerEntries
order[#order + 1] = playerName
unitByPlayer[playerName] = previewUnit.unitId
end
end
return byPlayer, order, unitByPlayer, #order > 0
end
local function SortTrackers(trackers)
table.sort(trackers, function(a, b)
local aId = tonumber(a and a.id) or 0
local bId = tonumber(b and b.id) or 0
if aId ~= bId then
return aId < bId
end
return tostring(a and a.name or "") < tostring(b and b.name or "")
end)
return trackers
end
local function BuildTrackerCacheSignature(trackers)
local parts = { tostring(#(trackers or {})) }
for index, tracker in ipairs(trackers or {}) do
parts[#parts + 1] = tostring(index)
parts[#parts + 1] = tostring(tonumber(tracker and tracker.id) or 0)
parts[#parts + 1] = tostring(tracker and tracker.trackerType or "normal")
parts[#parts + 1] = tostring(tracker and tracker.enabled)
parts[#parts + 1] = tostring(tracker and tracker.name or "")
end
return table.concat(parts, "|")
end
local function BuildNormalDisplaySignature(frameShown, entries)
local parts = { frameShown and "1" or "0", tostring(#(entries or {})) }
for index, entry in ipairs(entries or {}) do
parts[#parts + 1] = tostring(index)
parts[#parts + 1] = tostring(entry and entry.playerName or "")
parts[#parts + 1] = tostring(entry and entry.spellEntry and entry.spellEntry.spellId or 0)
end
return table.concat(parts, "|")
end
local function BuildGroupDisplaySignature(order, byPlayer)
local parts = { tostring(#(order or {})) }
for _, playerName in ipairs(order or {}) do
parts[#parts + 1] = tostring(playerName)
parts[#parts + 1] = tostring(#((byPlayer and byPlayer[playerName]) or {}))
end
return table.concat(parts, "|")
end
Manager._shared.GetTrackerFrameKey = GetTrackerFrameKey
Manager._shared.GetTrackerFrameName = GetTrackerFrameName
Manager._shared.GetTrackerPlayerFrameName = GetTrackerPlayerFrameName
Manager._shared.ShortName = ShortName
Manager._shared.BuildAnchorLayoutSignature = BuildAnchorLayoutSignature
Manager._shared.IsGroupTracker = IsGroupTracker
Manager._shared.ResolveUnitAnchorFrame = ResolveUnitAnchorFrame
Manager._shared.GetGroupPlayers = GetGroupPlayers
Manager._shared.GetTrackerLabel = GetTrackerLabel
Manager._shared.GetTrackerSpellPool = GetTrackerSpellPool
Manager._shared.GetTrackerSpellsForPlayer = GetTrackerSpellsForPlayer
Manager._shared.CollectEntriesForPlayer = CollectEntriesForPlayer
Manager._shared.BuildPartyPreviewEntries = BuildPartyPreviewEntries
Manager._shared.EntryNeedsVisualTicker = EntryNeedsVisualTicker
Manager._shared.BuildGroupDisplaySignature = BuildGroupDisplaySignature
function Manager:GetTrackers()
local profile = HMGT and HMGT.db and HMGT.db.profile
local trackers = profile and profile.trackers or {}
local signature = BuildTrackerCacheSignature(trackers)
if self._trackerCache and self._trackerCacheSignature == signature then
return self._trackerCache
end
local result = {}
for _, tracker in ipairs(trackers) do
result[#result + 1] = tracker
end
self._trackerCache = SortTrackers(result)
self._trackerCacheSignature = signature
return self._trackerCache
end
function Manager:MarkTrackersDirty()
self._trackerCache = nil
self._trackerCacheSignature = nil
self._layoutDirty = true
end
function Manager:MarkLayoutDirty()
self._layoutDirty = true
end
function Manager:EnsureFrame(tracker)
local frameKey = GetTrackerFrameKey(tracker.id)
local frame = self.frames[frameKey]
if not frame then
frame = HMGT.TrackerFrame:CreateTrackerFrame(GetTrackerFrameName(tracker.id), tracker)
frame._hmgtTrackerId = tonumber(tracker.id) or 0
self.frames[frameKey] = frame
end
frame._settings = tracker
HMGT.TrackerFrame:SetTitle(frame, GetTrackerLabel(tracker))
HMGT.TrackerFrame:SetLocked(frame, tracker.locked)
return frame
end
function Manager:GetAnchorFrame(tracker)
if type(tracker) ~= "table" then
return nil
end
if IsGroupTracker(tracker) then
local frameKey = GetTrackerFrameKey(tracker.id)
local order = self.activeOrders[frameKey] or {}
local frames = self.perPlayerFrames[frameKey] or {}
if order[1] and frames[order[1]] and frames[order[1]]:IsShown() then
return frames[order[1]]
end
end
return self:EnsureFrame(tracker)
end
function Manager:StopVisualTicker()
if self.visualTicker then
self.visualTicker:Cancel()
self.visualTicker = nil
end
end
function Manager:SetVisualTickerEnabled(enabled)
if enabled then
if not self.visualTicker then
self.visualTicker = C_Timer.NewTicker(0.1, function()
self:RefreshVisibleVisuals()
end)
end
else
self:StopVisualTicker()
end
end
function Manager:RefreshAnchors(force)
for _, tracker in ipairs(self:GetTrackers()) do
local frameKey = GetTrackerFrameKey(tracker.id)
if IsGroupTracker(tracker) then
local anchorFrame = self.frames[frameKey]
if anchorFrame and not anchorFrame._hmgtDragging then
HMGT.TrackerFrame:ApplyAnchor(anchorFrame)
end
self:RefreshPerGroupAnchors(tracker, force)
else
local frame = self.frames[frameKey]
if frame and (force or frame:IsShown()) then
if not frame._hmgtDragging then
HMGT.TrackerFrame:ApplyAnchor(frame)
end
frame:EnableMouse(not tracker.locked)
end
end
end
end
function Manager:InvalidateAnchorLayout()
self.anchorLayoutSignatures = {}
self.nextAnchorRetryAt = {}
self:MarkLayoutDirty()
self:RefreshAnchors(true)
end
function Manager:SetAllLocked(locked)
for _, frame in pairs(self.frames) do
HMGT.TrackerFrame:SetLocked(frame, locked)
end
for _, frameSet in pairs(self.perPlayerFrames) do
for _, frame in pairs(frameSet) do
HMGT.TrackerFrame:SetLocked(frame, locked)
end
end
end
function Manager:GetAnchorableFrames()
local frames = {}
for _, tracker in ipairs(self:GetTrackers()) do
local anchorKey = HMGT.GetTrackerAnchorKey and HMGT:GetTrackerAnchorKey(tracker.id) or nil
local frame = self:GetAnchorFrame(tracker)
if anchorKey and frame then
frames[anchorKey] = frame
end
end
return frames
end
function Manager:Enable()
self.enabled = true
self:MarkTrackersDirty()
self:UpdateDisplay()
end
function Manager:Disable()
self.enabled = false
self:StopVisualTicker()
self._layoutDirty = true
for _, frame in pairs(self.frames) do
frame:Hide()
end
for frameKey in pairs(self.perPlayerFrames) do
self:HidePlayerFrames(frameKey)
end
end
function Manager:RefreshVisibleVisuals()
if not self.enabled then
self:StopVisualTicker()
return
end
local shouldTick = false
local needsFullRefresh = false
local totalEntries = 0
for _, tracker in ipairs(self:GetTrackers()) do
local frameKey = GetTrackerFrameKey(tracker.id)
if IsGroupTracker(tracker) then
local currentOrder = self.activeOrders[frameKey] or {}
if #currentOrder > 0 then
local byPlayer, order, unitByPlayer, shouldShow = self:BuildEntriesByPlayerForTracker(tracker)
if not shouldShow or #order ~= #currentOrder then
needsFullRefresh = true
else
local byPlayerFiltered = {}
for index, playerName in ipairs(order) do
if playerName ~= currentOrder[index] then
needsFullRefresh = true
break
end
local entries = byPlayer[playerName] or {}
if HMGT.FilterDisplayEntries then
entries = HMGT:FilterDisplayEntries(tracker, entries) or entries
end
if HMGT.SortDisplayEntries then
HMGT:SortDisplayEntries(entries)
end
if #entries == 0 then
needsFullRefresh = true
break
end
local frame = self.perPlayerFrames[frameKey] and self.perPlayerFrames[frameKey][playerName]
if not frame or not frame:IsShown() then
needsFullRefresh = true
break
end
HMGT.TrackerFrame:UpdateFrame(frame, entries, true)
totalEntries = totalEntries + #entries
byPlayerFiltered[playerName] = entries
for _, entry in ipairs(entries) do
if EntryNeedsVisualTicker(entry) then
shouldTick = true
break
end
end
end
local newSignature = BuildGroupDisplaySignature(currentOrder, byPlayerFiltered)
if self._displaySignatures[frameKey] ~= newSignature then
needsFullRefresh = true
end
end
end
else
local frame = self.frames[frameKey]
if frame and frame:IsShown() then
local entries, shouldShow = self:BuildEntriesForTracker(tracker)
if not shouldShow then
needsFullRefresh = true
else
if HMGT.FilterDisplayEntries then
entries = HMGT:FilterDisplayEntries(tracker, entries) or entries
end
if HMGT.SortDisplayEntries then
HMGT:SortDisplayEntries(entries)
end
if #entries == 0 then
needsFullRefresh = true
else
HMGT.TrackerFrame:UpdateFrame(frame, entries, true)
totalEntries = totalEntries + #entries
local newSignature = BuildNormalDisplaySignature(true, entries)
if self._displaySignatures[frameKey] ~= newSignature then
needsFullRefresh = true
end
for _, entry in ipairs(entries) do
if EntryNeedsVisualTicker(entry) then
shouldTick = true
break
end
end
end
end
end
end
end
self.lastEntryCount = totalEntries
self:SetVisualTickerEnabled(shouldTick)
if needsFullRefresh then
HMGT:TriggerTrackerUpdate("layout")
end
end
function Manager:UpdateDisplay()
if not self.enabled then
self:StopVisualTicker()
return
end
local trackers = self:GetTrackers()
local activeFrames = {}
local shouldTick = false
local totalEntries = 0
local layoutDirty = self._layoutDirty == true
for _, tracker in ipairs(trackers) do
local frameKey = GetTrackerFrameKey(tracker.id)
local frame = self:EnsureFrame(tracker)
if IsGroupTracker(tracker) then
frame:Hide()
local shown, entryCount, trackerShouldTick = self:UpdatePerGroupMemberTracker(tracker)
totalEntries = totalEntries + (entryCount or 0)
if trackerShouldTick then
shouldTick = true
end
if not shown then
self:HidePlayerFrames(frameKey)
end
else
self:HidePlayerFrames(frameKey)
local entries, shouldShow = self:BuildEntriesForTracker(tracker)
if shouldShow then
if HMGT.FilterDisplayEntries then
entries = HMGT:FilterDisplayEntries(tracker, entries) or entries
end
if HMGT.SortDisplayEntries then
HMGT:SortDisplayEntries(entries)
end
HMGT.TrackerFrame:UpdateFrame(frame, entries, true)
frame:Show()
frame:EnableMouse(not tracker.locked)
activeFrames[frameKey] = true
totalEntries = totalEntries + #entries
local newSignature = BuildNormalDisplaySignature(true, entries)
if self._displaySignatures[frameKey] ~= newSignature then
self._displaySignatures[frameKey] = newSignature
layoutDirty = true
end
for _, entry in ipairs(entries) do
if EntryNeedsVisualTicker(entry) then
shouldTick = true
break
end
end
else
frame:Hide()
if self._displaySignatures[frameKey] ~= "0" then
self._displaySignatures[frameKey] = "0"
layoutDirty = true
end
end
end
end
for frameKey, frame in pairs(self.frames) do
if not activeFrames[frameKey] then
if frame:IsShown() then
layoutDirty = true
end
frame:Hide()
end
end
self.lastEntryCount = totalEntries
self:SetVisualTickerEnabled(shouldTick)
if layoutDirty then
self._layoutDirty = false
self:RefreshAnchors()
end
end