-- Modules/GroupCooldownTracker.lua -- Group-Cooldown-Tracker Modul (ein Frame pro Spieler in der Gruppe) local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) if not HMGT then return end local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME) local GCT = HMGT:NewModule("GroupCooldownTracker") HMGT.GroupCooldownTracker = GCT GCT.frame = nil GCT.frames = {} local function SanitizeFrameToken(name) if not name or name == "" then return "Unknown" end return name:gsub("[^%w_]", "_") 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 local unit = GetFrameUnit(frame) return unit == unitId end local PLAYER_FRAME_CANDIDATES = { "PlayerFrame", "ElvUF_Player", "NephUI_PlayerFrame", "NephUIPlayerFrame", "oUF_NephUI_Player", "SUFUnitplayer", } local PARTY_FRAME_PATTERNS = { "PartyMemberFrame%d", -- Blizzard alt "CompactPartyFrameMember%d", -- Blizzard modern "ElvUF_PartyGroup1UnitButton%d", -- ElvUI "ElvUF_PartyUnitButton%d", -- ElvUI variant "NephUI_PartyUnitButton%d", -- NephUI (common naming variants) "NephUI_PartyFrame%d", "NephUIPartyFrame%d", "oUF_NephUI_PartyUnitButton%d", "SUFUnitparty%d", -- Shadowed Unit Frames } 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 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) then local unit = GetFrameUnit(frame) if unit == unitId then HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach scan unit=%s scanned=%d found=true", tostring(unitId), scanned) return frame end end scanned = scanned + 1 frame = EnumerateFrames(frame) end HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach 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 function GCT:GetFrameIdForPlayer(playerName) return "GroupCooldownTracker_" .. SanitizeFrameToken(playerName) end function GCT:GetPlayerFrame(playerName) if not playerName then return nil end return self.frames[playerName] end function GCT:GetAnchorableFrames() return self.frames end function GCT:EnsurePlayerFrame(playerName) local frame = self.frames[playerName] local s = HMGT.db.profile.groupCooldownTracker if frame then return frame end frame = HMGT.TrackerFrame:CreateTrackerFrame(self:GetFrameIdForPlayer(playerName), s) frame._hmgtPlayerName = playerName self.frames[playerName] = frame return frame end function GCT:HideAllFrames() for _, frame in pairs(self.frames) do frame:Hide() end self.activeOrder = nil self.unitByPlayer = nil self.frame = nil self._lastAnchorLayoutSignature = nil self._nextAnchorRetryAt = nil end function GCT:SetLockedAll(locked) for _, frame in pairs(self.frames) do HMGT.TrackerFrame:SetLocked(frame, locked) end end function GCT:EnsureUpdateTicker() if self.updateTicker then return end self.updateTicker = C_Timer.NewTicker(0.1, function() self:UpdateDisplay() end) end function GCT:StopUpdateTicker() if self.updateTicker then self.updateTicker:Cancel() self.updateTicker = nil end end function GCT:SetUpdateTickerEnabled(enabled) if enabled then self:EnsureUpdateTicker() else self:StopUpdateTicker() end end function GCT:InvalidateAnchorLayout() self._lastAnchorLayoutSignature = nil self._nextAnchorRetryAt = nil end function GCT:RefreshAnchors(force) local s = HMGT.db.profile.groupCooldownTracker if not s then return end local ordered = {} for _, playerName in ipairs(self.activeOrder or {}) do local frame = self.frames[playerName] if frame and frame:IsShown() then table.insert(ordered, playerName) end end if #ordered == 0 then self.frame = nil self._lastAnchorLayoutSignature = nil self._nextAnchorRetryAt = nil return end local now = GetTime() local signature = BuildAnchorLayoutSignature(s, ordered, self.unitByPlayer) if not force and self._lastAnchorLayoutSignature == signature then local retryAt = tonumber(self._nextAnchorRetryAt) or 0 if retryAt <= 0 or now < retryAt then return end end -- Do not force anchor updates while user is dragging a tracker frame. for _, playerName in ipairs(ordered) do local frame = self.frames[playerName] if frame and frame._hmgtDragging then return end end local primaryName = ordered[1] local primary = self.frames[primaryName] self.frame = primary if s.attachToPartyFrame == true then local side = s.partyAttachSide or "RIGHT" local extraX = tonumber(s.partyAttachOffsetX) or 8 local extraY = tonumber(s.partyAttachOffsetY) or 0 local growsUp = s.showBar == true and s.growDirection == "UP" local barHeight = tonumber(s.barHeight) or 20 local growUpAttachOffset = barHeight + 20 local prevPlaced = nil local missingTargets = 0 for i = 1, #ordered do local playerName = ordered[i] local frame = self.frames[playerName] local unitId = self.unitByPlayer and self.unitByPlayer[playerName] local target = ResolveUnitAnchorFrame(unitId) local contentTopInset = HMGT.TrackerFrame.GetContentTopInset and HMGT.TrackerFrame:GetContentTopInset(frame) or 0 frame:ClearAllPoints() if target then if side == "LEFT" then if growsUp then frame:SetPoint("BOTTOMRIGHT", target, "TOPLEFT", -extraX, extraY - growUpAttachOffset) else frame:SetPoint("TOPRIGHT", target, "TOPLEFT", -extraX, extraY + contentTopInset) end else if growsUp then frame:SetPoint("BOTTOMLEFT", target, "TOPRIGHT", extraX, extraY - growUpAttachOffset) else frame:SetPoint("TOPLEFT", target, "TOPRIGHT", extraX, extraY + contentTopInset) end end elseif prevPlaced then missingTargets = missingTargets + 1 HMGT:DebugScoped("verbose", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach fallback-stack player=%s unit=%s", tostring(playerName), tostring(unitId)) if growsUp then frame:SetPoint("BOTTOMLEFT", prevPlaced, "TOPLEFT", 0, (s.barSpacing or 2) + 10) else frame:SetPoint("TOPLEFT", prevPlaced, "BOTTOMLEFT", 0, -((s.barSpacing or 2) + 10)) end else missingTargets = missingTargets + 1 HMGT:DebugScoped("info", HMGT:GetTrackerDebugScope("Group Cooldowns"), "GroupAttach fallback-anchor player=%s unit=%s (no party frame found)", tostring(playerName), tostring(unitId)) HMGT.TrackerFrame:ApplyAnchor(frame) end frame:EnableMouse(false) prevPlaced = frame end if missingTargets > 0 then self._lastAnchorLayoutSignature = nil self._nextAnchorRetryAt = now + 1.0 else self._lastAnchorLayoutSignature = signature self._nextAnchorRetryAt = nil end return end HMGT.TrackerFrame:ApplyAnchor(primary) primary:EnableMouse(not s.locked) local gap = (s.barSpacing or 2) + 10 local growsUp = s.showBar == true and s.growDirection == "UP" for i = 2, #ordered do local prev = self.frames[ordered[i - 1]] local frame = self.frames[ordered[i]] frame:ClearAllPoints() if growsUp then frame:SetPoint("BOTTOMLEFT", prev, "TOPLEFT", 0, gap) else frame:SetPoint("TOPLEFT", prev, "BOTTOMLEFT", 0, -gap) end frame:EnableMouse(false) end self._lastAnchorLayoutSignature = signature self._nextAnchorRetryAt = nil end -- ============================================================ -- ENABLE / DISABLE -- ============================================================ function GCT:Enable() local s = HMGT.db.profile.groupCooldownTracker if not s.enabled and not s.demoMode and not s.testMode then return end self:UpdateDisplay() end function GCT:Disable() self:StopUpdateTicker() self:HideAllFrames() end -- ============================================================ -- DISPLAY UPDATE -- ============================================================ function GCT:UpdateDisplay() local s = HMGT.db.profile.groupCooldownTracker if not s then return end if s.testMode then local entries, playerName = HMGT:GetOwnTestEntries(HMGT_SpellData.GroupCooldowns, s, { deferChargeCooldownUntilEmpty = false, }) local byPlayer = { [playerName] = {} } for _, entry in ipairs(entries) do entry.playerName = playerName table.insert(byPlayer[playerName], entry) end self.activeOrder = { playerName } self.unitByPlayer = { [playerName] = "player" } self.lastEntryCount = 0 local active = {} local shownOrder = {} local shouldTick = false for _, pName in ipairs(self.activeOrder) do local frame = self:EnsurePlayerFrame(pName) HMGT.TrackerFrame:SetLocked(frame, s.locked) HMGT.TrackerFrame:SetTitle(frame, string.format("%s - %s", L["GCD_TITLE"], ShortName(pName))) local displayEntries = byPlayer[pName] if HMGT.FilterDisplayEntries then displayEntries = HMGT:FilterDisplayEntries(s, displayEntries) or displayEntries end if HMGT.SortDisplayEntries then HMGT:SortDisplayEntries(displayEntries, "groupCooldownTracker") end if #displayEntries > 0 then HMGT.TrackerFrame:UpdateFrame(frame, displayEntries, true) self.lastEntryCount = self.lastEntryCount + #displayEntries frame:Show() active[pName] = true shownOrder[#shownOrder + 1] = pName for _, entry in ipairs(displayEntries) do if EntryNeedsVisualTicker(entry) then shouldTick = true break end end else frame:Hide() end end self.activeOrder = shownOrder for pn, frame in pairs(self.frames) do if not active[pn] then frame:Hide() end end self:RefreshAnchors() self:SetUpdateTickerEnabled(shouldTick) return end if s.demoMode then local entries = HMGT:GetDemoEntries("groupCooldownTracker", HMGT_SpellData.GroupCooldowns, s) local playerName = HMGT:NormalizePlayerName(UnitName("player")) or "DemoPlayer" local byPlayer = { [playerName] = {} } for _, entry in ipairs(entries) do entry.playerName = playerName table.insert(byPlayer[playerName], entry) end self.activeOrder = { playerName } self.unitByPlayer = { [playerName] = "player" } self.lastEntryCount = 0 local active = {} local shownOrder = {} local shouldTick = false for _, playerName in ipairs(self.activeOrder) do local frame = self:EnsurePlayerFrame(playerName) HMGT.TrackerFrame:SetLocked(frame, s.locked) HMGT.TrackerFrame:SetTitle(frame, string.format("%s - %s", L["GCD_TITLE"], ShortName(playerName))) local displayEntries = byPlayer[playerName] if HMGT.FilterDisplayEntries then displayEntries = HMGT:FilterDisplayEntries(s, displayEntries) or displayEntries end if HMGT.SortDisplayEntries then HMGT:SortDisplayEntries(displayEntries, "groupCooldownTracker") end if #displayEntries > 0 then HMGT.TrackerFrame:UpdateFrame(frame, displayEntries, true) self.lastEntryCount = self.lastEntryCount + #displayEntries frame:Show() active[playerName] = true shownOrder[#shownOrder + 1] = playerName shouldTick = true else frame:Hide() end end self.activeOrder = shownOrder for pn, frame in pairs(self.frames) do if not active[pn] then frame:Hide() end end self:RefreshAnchors() self:SetUpdateTickerEnabled(shouldTick) return end if IsInRaid() or not IsInGroup() then self.lastEntryCount = 0 self:StopUpdateTicker() self:HideAllFrames() return end if not s.enabled then self.lastEntryCount = 0 self:StopUpdateTicker() self:HideAllFrames() return end if not HMGT:IsVisibleForCurrentGroup(s) then self.lastEntryCount = 0 self:StopUpdateTicker() self:HideAllFrames() return end local entriesByPlayer, order, unitByPlayer = self:CollectEntriesByPlayer() self.activeOrder = order self.unitByPlayer = unitByPlayer self.lastEntryCount = 0 local active = {} local shownOrder = {} local shouldTick = false for _, playerName in ipairs(order) do local frame = self:EnsurePlayerFrame(playerName) HMGT.TrackerFrame:SetLocked(frame, s.locked) HMGT.TrackerFrame:SetTitle(frame, string.format("%s - %s", L["GCD_TITLE"], ShortName(playerName))) local entries = entriesByPlayer[playerName] or {} if HMGT.FilterDisplayEntries then entries = HMGT:FilterDisplayEntries(s, entries) or entries end if HMGT.SortDisplayEntries then HMGT:SortDisplayEntries(entries, "groupCooldownTracker") end if #entries > 0 then HMGT.TrackerFrame:UpdateFrame(frame, entries, true) self.lastEntryCount = self.lastEntryCount + #entries frame:Show() active[playerName] = true shownOrder[#shownOrder + 1] = playerName for _, entry in ipairs(entries) do if EntryNeedsVisualTicker(entry) then shouldTick = true break end end else frame:Hide() end end self.activeOrder = shownOrder for pn, frame in pairs(self.frames) do if not active[pn] then frame:Hide() end end self:RefreshAnchors() self:SetUpdateTickerEnabled(shouldTick) end function GCT:CollectEntriesByPlayer() local s = HMGT.db.profile.groupCooldownTracker local byPlayer = {} local playerOrder = {} local unitByPlayer = {} local players = self:GetGroupPlayers() for _, playerInfo in ipairs(players) do repeat local name = playerInfo.name if not name then break end local pData = HMGT.playerData[name] local class = pData and pData.class or playerInfo.class local specIdx if playerInfo.isOwn then specIdx = GetSpecialization() if not specIdx or specIdx == 0 then break end else specIdx = pData and pData.specIndex or nil if not specIdx or tonumber(specIdx) <= 0 then break end end local talents = pData and pData.talents or {} if not class then break end local knownCDs = HMGT_SpellData.GetSpellsForSpec(class, specIdx, HMGT_SpellData.GroupCooldowns) local entries = {} for _, spellEntry in ipairs(knownCDs) do if s.enabledSpells[spellEntry.spellId] ~= false then local remaining, total, curCharges, maxCharges = HMGT:GetCooldownInfo(name, spellEntry.spellId, { deferChargeCooldownUntilEmpty = false, }) local isAvailabilitySpell = HMGT.IsAvailabilitySpell and HMGT:IsAvailabilitySpell(spellEntry) local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents) local hasChargeSpell = (tonumber(maxCharges) or 0) > 1 local hasPartialCharges = (tonumber(maxCharges) or 0) > 0 and (tonumber(curCharges) or tonumber(maxCharges) or 0) < (tonumber(maxCharges) or 0) local include = HMGT:ShouldDisplayEntry(s, remaining, curCharges, maxCharges, spellEntry) local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(name, spellEntry.spellId) 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 then if isAvailabilitySpell and not HMGT:HasAvailabilityState(name, spellEntry.spellId) then include = false end end if include then table.insert(entries, { playerName = name, class = class, spellEntry = spellEntry, remaining = remaining, total = total > 0 and total or effectiveCd, currentCharges = curCharges, maxCharges = maxCharges, }) end end end if #entries > 0 then byPlayer[name] = entries table.insert(playerOrder, name) unitByPlayer[name] = playerInfo.unitId end until true end table.sort(playerOrder, function(a, b) local own = HMGT:NormalizePlayerName(UnitName("player")) if a == own and b ~= own then return true end if b == own and a ~= own then return false end return a < b end) return byPlayer, playerOrder, unitByPlayer end function GCT:GetGroupPlayers() local players = {} local ownName = HMGT:NormalizePlayerName(UnitName("player")) local settings = HMGT.db and HMGT.db.profile and HMGT.db.profile.groupCooldownTracker if settings and settings.includeSelfFrame == true and ownName then table.insert(players, { name = ownName, class = select(2, UnitClass("player")), unitId = "player", isOwn = true, }) end if IsInGroup() and not IsInRaid() 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 table.insert(players, {name = name, class = class, unitId = unitId}) end end end return players end