initial commit

This commit is contained in:
Torsten Brendgen
2026-04-10 21:30:31 +02:00
commit fc5a8aa361
108 changed files with 40568 additions and 0 deletions

View File

@@ -0,0 +1,472 @@
-- Modules/BuffEndingAnnouncer/BuffEndingAnnouncer.lua
-- Announces tracked buffs in SAY shortly before they expire.
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, true) or {}
local BEA = HMGT:NewModule("BuffEndingAnnouncer")
HMGT.BuffEndingAnnouncer = BEA
BEA.runtimeEnabled = false
BEA.eventFrame = nil
BEA.ticker = nil
BEA.lastAnnouncedSecond = {}
BEA.recentOwnCastAt = {}
local function NormalizeThreshold(value, fallback)
local threshold = tonumber(value)
if not threshold then
threshold = tonumber(fallback) or 5
end
threshold = math.floor(threshold + 0.5)
if threshold < 1 then threshold = 1 end
if threshold > 30 then threshold = 30 end
return threshold
end
local function GetSpellName(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return nil end
if C_Spell and type(C_Spell.GetSpellName) == "function" then
local name = C_Spell.GetSpellName(sid)
if type(name) == "string" and name ~= "" then
return name
end
end
if type(GetSpellInfo) == "function" then
local name = GetSpellInfo(sid)
if type(name) == "string" and name ~= "" then
return name
end
end
return nil
end
local function IsAuraSourcePlayer(sourceUnit)
if not sourceUnit then return nil end
if sourceUnit == "player" then return true end
if type(UnitIsUnit) == "function" and type(UnitExists) == "function" and UnitExists(sourceUnit) then
return UnitIsUnit(sourceUnit, "player") and true or false
end
return false
end
local function GetPlayerBuffExpiration(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return nil, nil, nil, nil end
if C_UnitAuras and type(C_UnitAuras.GetPlayerAuraBySpellID) == "function" then
local aura = C_UnitAuras.GetPlayerAuraBySpellID(sid)
if aura then
local exp = tonumber(aura.expirationTime) or 0
if exp > 0 then
local isOwnCaster = IsAuraSourcePlayer(aura.sourceUnit)
if isOwnCaster == nil and aura.isFromPlayerOrPlayerPet ~= nil then
isOwnCaster = aura.isFromPlayerOrPlayerPet and true or false
end
return exp, tonumber(aura.duration) or 0, aura.name, isOwnCaster
end
end
end
if AuraUtil and type(AuraUtil.FindAuraBySpellID) == "function" then
local name, _, _, _, duration, expirationTime, sourceUnit, _, _, _, _, _, castByPlayer =
AuraUtil.FindAuraBySpellID(sid, "player", "HELPFUL")
local exp = tonumber(expirationTime) or 0
if name and exp > 0 then
local isOwnCaster = IsAuraSourcePlayer(sourceUnit)
if isOwnCaster == nil and castByPlayer ~= nil then
isOwnCaster = castByPlayer and true or false
end
return exp, tonumber(duration) or 0, name, isOwnCaster
end
end
return nil, nil, nil, nil
end
local function GetPlayerChannelExpiration(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return nil, nil, nil, nil end
if type(UnitChannelInfo) ~= "function" then
return nil, nil, nil, nil
end
local name, _, _, startTimeMS, endTimeMS, _, _, channelSpellId = UnitChannelInfo("player")
local activeSpellId = tonumber(channelSpellId)
if activeSpellId ~= sid then
return nil, nil, nil, nil
end
local startTime = tonumber(startTimeMS) or 0
local endTime = tonumber(endTimeMS) or 0
if endTime <= 0 or endTime <= startTime then
return nil, nil, nil, nil
end
return endTime / 1000, (endTime - startTime) / 1000, name, true
end
local function GetTrackedSpellExpiration(spellId)
local expirationTime, duration, name, isOwnCaster = GetPlayerBuffExpiration(spellId)
if expirationTime and expirationTime > 0 then
return expirationTime, duration, name, isOwnCaster, "aura"
end
expirationTime, duration, name, isOwnCaster = GetPlayerChannelExpiration(spellId)
if expirationTime and expirationTime > 0 then
return expirationTime, duration, name, isOwnCaster, "channel"
end
return nil, nil, nil, nil, nil
end
function BEA:GetSettings()
local p = HMGT.db and HMGT.db.profile
if not p then return nil end
p.buffEndingAnnouncer = p.buffEndingAnnouncer or {}
p.buffEndingAnnouncer.announceAtSec = NormalizeThreshold(p.buffEndingAnnouncer.announceAtSec, 5)
p.buffEndingAnnouncer.trackedBuffs = p.buffEndingAnnouncer.trackedBuffs or {}
return p.buffEndingAnnouncer
end
function BEA:GetDefaultThreshold()
local s = self:GetSettings()
return NormalizeThreshold(s and s.announceAtSec, 5)
end
function BEA:GetTrackedBuffThreshold(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return nil end
local settings = self:GetSettings()
local tracked = settings and settings.trackedBuffs
local value = tracked and tracked[sid]
if value == nil then
return nil
end
if type(value) == "table" then
value = value.threshold
end
if value == true then
value = self:GetDefaultThreshold()
end
return NormalizeThreshold(value, self:GetDefaultThreshold())
end
function BEA:SetTrackedBuffThreshold(spellId, threshold)
local sid = tonumber(spellId)
if not sid or sid <= 0 then
return false
end
local s = self:GetSettings()
s.trackedBuffs[sid] = NormalizeThreshold(threshold, self:GetDefaultThreshold())
self.lastAnnouncedSecond[sid] = nil
self:Refresh()
return true
end
function BEA:GetTrackedBuffEntries()
local entries = {}
for _, sid in ipairs(self:GetTrackedBuffSpellIds()) do
entries[#entries + 1] = {
spellId = sid,
name = GetSpellName(sid) or ("Spell " .. tostring(sid)),
threshold = self:GetTrackedBuffThreshold(sid) or self:GetDefaultThreshold(),
}
end
return entries
end
function BEA:GetTrackedBuffSpellIds()
local ids = {}
local settings = self:GetSettings()
local tracked = (settings and settings.trackedBuffs) or {}
for sid, value in pairs(tracked) do
local id = tonumber(sid)
if id and id > 0 and value ~= nil and value ~= false then
ids[#ids + 1] = id
end
end
table.sort(ids, function(a, b)
local nameA = tostring(GetSpellName(a) or a):lower()
local nameB = tostring(GetSpellName(b) or b):lower()
if nameA == nameB then
return a < b
end
return nameA < nameB
end)
return ids
end
function BEA:ResetAnnouncements()
for sid in pairs(self.lastAnnouncedSecond) do
self.lastAnnouncedSecond[sid] = nil
end
end
function BEA:ResetRecentOwnCasts()
for sid in pairs(self.recentOwnCastAt) do
self.recentOwnCastAt[sid] = nil
end
end
function BEA:StopTicker()
if self.ticker then
self.ticker:Cancel()
self.ticker = nil
end
end
function BEA:HasTrackedBuffsConfigured()
return #self:GetTrackedBuffSpellIds() > 0
end
function BEA:UpdateRuntimeEventRegistrations()
if not self.eventFrame then
return
end
self.eventFrame:UnregisterEvent("UNIT_AURA")
self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_SUCCEEDED")
self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_START")
self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_UPDATE")
self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_STOP")
if not self.runtimeEnabled or not self:HasTrackedBuffsConfigured() then
return
end
self.eventFrame:RegisterUnitEvent("UNIT_AURA", "player")
self.eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_SUCCEEDED", "player")
self.eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_START", "player")
self.eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_UPDATE", "player")
self.eventFrame:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_STOP", "player")
end
function BEA:EnsureTicker()
if self.ticker then return end
self.ticker = C_Timer.NewTicker(0.1, function()
self:OnTicker()
end)
end
function BEA:IsProfileEnabled()
local s = self:GetSettings()
return s and s.enabled == true
end
function BEA:BuildAnnouncement(spellName, secondsLeft)
local template = L["BEA_MSG_TEMPLATE"] or "%s ending in %d"
return string.format(template, tostring(spellName or "?"), tonumber(secondsLeft) or 0)
end
function BEA:IsOwnBuffCaster(spellId, isOwnCaster, duration, now)
if isOwnCaster == true then
return true
end
if isOwnCaster == false then
return false
end
-- Fallback when aura source information is missing:
-- only trust a very recent own cast of the tracked spell.
local castAt = self.recentOwnCastAt[tonumber(spellId)]
if not castAt then
return false
end
local maxAge = tonumber(duration) or 0
if maxAge < 3 then maxAge = 3 end
if maxAge > 12 then maxAge = 12 end
return (tonumber(now) or GetTime()) - castAt <= maxAge
end
function BEA:EvaluateTrackedBuffs()
if not self:IsProfileEnabled() then
self:ResetAnnouncements()
return false
end
local ids = self:GetTrackedBuffSpellIds()
if #ids == 0 then
self:ResetAnnouncements()
return false
end
local now = GetTime()
local hasAnyTrackedBuff = false
for _, sid in ipairs(ids) do
local threshold = self:GetTrackedBuffThreshold(sid)
local expirationTime, duration, auraName, isOwnCaster, stateKind = GetTrackedSpellExpiration(sid)
if expirationTime and expirationTime > now then
local isOwnSource = (stateKind == "channel") or self:IsOwnBuffCaster(sid, isOwnCaster, duration, now)
if isOwnSource then
hasAnyTrackedBuff = true
local remaining = expirationTime - now
if threshold and remaining > 0 and remaining <= threshold then
local second = math.ceil(remaining - 0.0001)
if second < 1 then second = 1 end
if self.lastAnnouncedSecond[sid] ~= second then
local msg = self:BuildAnnouncement(auraName or GetSpellName(sid) or ("Spell " .. tostring(sid)), second)
SendChatMessage(msg, "SAY")
self.lastAnnouncedSecond[sid] = second
end
else
self.lastAnnouncedSecond[sid] = nil
end
else
self.lastAnnouncedSecond[sid] = nil
end
else
self.lastAnnouncedSecond[sid] = nil
end
local castAt = self.recentOwnCastAt[sid]
if castAt and (now - castAt) > 30 then
self.recentOwnCastAt[sid] = nil
end
end
return hasAnyTrackedBuff
end
function BEA:Refresh()
if not self.runtimeEnabled then return end
self:UpdateRuntimeEventRegistrations()
local active = self:EvaluateTrackedBuffs()
if active then
self:EnsureTicker()
else
self:StopTicker()
end
end
function BEA:OnTicker()
if not self.runtimeEnabled then
self:StopTicker()
return
end
local active = self:EvaluateTrackedBuffs()
if not active then
self:StopTicker()
end
end
function BEA:OnEvent(event, ...)
if event == "UNIT_AURA" then
local unit = ...
if unit ~= "player" then
return
end
elseif event == "UNIT_SPELLCAST_SUCCEEDED" then
local unit, _, spellId = ...
if unit == "player" then
local sid = tonumber(spellId)
if sid and sid > 0 then
local s = self:GetSettings()
if s and s.trackedBuffs and s.trackedBuffs[sid] then
self.recentOwnCastAt[sid] = GetTime()
end
end
end
elseif event == "UNIT_SPELLCAST_CHANNEL_START" or event == "UNIT_SPELLCAST_CHANNEL_UPDATE" or event == "UNIT_SPELLCAST_CHANNEL_STOP" then
local unit = ...
if unit ~= "player" then
return
end
elseif event == "PLAYER_ENTERING_WORLD" or event == "PLAYER_DEAD" then
self:ResetAnnouncements()
self:ResetRecentOwnCasts()
end
self:Refresh()
end
function BEA:StartRuntime()
if not self:IsProfileEnabled() then return end
if self.runtimeEnabled then
self:Refresh()
return
end
self.runtimeEnabled = true
if not self.eventFrame then
self.eventFrame = CreateFrame("Frame")
self.eventFrame:SetScript("OnEvent", function(_, event, ...)
self:OnEvent(event, ...)
end)
end
self.eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
self.eventFrame:RegisterEvent("PLAYER_DEAD")
self:Refresh()
end
function BEA:StopRuntime()
if self.eventFrame then
self.eventFrame:UnregisterEvent("UNIT_AURA")
self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_SUCCEEDED")
self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_START")
self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_UPDATE")
self.eventFrame:UnregisterEvent("UNIT_SPELLCAST_CHANNEL_STOP")
self.eventFrame:UnregisterEvent("PLAYER_ENTERING_WORLD")
self.eventFrame:UnregisterEvent("PLAYER_DEAD")
end
self.runtimeEnabled = false
self:StopTicker()
self:ResetAnnouncements()
self:ResetRecentOwnCasts()
end
function BEA:OnInitialize()
HMGT.BuffEndingAnnouncer = self
end
function BEA:OnEnable()
self:StartRuntime()
end
function BEA:OnDisable()
self:StopRuntime()
end
function BEA:AddTrackedBuff(spellId, threshold)
local sid = tonumber(spellId)
if not sid or sid <= 0 then
return false, "invalid"
end
local name = GetSpellName(sid)
if not name then
return false, "invalid"
end
local s = self:GetSettings()
s.trackedBuffs[sid] = NormalizeThreshold(threshold, self:GetDefaultThreshold())
self.lastAnnouncedSecond[sid] = nil
self:Refresh()
return true, name
end
function BEA:RemoveTrackedBuff(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then
return false, "invalid"
end
local s = self:GetSettings()
if not s.trackedBuffs[sid] then
return false, "missing"
end
s.trackedBuffs[sid] = nil
self.lastAnnouncedSecond[sid] = nil
self:Refresh()
return true, GetSpellName(sid) or tostring(sid)
end

View File

@@ -0,0 +1,409 @@
-- Modules/BuffEndingAnnouncer/BuffEndingAnnouncerOptions.lua
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT then return end
local BEA = HMGT.BuffEndingAnnouncer
if not BEA then return end
if not HMGT_Config or not HMGT_Config.RegisterOptionsProvider then return end
local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {}
local AceConfigRegistry = LibStub("AceConfigRegistry-3.0")
local beaOptionsGroup
local function GetSpellName(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return nil end
if C_Spell and type(C_Spell.GetSpellName) == "function" then
local name = C_Spell.GetSpellName(sid)
if type(name) == "string" and name ~= "" then
return name
end
end
if type(GetSpellInfo) == "function" then
local name = GetSpellInfo(sid)
if type(name) == "string" and name ~= "" then
return name
end
end
return nil
end
local function GetSpellIcon(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return nil end
if HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then
return HMGT_SpellData.GetSpellIcon(sid)
end
if C_Spell and type(C_Spell.GetSpellTexture) == "function" then
return C_Spell.GetSpellTexture(sid)
end
local _, _, icon = GetSpellInfo(sid)
return icon
end
local function RefreshTrackedGroup()
if not beaOptionsGroup or type(beaOptionsGroup.args) ~= "table" then
return
end
local fresh = BEA:BuildOptionsGroup()
local currentTracked = beaOptionsGroup.args.tracked
local freshTracked = fresh and fresh.args and fresh.args.tracked
if type(currentTracked) == "table" and type(freshTracked) == "table" then
currentTracked.name = freshTracked.name
currentTracked.order = freshTracked.order
currentTracked.inline = freshTracked.inline
currentTracked.childGroups = freshTracked.childGroups
currentTracked.args = freshTracked.args
end
end
local function NotifyOptionsChanged(rebuild)
if rebuild ~= false then
RefreshTrackedGroup()
end
AceConfigRegistry:NotifyChange(ADDON_NAME)
end
local function GetFilterState()
HMGT._beaSpellFilter = HMGT._beaSpellFilter or {
search = "",
}
return HMGT._beaSpellFilter
end
local function NormalizeSearchText(value)
local text = tostring(value or ""):lower()
text = text:gsub("^%s+", "")
text = text:gsub("%s+$", "")
return text
end
local function GetDraft()
HMGT._beaDraft = HMGT._beaDraft or {}
return HMGT._beaDraft
end
local function GetTrackedBuffLabel(entry)
local spellId = tonumber(entry and entry.spellId) or 0
local spellName = tostring(entry and entry.name or GetSpellName(spellId) or ("Spell " .. spellId))
local icon = GetSpellIcon(spellId)
local threshold = tonumber(entry and entry.threshold) or tonumber(BEA:GetDefaultThreshold()) or 0
if icon and icon ~= "" then
return string.format("|T%s:16:16:0:0|t %s (%ss)", tostring(icon), spellName, threshold)
end
return string.format("%s (%ss)", spellName, threshold)
end
local function BuildTrackedBuffLeaf(entry)
local spellId = tonumber(entry and entry.spellId) or 0
return {
type = "group",
name = GetTrackedBuffLabel(entry),
args = {
header = {
type = "header",
order = 1,
name = GetTrackedBuffLabel(entry),
},
spellInfo = {
type = "description",
order = 2,
width = "full",
name = string.format(
"|cffffd100Spell ID|r: %d\n|cffffd100%s|r: %s",
spellId,
L["OPT_BEA_COL_SPELL"] or "Spellname",
tostring(entry and entry.name or GetSpellName(spellId) or ("Spell " .. spellId))
),
},
threshold = {
type = "range",
order = 3,
min = 1,
max = 60,
step = 1,
width = "full",
name = L["OPT_BEA_COL_THRESHOLD"] or "Threshold",
get = function()
local current = BEA:GetTrackedBuffEntries()
for _, candidate in ipairs(current) do
if tonumber(candidate.spellId) == spellId then
return tonumber(candidate.threshold) or BEA:GetDefaultThreshold()
end
end
return tonumber(BEA:GetDefaultThreshold()) or 5
end,
set = function(_, val)
BEA:SetTrackedBuffThreshold(spellId, val)
NotifyOptionsChanged()
end,
},
remove = {
type = "execute",
order = 4,
width = "full",
name = REMOVE or (L["OPT_BEA_REMOVE"] or "Remove buff"),
confirm = function()
return string.format("%s?", tostring(entry and entry.name or GetSpellName(spellId) or ("Spell " .. spellId)))
end,
func = function()
local ok, info = BEA:RemoveTrackedBuff(spellId)
if ok then
HMGT:Print(string.format(L["OPT_BEA_MSG_REMOVED"] or "HMGT: buff removed: %s", tostring(info or spellId)))
else
HMGT:Print(L["OPT_BEA_MSG_NOT_FOUND"] or "HMGT: buff not found")
end
NotifyOptionsChanged()
end,
},
},
}
end
local function IsTrackedBuffVisible(entry)
local spellId = tonumber(entry and entry.spellId) or 0
if spellId <= 0 then
return false
end
local search = NormalizeSearchText(GetFilterState().search)
if search == "" then
return true
end
local haystack = table.concat({
tostring(entry and entry.name or GetSpellName(spellId) or ""),
tostring(spellId),
}, " "):lower()
return haystack:find(search, 1, true) ~= nil
end
local function CountVisibleEntries(entries)
local count = 0
for _, entry in ipairs(entries or {}) do
if IsTrackedBuffVisible(entry) then
count = count + 1
end
end
return count
end
function BEA:BuildOptionsGroup()
local draft = GetDraft()
local entries = self:GetTrackedBuffEntries()
local group = {
type = "group",
name = L["BEA_NAME"] or "Buff Ending Announcer",
order = 3,
childGroups = "tree",
args = {
general = {
type = "group",
order = 1,
name = L["OPT_BEA_SECTION_GENERAL"] or "General",
args = {
enabled = {
type = "toggle",
order = 1,
width = "full",
name = L["OPT_BEA_ENABLED"] or "Enable buff ending announcer",
desc = L["OPT_BEA_ENABLED_DESC"] or "Announce tracked buff countdowns in /say",
get = function()
return self:GetSettings().enabled == true
end,
set = function(_, val)
self:GetSettings().enabled = val and true or false
if val then
self:Enable()
else
self:Disable()
end
end,
},
defaultThreshold = {
type = "range",
order = 2,
min = 1,
max = 60,
step = 1,
width = "full",
name = L["OPT_BEA_DEFAULT_THRESHOLD"] or "Default threshold (sec)",
desc = L["OPT_BEA_DEFAULT_THRESHOLD_DESC"] or "Used when you add a new tracked buff",
get = function()
return tonumber(self:GetDefaultThreshold()) or 5
end,
set = function(_, val)
self:GetSettings().announceAtSec = math.floor((tonumber(val) or 5) + 0.5)
self:Refresh()
end,
},
},
},
tracked = {
type = "group",
order = 2,
name = string.format(
"%s (%d)",
L["OPT_BEA_SECTION_BUFFS"] or "Tracked buffs",
#entries
),
args = {
header = {
type = "header",
order = 1,
name = L["OPT_SPELL_BROWSER"] or "Spell Browser",
},
summary = {
type = "description",
order = 2,
width = "full",
name = function()
local currentEntries = BEA:GetTrackedBuffEntries()
return string.format(
"%s\n\n|cffffd100%s|r: %d\n|cffffd100%s|r: %d",
L["OPT_SPELL_BROWSER_DESC"] or "Filter tracked spells by name or Spell ID and apply quick actions to the visible results.",
L["OPT_SPELLS_VISIBLE"] or "Visible spells",
CountVisibleEntries(currentEntries),
L["OPT_BEA_SECTION_BUFFS"] or "Tracked buffs",
#currentEntries
)
end,
},
search = {
type = "input",
order = 3,
width = "full",
name = L["OPT_FILTER_SEARCH"] or "Search",
desc = L["OPT_FILTER_SEARCH_DESC"] or "Search by spell name or Spell ID",
get = function()
return GetFilterState().search or ""
end,
set = function(_, val)
GetFilterState().search = val or ""
NotifyOptionsChanged(false)
end,
},
resetFilters = {
type = "execute",
order = 4,
width = "full",
name = L["OPT_FILTER_RESET"] or "Reset filters",
func = function()
GetFilterState().search = ""
NotifyOptionsChanged(false)
end,
},
addGroup = {
type = "group",
order = 5,
inline = true,
name = L["OPT_BEA_CURRENT"] or "Current tracked buffs",
args = {
addSpellId = {
type = "input",
order = 1,
width = 0.9,
name = L["OPT_BEA_ADD_ID"] or "Add Spell ID",
get = function()
return tostring(draft.addSpellId or "")
end,
set = function(_, val)
draft.addSpellId = val
end,
},
addThreshold = {
type = "range",
order = 2,
min = 1,
max = 60,
step = 1,
width = 1.1,
name = L["OPT_BEA_ADD_THRESHOLD"] or "Threshold",
desc = L["OPT_BEA_ADD_THRESHOLD_DESC"] or "Countdown start in seconds for this buff",
get = function()
return tonumber(draft.addThreshold) or tonumber(self:GetDefaultThreshold()) or 5
end,
set = function(_, val)
draft.addThreshold = tonumber(val) or self:GetDefaultThreshold()
end,
},
addSpell = {
type = "execute",
order = 3,
width = "full",
name = L["OPT_BEA_ADD"] or "Add buff",
func = function()
local sid = tonumber(draft.addSpellId)
local threshold = tonumber(draft.addThreshold) or self:GetDefaultThreshold()
local ok, info = self:AddTrackedBuff(sid, threshold)
if ok then
draft.addSpellId = ""
draft.addThreshold = tonumber(self:GetDefaultThreshold()) or 5
HMGT:Print(string.format(L["OPT_BEA_MSG_ADDED"] or "HMGT: buff added: %s", tostring(info or sid)))
else
HMGT:Print(L["OPT_BEA_MSG_INVALID"] or "HMGT: invalid buff spell ID")
end
NotifyOptionsChanged()
end,
},
},
},
},
},
},
}
if #entries == 0 then
group.args.tracked.args.empty = {
type = "description",
order = 10,
width = "full",
name = L["OPT_BEA_EMPTY"] or "No buffs configured.",
}
else
for index, entry in ipairs(entries) do
local key = "buff_" .. tostring(tonumber(entry.spellId) or index)
local leaf = BuildTrackedBuffLeaf(entry)
leaf.order = 10 + index
leaf.inline = true
leaf.name = " "
leaf.hidden = function()
return not IsTrackedBuffVisible(entry)
end
group.args.tracked.args[key] = leaf
end
group.args.tracked.args.noVisible = {
type = "description",
order = 1000,
width = "full",
hidden = function()
return CountVisibleEntries(BEA:GetTrackedBuffEntries()) > 0
end,
name = L["OPT_BEA_EMPTY"] or "No buffs configured.",
}
end
return group
end
function BEA:GetOptionsGroup()
if not beaOptionsGroup then
beaOptionsGroup = self:BuildOptionsGroup()
else
RefreshTrackedGroup()
end
return beaOptionsGroup
end
HMGT_Config:RegisterOptionsProvider("announcer.buffEndingAnnouncer", function()
return {
path = "announcer",
order = 3,
group = BEA:GetOptionsGroup(),
}
end)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Button name="HMGTMapOverlayPinTemplate" mixin="HMGTMapOverlayPinMixin" virtual="true">
<Size x="16" y="16" />
<Scripts>
<OnLoad method="OnLoad" />
<OnEnter method="OnMouseEnter" />
<OnLeave method="OnMouseLeave" />
<OnClick method="OnClick" />
</Scripts>
</Button>
</Ui>

View File

@@ -0,0 +1,37 @@
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT then return end
local MEDIA_PATH = "Interface\\AddOns\\HailMaryGuildTools\\Modules\\MapOverlay\\Media\\"
local BLIP_TEXTURE = "Interface\\Minimap\\ObjectIconsAtlas"
-- Curated list of allowed Map Overlay icons.
-- Add, remove, or reorder entries here without touching the runtime module.
-- Supported fields per icon:
-- key, label, texture
-- atlasIndex + atlasSize = "32x32" (optionally textureWidth, textureHeight, atlasColumns)
-- or exact iconCoords = { left, right, top, bottom }
-- or exact cell = { col, row } for fixed 32x32 sheets
-- Note: 32x32 ObjectIconsAtlas minimap blips are rendered through Blizzard's
-- legacy ObjectIcons coordinate path internally, so the visible crop stays exact.
HMGT.MapOverlayIconConfig = {
defaultKey = "default",
icons = {
{ key = "default", label = "Default", texture = MEDIA_PATH .. "DefaultIcon.png" },
{ key = "auctionhouse", label = "Auction House", texture = BLIP_TEXTURE, atlasIndex = 16, atlasSize = "32x32", aliases = { "auction_house" } },
{ key = "bank", label = "Bank", texture = BLIP_TEXTURE, atlasIndex = 17, atlasSize = "32x32" },
{ key = "battlemaster", label = "Battlemaster", texture = BLIP_TEXTURE, atlasIndex = 18, atlasSize = "32x32" },
{ key = "classtrainer", label = "Class Trainer", texture = BLIP_TEXTURE, atlasIndex = 19, atlasSize = "32x32", aliases = { "class_trainer" } },
{ key = "fooddrink", label = "Food & Drink", texture = BLIP_TEXTURE, atlasIndex = 21, atlasSize = "32x32", aliases = { "food_drink" } },
{ key = "innkeeper", label = "Innkeeper", texture = BLIP_TEXTURE, atlasIndex = 22, atlasSize = "32x32" },
{ key = "poisons", label = "Poisons", texture = BLIP_TEXTURE, atlasIndex = 24, atlasSize = "32x32" },
{ key = "professiontrainer", label = "Profession Trainer", texture = BLIP_TEXTURE, atlasIndex = 25, atlasSize = "32x32", aliases = { "profession_trainer" } },
{ key = "reagents", label = "Reagents", texture = BLIP_TEXTURE, atlasIndex = 26, atlasSize = "32x32" },
{ key = "repairs", label = "Repairs", texture = BLIP_TEXTURE, atlasIndex = 27, atlasSize = "32x32" },
{ key = "blueblip", label = "Blue Blip", texture = BLIP_TEXTURE, atlasIndex = 0, atlasSize = "32x32", aliases = { "blip_blue" } },
{ key = "lightblueblip", label = "Light Blue Blip", texture = BLIP_TEXTURE, atlasIndex = 1, atlasSize = "32x32", aliases = { "blip_lightblue" } },
{ key = "redblip", label = "Red Blip", texture = BLIP_TEXTURE, atlasIndex = 2, atlasSize = "32x32", aliases = { "blip_red" } },
{ key = "yellowblip", label = "Yellow Blip", texture = BLIP_TEXTURE, atlasIndex = 3, atlasSize = "32x32", aliases = { "blip_yellow" } },
{ key = "greenblip", label = "Green Blip", texture = BLIP_TEXTURE, atlasIndex = 4, atlasSize = "32x32", aliases = { "blip_green" } },
},
}

View File

@@ -0,0 +1,554 @@
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT then return end
local MapOverlay = HMGT.MapOverlay
if not MapOverlay then return end
if not HMGT_Config or not HMGT_Config.RegisterOptionsProvider then return end
local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {}
local AceConfigRegistry = LibStub("AceConfigRegistry-3.0", true)
local MAX_POI_EDITOR_ROWS = 40
local function NotifyOptionsChanged()
if AceConfigRegistry and type(AceConfigRegistry.NotifyChange) == "function" then
AceConfigRegistry:NotifyChange(ADDON_NAME)
end
end
local function MapCategoryValues()
if HMGT.GetMapPOICategoryValues then
return HMGT:GetMapPOICategoryValues()
end
return { default = "Default" }
end
local function GetMapPOIs()
local profile = HMGT.db and HMGT.db.profile
if not profile then
return {}
end
profile.mapOverlay = profile.mapOverlay or {}
profile.mapOverlay.pois = profile.mapOverlay.pois or {}
return profile.mapOverlay.pois
end
local function HasPOIAt(index)
return GetMapPOIs()[index] ~= nil
end
local function HasAnyPOIs()
return GetMapPOIs()[1] ~= nil
end
local function GetPoiCount()
local count = 0
for _ in ipairs(GetMapPOIs()) do
count = count + 1
end
return count
end
local function EnsurePoiDraft(index)
local poi = GetMapPOIs()[index]
if not poi then
return nil
end
HMGT._mapPoiDrafts = HMGT._mapPoiDrafts or {}
local draft = HMGT._mapPoiDrafts[index]
if not draft then
draft = {
mapID = tostring(tonumber(poi.mapID) or ""),
x = string.format("%.2f", tonumber(poi.x) or 0),
y = string.format("%.2f", tonumber(poi.y) or 0),
label = tostring(poi.label or ""),
category = tostring(poi.category or "default"),
}
HMGT._mapPoiDrafts[index] = draft
end
return draft
end
local function BuildPoiEditorGroupArgs()
local function GetPoiTitle(index)
local poi = GetMapPOIs()[index]
if not poi then
return string.format("POI #%d", index)
end
return tostring(poi.label or ("POI " .. index))
end
local args = {
}
for index = 1, MAX_POI_EDITOR_ROWS do
local row = index
args["poi_" .. index] = {
type = "group",
name = function()
return GetPoiTitle(row)
end,
icon = function()
local poi = GetMapPOIs()[row]
if not poi or not MapOverlay.GetCategoryVisual then
return poi and poi.icon or nil
end
local texture = MapOverlay:GetCategoryVisual(poi.category)
return texture or poi.icon or nil
end,
iconCoords = function()
local poi = GetMapPOIs()[row]
if not poi or not MapOverlay.GetCategoryVisual then
return poi and poi.iconCoords or nil
end
local _, iconCoords = MapOverlay:GetCategoryVisual(poi.category)
return iconCoords or poi.iconCoords or nil
end,
order = 20 + index,
hidden = function()
return not HasPOIAt(row)
end,
args = {
details = {
type = "description",
order = 0.5,
width = "full",
name = function()
local poi = GetMapPOIs()[row]
if not poi then
return "POI not found."
end
local categoryValues = MapCategoryValues()
local category = tostring(poi.category or "default")
local categoryLabel = categoryValues[category] or category
return string.format(
"Map-ID: %d\nX: %.2f\nY: %.2f\nIcon: %s",
tonumber(poi.mapID) or 0,
tonumber(poi.x) or 0,
tonumber(poi.y) or 0,
tostring(categoryLabel)
)
end,
},
mapID = {
type = "input",
order = 1,
width = 0.7,
name = L["OPT_MAP_POI_MAPID"] or "Map ID",
get = function()
local draft = EnsurePoiDraft(row)
return (draft and draft.mapID) or ""
end,
set = function(_, value)
local draft = EnsurePoiDraft(row)
if draft then
draft.mapID = value
end
end,
},
x = {
type = "input",
order = 2,
width = 0.7,
name = L["OPT_MAP_POI_X"] or "X (0-100)",
get = function()
local draft = EnsurePoiDraft(row)
return (draft and draft.x) or ""
end,
set = function(_, value)
local draft = EnsurePoiDraft(row)
if draft then
draft.x = value
end
end,
},
y = {
type = "input",
order = 3,
width = 0.7,
name = L["OPT_MAP_POI_Y"] or "Y (0-100)",
get = function()
local draft = EnsurePoiDraft(row)
return (draft and draft.y) or ""
end,
set = function(_, value)
local draft = EnsurePoiDraft(row)
if draft then
draft.y = value
end
end,
},
label = {
type = "input",
order = 4,
width = "full",
name = L["OPT_MAP_POI_LABEL"] or "Label",
get = function()
local draft = EnsurePoiDraft(row)
return (draft and draft.label) or ""
end,
set = function(_, value)
local draft = EnsurePoiDraft(row)
if draft then
draft.label = value
end
end,
},
category = {
type = "select",
order = 5,
width = "full",
name = L["OPT_MAP_POI_CATEGORY"] or "Category",
values = function()
return MapCategoryValues()
end,
get = function()
local draft = EnsurePoiDraft(row)
return (draft and draft.category) or "default"
end,
set = function(_, value)
local draft = EnsurePoiDraft(row)
if draft then
draft.category = value
end
end,
},
waypoint = {
type = "execute",
order = 6,
width = "half",
name = "Waypoint",
func = function()
local poi = GetMapPOIs()[row]
if poi and MapOverlay.ToggleWaypointForPOI then
MapOverlay:ToggleWaypointForPOI(poi)
end
end,
},
update = {
type = "execute",
order = 7,
width = "half",
name = L["OPT_MAP_POI_UPDATE"] or "Update POI",
func = function()
local draft = EnsurePoiDraft(row)
if not draft then
return
end
local ok = HMGT.UpdateMapPOI and HMGT:UpdateMapPOI(row, draft.mapID, draft.x, draft.y, draft.label, nil, draft.category)
if ok then
if HMGT._mapPoiDrafts then
HMGT._mapPoiDrafts[row] = nil
end
HMGT:Print(L["OPT_MAP_POI_UPDATED"] or "HMGT: POI updated")
else
HMGT:Print(L["OPT_MAP_POI_UPDATE_FAILED"] or "HMGT: could not update POI")
end
NotifyOptionsChanged()
end,
},
delete = {
type = "execute",
order = 8,
width = "half",
name = L["OPT_MAP_POI_REMOVE"] or "Remove POI",
func = function()
local ok = HMGT.RemoveMapPOI and HMGT:RemoveMapPOI(row)
if ok then
HMGT._mapPoiDrafts = nil
HMGT:Print(L["OPT_MAP_POI_REMOVED"] or "HMGT: POI removed")
else
HMGT:Print(L["OPT_MAP_POI_REMOVE_FAILED"] or "HMGT: could not remove POI")
end
NotifyOptionsChanged()
end,
},
},
}
end
return args
end
function MapOverlay:GetOptionsGroup()
local group = {
type = "group",
name = L["OPT_MODULE_MAP_OVERLAY"] or "Map Overlay",
order = 30,
childGroups = "tree",
args = {
general = {
type = "group",
order = 1,
name = GENERAL or "General",
args = {
enabled = {
type = "toggle",
order = 1,
width = "full",
name = L["OPT_MAP_ENABLED"] or "Enable map overlay",
get = function()
local profile = HMGT.db and HMGT.db.profile
if not profile then
return true
end
profile.mapOverlay = profile.mapOverlay or {}
return profile.mapOverlay.enabled ~= false
end,
set = function(_, value)
local profile = HMGT.db and HMGT.db.profile
if not profile then
return
end
profile.mapOverlay = profile.mapOverlay or {}
profile.mapOverlay.enabled = value
if MapOverlay.Refresh then
MapOverlay:Refresh()
end
end,
},
iconSize = {
type = "range",
order = 2,
min = 8,
max = 48,
step = 1,
name = L["OPT_MAP_ICON_SIZE"] or "Icon size",
get = function()
local profile = HMGT.db and HMGT.db.profile
if not profile then
return 16
end
profile.mapOverlay = profile.mapOverlay or {}
return profile.mapOverlay.iconSize or 16
end,
set = function(_, value)
local profile = HMGT.db and HMGT.db.profile
if not profile then
return
end
profile.mapOverlay = profile.mapOverlay or {}
profile.mapOverlay.iconSize = value
if MapOverlay.Refresh then
MapOverlay:Refresh()
end
end,
},
alpha = {
type = "range",
order = 3,
min = 0.1,
max = 1,
step = 0.05,
name = L["OPT_MAP_ALPHA"] or "Icon alpha",
get = function()
local profile = HMGT.db and HMGT.db.profile
if not profile then
return 1
end
profile.mapOverlay = profile.mapOverlay or {}
return profile.mapOverlay.alpha or 1
end,
set = function(_, value)
local profile = HMGT.db and HMGT.db.profile
if not profile then
return
end
profile.mapOverlay = profile.mapOverlay or {}
profile.mapOverlay.alpha = value
if MapOverlay.Refresh then
MapOverlay:Refresh()
end
end,
},
showLabels = {
type = "toggle",
order = 4,
width = "full",
name = L["OPT_MAP_SHOW_LABELS"] or "Show labels",
get = function()
local profile = HMGT.db and HMGT.db.profile
if not profile then
return true
end
profile.mapOverlay = profile.mapOverlay or {}
return profile.mapOverlay.showLabels ~= false
end,
set = function(_, value)
local profile = HMGT.db and HMGT.db.profile
if not profile then
return
end
profile.mapOverlay = profile.mapOverlay or {}
profile.mapOverlay.showLabels = value
if MapOverlay.Refresh then
MapOverlay:Refresh()
end
end,
},
poiSection = {
type = "header",
order = 10,
name = L["OPT_MAP_POI_SECTION"] or "Custom POIs",
},
poiSummary = {
type = "description",
order = 10.1,
width = "full",
name = function()
local count = GetPoiCount()
if count <= 0 then
return L["OPT_MAP_POI_EMPTY"] or "No POIs configured."
end
return string.format(
"%s: %d\n%s",
L["OPT_MAP_POI_LIST"] or "Current POIs",
count,
L["OPT_MAP_POI_SELECT_HINT"] or "Select a POI in the tree on the left to edit it."
)
end,
},
draftMapID = {
type = "input",
order = 11,
width = 0.8,
name = L["OPT_MAP_POI_MAPID"] or "Map ID",
get = function()
HMGT._mapDraft = HMGT._mapDraft or {}
if not HMGT._mapDraft.mapID and MapOverlay.GetActiveMapID then
local activeMap = MapOverlay:GetActiveMapID()
if activeMap then
HMGT._mapDraft.mapID = tostring(activeMap)
end
end
return HMGT._mapDraft.mapID or ""
end,
set = function(_, value)
HMGT._mapDraft = HMGT._mapDraft or {}
HMGT._mapDraft.mapID = value
end,
},
useCurrentPosition = {
type = "execute",
order = 11.1,
width = "half",
name = L["OPT_MAP_POI_USE_CURRENT"] or "Use current position",
desc = L["OPT_MAP_POI_USE_CURRENT_DESC"] or "Fill map ID, X and Y from your current player position",
func = function()
HMGT._mapDraft = HMGT._mapDraft or {}
local mapID, x, y = nil, nil, nil
if HMGT.GetCurrentMapPOIData then
mapID, x, y = HMGT:GetCurrentMapPOIData()
end
if mapID and x and y then
HMGT._mapDraft.mapID = tostring(mapID)
HMGT._mapDraft.x = string.format("%.2f", x)
HMGT._mapDraft.y = string.format("%.2f", y)
HMGT:Print(L["OPT_MAP_POI_CURRENT_SET"] or "HMGT: current position copied")
else
HMGT:Print(L["OPT_MAP_POI_CURRENT_FAILED"] or "HMGT: could not determine current position")
end
NotifyOptionsChanged()
end,
},
draftX = {
type = "input",
order = 12,
width = 0.8,
name = L["OPT_MAP_POI_X"] or "X (0-100)",
get = function()
HMGT._mapDraft = HMGT._mapDraft or {}
return HMGT._mapDraft.x or ""
end,
set = function(_, value)
HMGT._mapDraft = HMGT._mapDraft or {}
HMGT._mapDraft.x = value
end,
},
draftY = {
type = "input",
order = 13,
width = 0.8,
name = L["OPT_MAP_POI_Y"] or "Y (0-100)",
get = function()
HMGT._mapDraft = HMGT._mapDraft or {}
return HMGT._mapDraft.y or ""
end,
set = function(_, value)
HMGT._mapDraft = HMGT._mapDraft or {}
HMGT._mapDraft.y = value
end,
},
draftLabel = {
type = "input",
order = 14,
width = "full",
name = L["OPT_MAP_POI_LABEL"] or "Label",
get = function()
HMGT._mapDraft = HMGT._mapDraft or {}
return HMGT._mapDraft.label or ""
end,
set = function(_, value)
HMGT._mapDraft = HMGT._mapDraft or {}
HMGT._mapDraft.label = value
end,
},
draftCategory = {
type = "select",
order = 14.1,
width = "full",
name = L["OPT_MAP_POI_CATEGORY"] or "Category",
values = function()
return MapCategoryValues()
end,
get = function()
HMGT._mapDraft = HMGT._mapDraft or {}
return HMGT._mapDraft.category or "default"
end,
set = function(_, value)
HMGT._mapDraft = HMGT._mapDraft or {}
HMGT._mapDraft.category = value
end,
},
addPoi = {
type = "execute",
order = 15,
width = "half",
name = L["OPT_MAP_POI_ADD"] or "Add POI",
func = function()
HMGT._mapDraft = HMGT._mapDraft or {}
local draft = HMGT._mapDraft
local ok = HMGT.AddMapPOI and HMGT:AddMapPOI(draft.mapID, draft.x, draft.y, draft.label, nil, draft.category or "default")
if ok then
HMGT._mapPoiDrafts = nil
HMGT:Print(L["OPT_MAP_POI_ADDED"] or "HMGT: POI added")
else
HMGT:Print(L["OPT_MAP_POI_ADD_FAILED"] or "HMGT: could not add POI")
end
NotifyOptionsChanged()
end,
},
},
},
},
}
for key, value in pairs(BuildPoiEditorGroupArgs()) do
group.args[key] = value
end
return group
end
HMGT_Config:RegisterOptionsProvider("map.overlay", function()
return {
path = "map.overlay",
order = 30,
group = MapOverlay:GetOptionsGroup(),
}
end)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,202 @@
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT then return end
local RT = HMGT.RaidTimeline
if not RT then return end
local function TrimText(value)
local text = tostring(value or "")
text = string.gsub(text, "^%s+", "")
text = string.gsub(text, "%s+$", "")
return text
end
local function StripBarDisplayText(value)
local text = tostring(value or "")
text = string.gsub(text, "|T.-|t", "")
text = string.gsub(text, "|c%x%x%x%x%x%x%x%x", "")
text = string.gsub(text, "|r", "")
return TrimText(text)
end
local function NormalizeBossAbilityBarName(value)
local text = TrimText(value)
if text == "" then
return ""
end
text = string.gsub(text, "%s*%(%d+%)%s*$", "")
text = string.gsub(text, "%s*%([Cc]ount%)%s*$", "")
text = string.gsub(text, "^%s*%[([A-Za-z])%]%s*", "%1 ")
text = string.gsub(text, "%s*%[([A-Za-z])%]%s*$", " (%1)")
text = string.gsub(text, "%s*%(([A-Za-z])%)%s*$", " (%1)")
text = string.lower(text)
return TrimText(text)
end
local function EnsureBigWigsBridge()
if RT._bigWigsBridgeRegistered == true then
return true
end
if type(BigWigsLoader) ~= "table" or type(BigWigsLoader.RegisterMessage) ~= "function" then
return false
end
RT._bigWigsObservedBars = RT._bigWigsObservedBars or setmetatable({}, { __mode = "k" })
RT._bigWigsReceiver = RT._bigWigsReceiver or {}
function RT._bigWigsReceiver:OnBigWigsBarCreated(_, plugin, bar, module, key, text, time)
RT._bigWigsObservedBars[bar] = {
plugin = plugin,
module = module,
key = key,
createdText = tostring(text or ""),
duration = tonumber(time) or 0,
}
end
function RT._bigWigsReceiver:OnBigWigsStopBar(_, plugin, module, text)
local targetText = StripBarDisplayText(text)
for bar, info in pairs(RT._bigWigsObservedBars) do
local currentText = ""
if type(bar) == "table" and type(bar.GetText) == "function" then
currentText = StripBarDisplayText(bar:GetText())
end
if (info and info.plugin == plugin or not info or not info.plugin)
and (currentText == targetText or StripBarDisplayText(info and info.createdText) == targetText) then
RT._bigWigsObservedBars[bar] = nil
end
end
end
BigWigsLoader.RegisterMessage(RT._bigWigsReceiver, "BigWigs_BarCreated", "OnBigWigsBarCreated")
BigWigsLoader.RegisterMessage(RT._bigWigsReceiver, "BigWigs_StopBar", "OnBigWigsStopBar")
RT._bigWigsBridgeRegistered = true
return true
end
function RT:GetObservedBigWigsBars()
local observed = {}
if not EnsureBigWigsBridge() then
return observed
end
for bar, info in pairs(self._bigWigsObservedBars or {}) do
local rawText = nil
if type(bar) == "table" and type(bar.GetText) == "function" then
rawText = bar:GetText()
end
rawText = StripBarDisplayText(rawText or (info and info.createdText) or "")
local remaining = math.max(0, tonumber(bar and bar.remaining) or 0)
if rawText ~= "" and remaining > 0 then
observed[#observed + 1] = {
rawText = rawText,
normalizedName = NormalizeBossAbilityBarName(rawText),
count = tonumber(string.match(rawText, "%((%d+)%)%s*$")) or 1,
seconds = remaining,
key = info and info.key or nil,
}
else
self._bigWigsObservedBars[bar] = nil
end
end
return observed
end
function RT:ProbeBossAbilityEntry(entryIndex, entry)
if not self:IsLocalEditor() or not self.activeEncounterId or self:GetTriggerType(entry) ~= "bossAbility" then
return
end
local desiredRawName = TrimText(entry and entry.bossAbilityBarName or "")
local desiredName = NormalizeBossAbilityBarName(desiredRawName)
local desiredSelector = self:NormalizeCastCountSelector(entry and entry.castCount)
local desiredSelectorText = self:FormatCastCountSelector(desiredSelector)
if desiredName == "" then
self:LogBossAbilityProbe(
entryIndex,
entry,
"missing-bar-name",
"RaidTimeline BigWigs probe encounter=%s slot=%s skipped=missing-bar-name",
tostring(self.activeEncounterId),
tostring(entryIndex)
)
return
end
local bars = self:GetObservedBigWigsBars()
if #bars == 0 then
self:LogBossAbilityProbe(
entryIndex,
entry,
"no-bars",
"RaidTimeline BigWigs probe encounter=%s slot=%s target=%s normalized=%s selector=%s foundBar=false bars=0",
tostring(self.activeEncounterId),
tostring(entryIndex),
tostring(desiredRawName),
tostring(desiredName),
tostring(desiredSelectorText)
)
return
end
local seenNames = {}
local foundName = false
for _, observed in ipairs(bars) do
seenNames[#seenNames + 1] = string.format("%s{key=%s}[%d]=%.1fs", tostring(observed.rawText), tostring(observed.key), tonumber(observed.count) or 1, tonumber(observed.seconds) or 0)
if observed.normalizedName == desiredName then
foundName = true
if self:DoesCastCountSelectorMatch(desiredSelector, observed.count) then
self:LogBossAbilityProbe(
entryIndex,
entry,
string.format("match:%s:%s:%d", desiredName, tostring(desiredSelector), tonumber(observed.count) or 1),
"RaidTimeline BigWigs probe encounter=%s slot=%s target=%s normalized=%s selector=%s foundBar=true countMatch=true observed=%s key=%s observedCount=%d remaining=%.1f",
tostring(self.activeEncounterId),
tostring(entryIndex),
tostring(desiredRawName),
tostring(desiredName),
tostring(desiredSelectorText),
tostring(observed.rawText),
tostring(observed.key),
tonumber(observed.count) or 1,
tonumber(observed.seconds) or 0
)
self:TryDispatchBossAbilityEntry(entryIndex, entry, observed.count, observed.seconds)
return
end
self:LogBossAbilityProbe(
entryIndex,
entry,
string.format("count-mismatch:%s:%d", desiredName, observed.count),
"RaidTimeline BigWigs probe encounter=%s slot=%s target=%s normalized=%s selector=%s foundBar=true countMatch=false observed=%s key=%s observedCount=%d remaining=%.1f",
tostring(self.activeEncounterId),
tostring(entryIndex),
tostring(desiredRawName),
tostring(desiredName),
tostring(desiredSelectorText),
tostring(observed.rawText),
tostring(observed.key),
tonumber(observed.count) or 1,
tonumber(observed.seconds) or 0
)
end
end
if not foundName then
self:LogBossAbilityProbe(
entryIndex,
entry,
string.format("name-miss:%s", desiredName),
"RaidTimeline BigWigs probe encounter=%s slot=%s target=%s normalized=%s selector=%s foundBar=false bars=%s",
tostring(self.activeEncounterId),
tostring(entryIndex),
tostring(desiredRawName),
tostring(desiredName),
tostring(desiredSelectorText),
table.concat(seenNames, ", ")
)
end
end

View File

@@ -0,0 +1,52 @@
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT then return end
HMGT.RaidTimelineBossAbilityData = HMGT.RaidTimelineBossAbilityData or {
raids = {
--[[
{
name = "Void Spire",
journalInstanceId = 1307,
bosses = {
[3176] = {
name = "Imperator Averzian",
abilities = {
{
key = "gloom",
name = "Gloom",
spellId = 123456,
icon = 1234567,
difficulties = {
lfr = false,
normal = true,
heroic = true,
mythic = true,
},
triggers = {
bigwigs = { 123456, "gloom" },
dbm = { 123456 },
},
},
{
key = "mythic_gloom",
name = "Mythic Gloom",
spellId = 123457,
difficulties = {
lfr = false,
normal = false,
heroic = false,
mythic = true,
},
triggers = {
bigwigs = { 123457 },
dbm = { 123457 },
},
},
},
},
},
},
]]
},
}

View File

@@ -0,0 +1,8 @@
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT then return end
local RT = HMGT.RaidTimeline
if not RT then return end
-- Placeholder for later DBM-specific raid timeline integration.

View File

@@ -0,0 +1,799 @@
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT then return end
local RT = HMGT.RaidTimeline
if not RT then return end
if not HMGT_Config or not HMGT_Config.RegisterOptionsProvider then return end
local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {}
local AceConfigRegistry = LibStub("AceConfigRegistry-3.0")
local LSM = LibStub("LibSharedMedia-3.0", true)
local FONT_OUTLINE_VALUES = {
NONE = NONE or "None",
OUTLINE = "Outline",
THICKOUTLINE = "Thick Outline",
MONOCHROME = "Monochrome",
["OUTLINE,MONOCHROME"] = "Outline Monochrome",
}
local DIFFICULTY_KEYS = { "lfr", "normal", "heroic", "mythic" }
local MAX_ENTRY_ROWS = 24
local raidTimelineOptionsGroup
local raidCooldownSpellValuesCache
local bossAbilityValuesCache = {}
local function ClearOptionCaches()
raidCooldownSpellValuesCache = nil
bossAbilityValuesCache = {}
end
local function Notify(rebuild)
if rebuild then
ClearOptionCaches()
end
if rebuild and raidTimelineOptionsGroup then
local fresh = RT:GetOptionsGroup()
raidTimelineOptionsGroup.name = fresh.name
raidTimelineOptionsGroup.order = fresh.order
raidTimelineOptionsGroup.childGroups = fresh.childGroups
raidTimelineOptionsGroup.args = fresh.args
end
AceConfigRegistry:NotifyChange(ADDON_NAME)
end
local function GetDrafts()
HMGT._raidTimelineDraft = HMGT._raidTimelineDraft or {
addEncounterId = "",
addEncounterName = "",
entries = {},
}
HMGT._raidTimelineDraft.entries = HMGT._raidTimelineDraft.entries or {}
return HMGT._raidTimelineDraft
end
local function TrimText(value)
return tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")
end
local function GetEncounterDraft(encounterId)
local drafts = GetDrafts()
local key = tostring(tonumber(encounterId) or encounterId or "")
drafts.entries[key] = drafts.entries[key] or {
time = "",
spellId = 0,
alertText = "",
playerName = "",
entryType = "spell",
triggerType = "time",
actionType = "raidCooldown",
targetSpec = "",
bossAbilityId = "",
bossAbilityBarName = "",
castCount = "1",
}
return drafts.entries[key]
end
local function GetTriggerTypeValues()
return (RT.GetTriggerTypeValues and RT:GetTriggerTypeValues()) or {
time = L["OPT_RT_TRIGGER_TIME"] or "Time",
bossAbility = L["OPT_RT_TRIGGER_BOSS_ABILITY"] or "Boss ability",
}
end
local function GetActionTypeValues()
return (RT.GetActionTypeValues and RT:GetActionTypeValues()) or {
text = L["OPT_RT_ACTION_TEXT"] or "Text",
raidCooldown = L["OPT_RT_ACTION_RAID_COOLDOWN"] or "Raid Cooldown",
}
end
local function GetEncounterEntry(encounterId, row)
local encounter = encounterId and RT:GetEncounter(encounterId)
return encounter and encounter.entries and encounter.entries[row] or nil
end
local function GetTargetFieldLabel(kind, isAddRow)
return (isAddRow and (L["OPT_RT_ADD_PLAYER"] or "Target")) or (L["OPT_RT_ENTRY_PLAYER"] or "Target")
end
local function GetTargetFieldDesc(kind)
return L["OPT_RT_ADD_PLAYER_DESC"] or "Optional. Comma-separated player names or variables like Group1, Group8, GroupEven, GroupOdd."
end
local function FormatEntryTime(value)
return RT.FormatTimelineClock and RT:FormatTimelineClock(value) or tostring(value or "")
end
local function GetBossAbilityValues(encounterId)
local encounterKey = tostring(tonumber(encounterId) or encounterId or 0)
if bossAbilityValuesCache[encounterKey] then
return bossAbilityValuesCache[encounterKey]
end
local values = RT.GetBossAbilityValues and RT:GetBossAbilityValues(encounterId) or { [""] = L["OPT_RT_NO_BOSS_ABILITY"] or "No boss ability" }
bossAbilityValuesCache[encounterKey] = values
return values
end
local function GetDraftTriggerType(encounterId)
local draft = GetEncounterDraft(encounterId)
draft.triggerType = tostring(draft.triggerType or "time")
return draft.triggerType == "bossAbility" and "bossAbility" or "time"
end
local function GetDraftActionType(encounterId)
local draft = GetEncounterDraft(encounterId)
draft.actionType = tostring(draft.actionType or "raidCooldown")
return draft.actionType == "text" and "text" or "raidCooldown"
end
local function GetSpellName(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return nil end
if C_Spell and type(C_Spell.GetSpellName) == "function" then
local name = C_Spell.GetSpellName(sid)
if type(name) == "string" and name ~= "" then
return name
end
end
if type(GetSpellInfo) == "function" then
local name = GetSpellInfo(sid)
if type(name) == "string" and name ~= "" then
return name
end
end
return nil
end
local function GetSpellIcon(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then return nil end
if HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then
local icon = HMGT_SpellData.GetSpellIcon(sid)
if icon and icon ~= "" then
return icon
end
end
if C_Spell and type(C_Spell.GetSpellTexture) == "function" then
local icon = C_Spell.GetSpellTexture(sid)
if icon and icon ~= "" then
return icon
end
end
local _, _, icon = GetSpellInfo(sid)
return icon
end
local function GetRaidCooldownSpellValues()
if raidCooldownSpellValuesCache then
return raidCooldownSpellValuesCache
end
local values = { [0] = L["OPT_RT_NO_SPELL"] or "No spell" }
local seen = { [0] = true }
for _, entry in ipairs(HMGT_SpellData and HMGT_SpellData.RaidCooldowns or {}) do
local spellId = tonumber(entry and entry.spellId)
if spellId and spellId > 0 and not seen[spellId] then
seen[spellId] = true
local spellName = GetSpellName(spellId) or tostring(entry.name or ("Spell " .. spellId))
local icon = GetSpellIcon(spellId)
values[spellId] = icon and icon ~= ""
and string.format("|T%s:16:16:0:0|t %s (%d)", tostring(icon), spellName, spellId)
or string.format("%s (%d)", spellName, spellId)
end
end
raidCooldownSpellValuesCache = values
return values
end
local function GetDifficultyLabel(key)
if key == "lfr" then return L["OPT_RT_DIFF_LFR"] or "LFR" end
if key == "heroic" then return L["OPT_RT_DIFF_HEROIC"] or "HC" end
if key == "mythic" then return L["OPT_RT_DIFF_MYTHIC"] or "Mythic" end
return L["OPT_RT_DIFF_NORMAL"] or "Normal"
end
local function GetEncounterLabel(encounterId)
local encounter = RT:GetEncounter(encounterId)
local name = TrimText(encounter and encounter.name or "")
if name == "" then
name = L["OPT_RT_ENCOUNTER"] or "Encounter"
end
return string.format("%s (%d)", name, tonumber(encounterId) or 0)
end
local function GetBossTreeLabel(encounterId)
local encounter = RT:GetEncounter(encounterId)
local name = TrimText(encounter and encounter.name or "")
if name == "" then
name = L["OPT_RT_ENCOUNTER"] or "Encounter"
end
return string.format("%s (%d)", name, #(encounter and encounter.entries or {}))
end
local function GetRaidBuckets()
local buckets, raidNames = {}, {}
for _, encounterId in ipairs(RT:GetEncounterIds()) do
local encounter = RT:GetEncounter(encounterId)
if encounter then
local journalInstanceId, instanceName = RT:GetEncounterInstanceInfo(encounterId)
local raidName = tostring(encounter.instanceName or instanceName or (L["OPT_RT_RAID_DEFAULT"] or "Encounter"))
if not buckets[raidName] then
buckets[raidName] = {
key = tostring(journalInstanceId or raidName),
ids = {},
difficulties = { lfr = {}, normal = {}, heroic = {}, mythic = {} },
}
table.insert(raidNames, raidName)
end
table.insert(buckets[raidName].ids, encounterId)
for _, difficultyKey in ipairs(DIFFICULTY_KEYS) do
if encounter.difficulties and encounter.difficulties[difficultyKey] == true then
table.insert(buckets[raidName].difficulties[difficultyKey], encounterId)
end
end
end
end
table.sort(raidNames)
for _, raidName in ipairs(raidNames) do
table.sort(buckets[raidName].ids)
for _, difficultyKey in ipairs(DIFFICULTY_KEYS) do
table.sort(buckets[raidName].difficulties[difficultyKey])
end
end
return raidNames, buckets
end
local function BuildEntryEditorArgs(encounterId)
local args = {
entriesHeader = {
type = "header",
order = 20,
name = L["OPT_RT_ENCOUNTERS_HEADER"] or "Encounter timelines",
},
addTime = {
type = "input", order = 21, width = 0.7, name = L["OPT_RT_ADD_TIME"] or "Time (MM:SS)",
disabled = function() return not RT:IsLocalEditor() end,
get = function() return tostring(GetEncounterDraft(encounterId).time or "") end,
set = function(_, val) GetEncounterDraft(encounterId).time = val end,
hidden = function() return GetDraftTriggerType(encounterId) == "bossAbility" end,
},
addTriggerType = {
type = "select", order = 22, width = 0.8, name = L["OPT_RT_TRIGGER"] or "Trigger",
disabled = function() return not RT:IsLocalEditor() end,
values = GetTriggerTypeValues,
get = function() return GetDraftTriggerType(encounterId) end,
set = function(_, val)
local draft = GetEncounterDraft(encounterId)
draft.triggerType = (tostring(val or "") == "bossAbility") and "bossAbility" or "time"
if draft.triggerType == "time" then
draft.bossAbilityId = ""
draft.bossAbilityBarName = ""
draft.castCount = "1"
end
Notify(false)
end,
},
addActionType = {
type = "select", order = 22.1, width = 1.0, name = L["OPT_RT_ACTION"] or "Action",
disabled = function() return not RT:IsLocalEditor() end,
values = GetActionTypeValues,
get = function() return GetDraftActionType(encounterId) end,
set = function(_, val)
local draft = GetEncounterDraft(encounterId)
draft.actionType = (tostring(val or "") == "text") and "text" or "raidCooldown"
if draft.actionType == "text" then
draft.spellId = 0
end
Notify(false)
end,
},
addSpellId = {
type = "select", order = 23, width = 1.2, name = L["OPT_RT_ADD_SPELL"] or "Spell",
disabled = function() return not RT:IsLocalEditor() end,
values = GetRaidCooldownSpellValues,
get = function() return math.max(0, tonumber(GetEncounterDraft(encounterId).spellId) or 0) end,
set = function(_, val) GetEncounterDraft(encounterId).spellId = math.max(0, tonumber(val) or 0) end,
hidden = function() return GetDraftActionType(encounterId) ~= "raidCooldown" end,
},
addCastCount = {
type = "input", order = 23.1, width = 0.7, name = L["OPT_RT_CAST_COUNT"] or "Cast count",
disabled = function() return not RT:IsLocalEditor() end,
desc = L["OPT_RT_CAST_COUNT_DESC"] or "Use a number, All, Odd, or Even.",
get = function() return RT:FormatCastCountSelector(GetEncounterDraft(encounterId).castCount or "1") end,
set = function(_, val) GetEncounterDraft(encounterId).castCount = RT:NormalizeCastCountSelector(val) end,
hidden = function() return GetDraftTriggerType(encounterId) ~= "bossAbility" end,
},
addBossAbilityBarName = {
type = "input", order = 23.2, width = 1.2, name = L["OPT_RT_BOSS_BAR_NAME"] or "Bossmod bar name",
disabled = function() return not RT:IsLocalEditor() end,
get = function() return tostring(GetEncounterDraft(encounterId).bossAbilityBarName or "") end,
set = function(_, val) GetEncounterDraft(encounterId).bossAbilityBarName = tostring(val or "") end,
hidden = function() return GetDraftTriggerType(encounterId) ~= "bossAbility" end,
},
addAlertText = {
type = "input", order = 24, width = 1.2, name = L["OPT_RT_ADD_TEXT"] or "Custom text",
disabled = function() return not RT:IsLocalEditor() end,
get = function() return tostring(GetEncounterDraft(encounterId).alertText or "") end,
set = function(_, val) GetEncounterDraft(encounterId).alertText = tostring(val or "") end,
hidden = function()
return GetDraftActionType(encounterId) ~= "text"
end,
},
addPlayerName = {
type = "input", order = 25, width = 1.2,
name = function() return GetTargetFieldLabel(GetDraftActionType(encounterId), true) end,
desc = function() return GetTargetFieldDesc(GetDraftActionType(encounterId)) end,
disabled = function() return not RT:IsLocalEditor() end,
get = function()
local draft = GetEncounterDraft(encounterId)
return tostring(draft.playerName or draft.targetSpec or "")
end,
set = function(_, val)
local draft = GetEncounterDraft(encounterId)
draft.playerName = tostring(val or "")
draft.targetSpec = tostring(val or "")
end,
},
addEntry = {
type = "execute", order = 26, width = "full", name = L["OPT_RT_ADD_ENTRY"] or "Add entry",
disabled = function() return not RT:IsLocalEditor() end,
func = function()
local draft = GetEncounterDraft(encounterId)
local ok = RT:AddDetailedEntry(encounterId, draft)
if ok then
draft.time, draft.spellId, draft.alertText, draft.playerName, draft.entryType, draft.triggerType, draft.actionType, draft.targetSpec, draft.bossAbilityId, draft.bossAbilityBarName, draft.castCount = "", 0, "", "", "spell", "time", "raidCooldown", "", "", "", "1"
Notify(true)
else
HMGT:Print(L["OPT_RT_ADD_ENTRY_INVALID"] or "HMGT: invalid raid timeline entry")
end
end,
},
addBreak = { type = "description", order = 27, width = "full", name = " " },
}
for entryRow = 1, MAX_ENTRY_ROWS do
local order = 40 + (entryRow * 10)
args["entryTime_" .. entryRow] = {
type = "input", order = order, width = 0.7,
disabled = function() return not RT:IsLocalEditor() end,
name = function() return entryRow == 1 and (L["OPT_RT_ENTRY_TIME"] or "Time") or "" end,
get = function() local entry = GetEncounterEntry(encounterId, entryRow); return entry and FormatEntryTime(entry.time or 0) or "" end,
set = function(_, val)
if not RT:SetEntryField(encounterId, entryRow, "time", val) then
HMGT:Print(L["OPT_RT_INVALID_TIME"] or "HMGT: invalid time")
end
Notify(false)
end,
hidden = function()
local entry = GetEncounterEntry(encounterId, entryRow)
return not entry or RT:GetTriggerType(entry) == "bossAbility"
end,
}
args["entryTriggerType_" .. entryRow] = {
type = "select", order = order + 1, width = 0.8,
disabled = function() return not RT:IsLocalEditor() end,
name = function() return entryRow == 1 and (L["OPT_RT_TRIGGER"] or "Trigger") or "" end,
values = GetTriggerTypeValues,
get = function() local entry = GetEncounterEntry(encounterId, entryRow); return entry and RT:GetTriggerType(entry) or "time" end,
set = function(_, val) RT:SetEntryField(encounterId, entryRow, "triggerType", val); Notify(false) end,
hidden = function() return GetEncounterEntry(encounterId, entryRow) == nil end,
}
args["entryActionType_" .. entryRow] = {
type = "select", order = order + 1.1, width = 1.0,
disabled = function() return not RT:IsLocalEditor() end,
name = function() return entryRow == 1 and (L["OPT_RT_ACTION"] or "Action") or "" end,
values = GetActionTypeValues,
get = function() local entry = GetEncounterEntry(encounterId, entryRow); return entry and RT:GetActionType(entry) or "raidCooldown" end,
set = function(_, val) RT:SetEntryField(encounterId, entryRow, "actionType", val); Notify(false) end,
hidden = function() return GetEncounterEntry(encounterId, entryRow) == nil end,
}
args["entrySpell_" .. entryRow] = {
type = "select", order = order + 2, width = 1.2,
disabled = function() return not RT:IsLocalEditor() end,
name = function() return entryRow == 1 and (L["OPT_RT_ENTRY_SPELL"] or "Spell") or "" end,
values = GetRaidCooldownSpellValues,
get = function() local entry = GetEncounterEntry(encounterId, entryRow); return entry and math.max(0, tonumber(entry.spellId) or 0) or 0 end,
set = function(_, val)
if not RT:SetEntryField(encounterId, entryRow, "spellId", math.max(0, tonumber(val) or 0)) then
HMGT:Print(L["OPT_RT_INVALID_SPELL"] or "HMGT: invalid spell ID")
end
Notify(false)
end,
hidden = function() local entry = GetEncounterEntry(encounterId, entryRow); return not entry or RT:GetActionType(entry) ~= "raidCooldown" end,
}
args["entryCastCount_" .. entryRow] = {
type = "input", order = order + 2.1, width = 0.7,
disabled = function() return not RT:IsLocalEditor() end,
name = function() return entryRow == 1 and (L["OPT_RT_CAST_COUNT"] or "Cast count") or "" end,
desc = L["OPT_RT_CAST_COUNT_DESC"] or "Use a number, All, Odd, or Even.",
get = function() local entry = GetEncounterEntry(encounterId, entryRow); return entry and RT:FormatCastCountSelector(entry.castCount) or "1" end,
set = function(_, val) RT:SetEntryField(encounterId, entryRow, "castCount", val); Notify(false) end,
hidden = function() local entry = GetEncounterEntry(encounterId, entryRow); return not entry or RT:GetTriggerType(entry) ~= "bossAbility" end,
}
args["entryBossAbilityBarName_" .. entryRow] = {
type = "input", order = order + 2.2, width = 1.2,
disabled = function() return not RT:IsLocalEditor() end,
name = function() return entryRow == 1 and (L["OPT_RT_BOSS_BAR_NAME"] or "Bossmod bar name") or "" end,
get = function() local entry = GetEncounterEntry(encounterId, entryRow); return entry and tostring(entry.bossAbilityBarName or "") or "" end,
set = function(_, val) RT:SetEntryField(encounterId, entryRow, "bossAbilityBarName", val); Notify(false) end,
hidden = function() local entry = GetEncounterEntry(encounterId, entryRow); return not entry or RT:GetTriggerType(entry) ~= "bossAbility" end,
}
args["entryText_" .. entryRow] = {
type = "input", order = order + 3, width = 1.2,
disabled = function() return not RT:IsLocalEditor() end,
name = function() return entryRow == 1 and (L["OPT_RT_ENTRY_TEXT"] or "Custom text") or "" end,
get = function() local entry = GetEncounterEntry(encounterId, entryRow); return entry and tostring(entry.alertText or "") or "" end,
set = function(_, val) RT:SetEntryField(encounterId, entryRow, "alertText", val); Notify(false) end,
hidden = function()
local entry = GetEncounterEntry(encounterId, entryRow)
if not entry then
return true
end
return RT:GetActionType(entry) ~= "text"
end,
}
args["entryPlayer_" .. entryRow] = {
type = "input", order = order + 4, width = 1.1,
disabled = function() return not RT:IsLocalEditor() end,
name = function()
local entry = GetEncounterEntry(encounterId, entryRow)
return entryRow == 1 and GetTargetFieldLabel(entry and RT:GetActionType(entry) or "raidCooldown", false) or ""
end,
desc = function()
local entry = GetEncounterEntry(encounterId, entryRow)
return GetTargetFieldDesc(entry and RT:GetActionType(entry) or "raidCooldown")
end,
get = function()
local entry = GetEncounterEntry(encounterId, entryRow)
if not entry then return "" end
return tostring(entry.playerName or entry.targetSpec or "")
end,
set = function(_, val)
local entry = GetEncounterEntry(encounterId, entryRow)
if entry then
RT:SetEntryField(encounterId, entryRow, "playerName", val)
RT:SetEntryField(encounterId, entryRow, "targetSpec", val)
Notify(false)
end
end,
hidden = function() return GetEncounterEntry(encounterId, entryRow) == nil end,
}
args["entryDelete_" .. entryRow] = {
type = "execute", order = order + 5, width = 0.6, name = REMOVE or "Remove",
disabled = function() return not RT:IsLocalEditor() end,
func = function() RT:RemoveEntry(encounterId, entryRow); Notify(true) end,
hidden = function() return GetEncounterEntry(encounterId, entryRow) == nil end,
}
args["entryBreak_" .. entryRow] = {
type = "description", order = order + 6, width = "full", name = " ",
hidden = function() return GetEncounterEntry(encounterId, entryRow) == nil end,
}
end
return args
end
local function BuildEncounterDetailGroup(encounterId)
local encounter = RT:GetEncounter(encounterId)
if not encounter then return nil end
local args = {
header = { type = "header", order = 1, name = GetEncounterLabel(encounterId) },
encounterId = {
type = "description", order = 2, width = "full",
name = string.format("|cffffd100%s|r: %d", L["OPT_RT_ADD_ENCOUNTER_ID"] or "Encounter ID", tonumber(encounterId) or 0),
},
raidName = {
type = "description", order = 3, width = "full",
name = function()
local _, instanceName = RT:GetEncounterInstanceInfo(encounterId)
local current = RT:GetEncounter(encounterId)
return string.format("|cffffd100%s|r: %s", L["OPT_RT_RAID_NAME"] or "Raid", tostring((current and current.instanceName) or instanceName or (L["OPT_RT_RAID_DEFAULT"] or "Encounter")))
end,
},
encounterName = {
type = "input", order = 4, width = "full", name = L["OPT_RT_ENCOUNTER_NAME"] or "Name",
disabled = function() return not RT:IsLocalEditor() end,
get = function() local current = RT:GetEncounter(encounterId); return current and tostring(current.name or "") or "" end,
set = function(_, val) local current = RT:GetEncounter(encounterId); if current then current.name = tostring(val or ""); Notify(true) end end,
},
difficultyHeader = { type = "header", order = 5, name = L["OPT_RT_DIFFICULTY_HEADER"] or "Difficulties" },
}
local diffOrder = 6
for _, difficultyKey in ipairs(DIFFICULTY_KEYS) do
args["difficulty_" .. difficultyKey] = {
type = "toggle", order = diffOrder, width = 0.75, name = GetDifficultyLabel(difficultyKey),
disabled = function() return not RT:IsLocalEditor() end,
get = function()
local current = RT:GetEncounter(encounterId)
local difficulties = current and current.difficulties or nil
return difficulties and difficulties[difficultyKey] ~= false or false
end,
set = function(_, val)
local current = RT:GetEncounter(encounterId)
if current then
current.difficulties = current.difficulties or {}
current.difficulties[difficultyKey] = val and true or false
Notify(true)
end
end,
}
diffOrder = diffOrder + 0.01
end
args.openTimelineEditor = {
type = "execute", order = 7, width = "full", name = L["OPT_RT_OPEN_EDITOR"] or "Open timeline",
func = function() if RT.OpenTimelineEditor then RT:OpenTimelineEditor(encounterId) end end,
}
args.runTest = {
type = "execute",
order = 7.5,
width = "full",
name = function()
if RT:IsTestRunning(encounterId) then
return L["OPT_RT_STOP_TEST"] or "Stop test"
end
return L["OPT_RT_START_TEST"] or "Start timeline test"
end,
disabled = function() return not RT:IsLocalEditor() end,
func = function()
if RT:IsTestRunning(encounterId) then
RT:StopEncounterTest()
else
RT:StartEncounterTest(encounterId)
end
Notify()
end,
}
args.testHint = {
type = "description",
order = 7.6,
width = "full",
name = L["OPT_RT_TEST_HINT"] or "Runs the encounter timeline outside of combat so you can verify assignments, whispers and debug output.",
}
args.editorHint = {
type = "description", order = 8, width = "full",
name = L["OPT_RT_TIMELINE_HINT"] or "Click the bar to add a cooldown. Drag markers horizontally to change the time. Mousewheel scrolls, Ctrl+Mousewheel zooms.",
}
args.encounterDelete = {
type = "execute", order = 9, width = "full", name = DELETE or "Delete",
disabled = function() return not RT:IsLocalEditor() end,
confirm = function() return string.format(L["OPT_RT_DELETE_ENCOUNTER_CONFIRM"] or "Delete raid timeline for encounter %d?", tonumber(encounterId) or 0) end,
func = function() RT:RemoveEncounter(encounterId); Notify(true) end,
}
args.entryBreak = { type = "description", order = 10, width = "full", name = " " }
for key, value in pairs(BuildEntryEditorArgs(encounterId)) do
args[key] = value
end
return { type = "group", name = GetBossTreeLabel(encounterId), args = args }
end
local function BuildRaidTreeArgs()
local args = {}
local raidNames, buckets = GetRaidBuckets()
for raidIndex, raidName in ipairs(raidNames) do
local raidKey = tostring(buckets[raidName].key or raidIndex)
args["raid_" .. raidKey] = {
type = "group",
order = 100 + raidIndex,
name = raidName,
childGroups = "tree",
args = {
description = {
type = "description",
order = 1,
width = "full",
name = string.format("|cffffd100%s|r: %s", L["OPT_RT_RAID_NAME"] or "Raid", raidName),
},
raidId = {
type = "description",
order = 2,
width = "full",
name = string.format("|cffffd100%s|r: %s", L["OPT_RT_RAID_ID"] or "Raid ID", tostring(buckets[raidName].key or raidIndex)),
},
},
}
local addedEncounterIds = {}
local bossOrder = 10
for _, difficultyKey in ipairs(DIFFICULTY_KEYS) do
local encounterIds = buckets[raidName].difficulties[difficultyKey] or {}
for _, encounterId in ipairs(encounterIds) do
if not addedEncounterIds[encounterId] then
local encounterGroup = BuildEncounterDetailGroup(encounterId)
if encounterGroup then
encounterGroup.order = bossOrder
args["raid_" .. raidKey].args["boss_" .. tostring(encounterId)] = encounterGroup
bossOrder = bossOrder + 1
addedEncounterIds[encounterId] = true
end
end
end
end
end
return args, #raidNames
end
function RT:GetOptionsGroup()
local drafts = GetDrafts()
local raidTreeArgs, raidCount = BuildRaidTreeArgs()
local group = {
type = "group",
name = L["OPT_RT_NAME"] or "Raid Timeline",
order = 4,
childGroups = "tree",
args = {
general = {
type = "group",
order = 1,
name = L["OPT_RT_SECTION_GENERAL"] or "General",
args = {
enabled = {
type = "toggle",
order = 1,
width = "full",
name = L["OPT_RT_ENABLED"] or "Enable Raid Timeline",
get = function() return RT:GetSettings().enabled == true end,
set = function(_, val)
RT:GetSettings().enabled = val and true or false
if val then RT:Enable() else RT:Disable() end
end,
},
leadTime = {
type = "range",
order = 2,
min = 1,
max = 15,
step = 1,
name = L["OPT_RT_LEAD_TIME"] or "Warning lead time",
get = function() return tonumber(RT:GetSettings().leadTime) or 5 end,
set = function(_, val) RT:GetSettings().leadTime = math.floor((tonumber(val) or 5) + 0.5) end,
},
assignmentLeadTime = {
type = "range",
order = 2.1,
min = 0,
max = 60,
step = 1,
name = L["OPT_RT_ASSIGNMENT_LEAD_TIME"] or "Assignment lead time",
desc = L["OPT_RT_ASSIGNMENT_LEAD_TIME_DESC"] or "How many seconds before the planned use the assigned player should be selected.",
get = function()
local settings = RT:GetSettings()
return tonumber(settings.assignmentLeadTime) or tonumber(settings.leadTime) or 5
end,
set = function(_, val)
RT:GetSettings().assignmentLeadTime = math.floor((tonumber(val) or 5) + 0.5)
end,
},
header = { type = "header", order = 3, name = L["OPT_RT_ALERT_HEADER"] or "Alert frame" },
unlocked = {
type = "toggle", order = 4, width = "double", name = L["OPT_RT_UNLOCK"] or "Unlock alert frame",
get = function() return RT:GetSettings().unlocked == true end,
set = function(_, val)
RT:GetSettings().unlocked = val and true or false
RT:ApplyAlertStyle()
if val then RT:ShowPreview() end
end,
},
preview = {
type = "toggle", order = 5, width = "double", name = L["OPT_RT_PREVIEW"] or "Preview alert",
get = function() return RT.previewAlertActive == true end,
set = function(_, val) if val then RT:ShowPreview() else RT:HideAlert() end end,
},
alertFont = {
type = "select", order = 6, width = 1.4, name = L["OPT_FONT"] or "Typeface",
dialogControl = "LSM30_Font",
values = AceGUIWidgetLSMlists and AceGUIWidgetLSMlists.font or (LSM and LSM:HashTable("font")) or {},
get = function() return RT:GetSettings().alertFont or "Friz Quadrata TT" end,
set = function(_, val) RT:GetSettings().alertFont = val; RT:ApplyAlertStyle() end,
},
alertFontSize = {
type = "range", order = 7, min = 10, max = 72, step = 1, width = 1.0, name = L["OPT_FONT_SIZE"] or "Font size",
get = function() return tonumber(RT:GetSettings().alertFontSize) or 30 end,
set = function(_, val) RT:GetSettings().alertFontSize = math.floor((tonumber(val) or 30) + 0.5); RT:ApplyAlertStyle() end,
},
alertFontOutline = {
type = "select", order = 8, width = 1.0, name = L["OPT_FONT_OUTLINE"] or "Font outline",
values = FONT_OUTLINE_VALUES,
get = function() return RT:GetSettings().alertFontOutline or "OUTLINE" end,
set = function(_, val) RT:GetSettings().alertFontOutline = val; RT:ApplyAlertStyle() end,
},
alertColor = {
type = "color", order = 9, width = 0.8, name = L["OPT_RT_ALERT_COLOR"] or "Text colour", hasAlpha = true,
get = function()
local color = RT:GetSettings().alertColor or {}
return color.r or 1, color.g or 0.82, color.b or 0.15, color.a or 1
end,
set = function(_, r, g, b, a)
local color = RT:GetSettings().alertColor
color.r, color.g, color.b, color.a = r, g, b, a
RT:ApplyAlertStyle()
end,
},
desc = {
type = "description", order = 10, width = "full",
name = L["OPT_RT_DESC"] or "Create encounter timelines here and open the interactive Ace3 timeline editor for visual planning.",
},
},
},
manage = {
type = "group",
order = 2,
name = L["OPT_RT_SECTION_MANAGE"] or "Manage encounters",
args = {
header = { type = "header", order = 1, name = L["OPT_RT_ENCOUNTERS_HEADER"] or "Encounter timelines" },
addEncounterId = {
type = "input", order = 2, width = 0.8, name = L["OPT_RT_ADD_ENCOUNTER_ID"] or "Encounter ID",
disabled = function() return not RT:IsLocalEditor() end,
get = function() return tostring(drafts.addEncounterId or "") end,
set = function(_, val) drafts.addEncounterId = val end,
},
addEncounterName = {
type = "input", order = 3, width = 1.2, name = L["OPT_RT_ADD_ENCOUNTER_NAME"] or "Encounter name",
disabled = function() return not RT:IsLocalEditor() end,
get = function() return tostring(drafts.addEncounterName or "") end,
set = function(_, val) drafts.addEncounterName = val end,
},
addEncounter = {
type = "execute", order = 5, width = "full", name = L["OPT_RT_ADD_ENCOUNTER"] or "Add encounter",
disabled = function() return not RT:IsLocalEditor() end,
func = function()
if RT:AddEncounter(drafts.addEncounterId, drafts.addEncounterName) then
drafts.addEncounterId, drafts.addEncounterName = "", ""
Notify(true)
else
HMGT:Print(L["OPT_RT_INVALID_ENCOUNTER"] or "HMGT: invalid encounter ID")
end
end,
},
summary = {
type = "description", order = 6, width = "full",
name = function()
if raidCount == 0 then return L["OPT_RT_EMPTY"] or "No encounter timelines configured yet." end
return string.format("|cffffd100%s|r: %d", L["OPT_RT_ENCOUNTERS_HEADER"] or "Encounter timelines", #RT:GetEncounterIds())
end,
},
},
},
},
}
if raidCount == 0 then
group.args.empty = {
type = "group",
order = 50,
name = L["OPT_RT_EMPTY"] or "No encounter timelines configured yet.",
args = {
description = { type = "description", order = 1, width = "full", name = L["OPT_RT_EMPTY"] or "No encounter timelines configured yet." },
},
}
else
for key, value in pairs(raidTreeArgs) do
group.args[key] = value
end
end
return group
end
HMGT_Config:RegisterOptionsProvider("raidTimeline", function()
raidTimelineOptionsGroup = raidTimelineOptionsGroup or RT:GetOptionsGroup()
ClearOptionCaches()
local fresh = RT:GetOptionsGroup()
raidTimelineOptionsGroup.name = fresh.name
raidTimelineOptionsGroup.order = fresh.order
raidTimelineOptionsGroup.childGroups = fresh.childGroups
raidTimelineOptionsGroup.args = fresh.args
return {
path = "raidTimeline",
order = 4,
group = raidTimelineOptionsGroup,
}
end)

1231
Modules/Tracker/Frame.lua Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,391 @@
-- Core/GroupCooldownSpellDatabase.lua
-- Group cooldown database.
HMGT_SpellData = HMGT_SpellData or {}
local Spell = HMGT_SpellData.Spell
local Relation = HMGT_SpellData.Relation
if not Spell then return end
HMGT_SpellData.GroupCooldowns = {
-- WARRIOR
-- Arms Spec
Spell(118038, "Die by the Sword", {
classes = {"WARRIOR"},
specs = {1},
category = "defensive",
state = { kind = "cooldown", cooldown = 120 },
}),
Spell(167105, "Colossus Smash", {
classes = {"WARRIOR"},
specs = {1},
category = "offensive",
state = { kind = "cooldown", cooldown = 45 },
}),
-- Fury Spec
Spell(184364, "Enraged Regeneration", {
classes = {"WARRIOR"},
specs = {2},
category = "defensive",
state = { kind = "cooldown", cooldown = 120 },
}),
Spell(1719, "Recklessness", {
classes = {"WARRIOR"},
specs = {2},
category = "offensive",
state = { kind = "cooldown", cooldown = 90 },
}),
-- Protection Spec
Spell(871, "Shield Wall", {
classes = {"WARRIOR"},
specs = {3},
category = "tank",
state = { kind = "cooldown", cooldown = 108, charges = 1 },
mods = {
{ talentSpellId = 391271, value = 10, op = "reduceByPercent", target = "cooldown" }, -- Honed Reflexes
{ talentSpellId = 397103, value = 2, op = "set", target = "charges" },
},
}),
Spell(385952, "Shield Charge", {
classes = {"WARRIOR"},
specs = {3},
category = "cc",
state = { kind = "cooldown", cooldown = 45 },
}),
Spell(46968, "Shockwave", {
classes = {"WARRIOR"},
specs = {3},
category = "cc",
state = { kind = "cooldown", cooldown = 40 },
}),
Spell(3411, "Intervene", {
classes = {"WARRIOR"},
specs = {3},
category = "defensive",
state = { kind = "cooldown", cooldown = 27 },
}),
-- All Specs
Spell(107574, "Avatar", {
classes = {"WARRIOR"},
category = "offensive",
state = { kind = "cooldown", cooldown = 90 },
}),
Spell(107570, "Storm Bolt", {
classes = {"WARRIOR"},
category = "cc",
state = { kind = "cooldown", cooldown = 36 },
}),
-- PALADIN
Spell(498, "Divine Protection", {
classes = {"PALADIN"},
specs = {1, 2},
category = "defensive",
state = { kind = "cooldown", cooldown = 60 },
}),
Spell(642, "Divine Shield", {
classes = {"PALADIN"},
category = "defensive",
state = { kind = "cooldown", cooldown = 300 },
}),
Spell(31884, "Avenging Wrath", {
classes = {"PALADIN"},
specs = {1, 3},
category = "offensive",
state = { kind = "cooldown", cooldown = 120 },
}),
Spell(86659, "Guardian of Ancient Kings", {
classes = {"PALADIN"},
specs = {2},
category = "tank",
state = { kind = "cooldown", cooldown = 300 },
}),
-- HUNTER
Spell(264735, "Survival of the Fittest", {
classes = {"HUNTER"},
specs = {1},
category = "defensive",
state = { kind = "cooldown", cooldown = 180 },
}),
Spell(109304, "Exhilaration", {
classes = {"HUNTER"},
category = "defensive",
state = { kind = "cooldown", cooldown = 120 },
}),
-- ROGUE
Spell(31224, "Cloak of Shadows", {
classes = {"ROGUE"},
category = "defensive",
state = { kind = "cooldown", cooldown = 60 },
}),
Spell(5277, "Evasion", {
classes = {"ROGUE"},
category = "defensive",
state = { kind = "cooldown", cooldown = 120 },
}),
Spell(2094, "Blind", {
classes = {"ROGUE"},
category = "cc",
state = { kind = "cooldown", cooldown = 120 },
}),
Spell(1856, "Vanish", {
classes = {"ROGUE"},
category = "defensive",
state = { kind = "cooldown", cooldown = 120 },
}),
-- DEATH KNIGHT
Spell(55233, "Vampiric Blood", {
classes = {"DEATHKNIGHT"},
specs = {1},
category = "tank",
state = { kind = "cooldown", cooldown = 180 },
mods = {
{ talentSpellId = 374200, value = 150, op = "set", target = "cooldown" },
},
}),
Spell(49028, "Dancing Rune Weapon", {
classes = {"DEATHKNIGHT"},
specs = {1},
category = "tank",
state = { kind = "cooldown", cooldown = 120 },
mods = {
{ talentSpellId = 377584, value = 60, op = "set", target = "cooldown" },
},
}),
Spell(48707, "Anti-Magic Shell", {
classes = {"DEATHKNIGHT"},
category = "defensive",
state = { kind = "cooldown", cooldown = 60 },
}),
Spell(48792, "Icebound Fortitude", {
classes = {"DEATHKNIGHT"},
category = "defensive",
state = { kind = "cooldown", cooldown = 180 },
}),
Spell(42650, "Army of the Dead", {
classes = {"DEATHKNIGHT"},
category = "offensive",
state = { kind = "cooldown", cooldown = 480 },
}),
Spell(49206, "Summon Gargoyle", {
classes = {"DEATHKNIGHT"},
specs = {3},
category = "offensive",
state = { kind = "cooldown", cooldown = 180 },
}),
-- SHAMAN
Spell(204336, "Grounding Totem", {
classes = {"SHAMAN"},
category = "defensive",
state = { kind = "cooldown", cooldown = 30 },
}),
Spell(51490, "Thunderstorm", {
classes = {"SHAMAN"},
specs = {1},
category = "cc",
state = { kind = "cooldown", cooldown = 45 },
}),
-- MAGE
Spell(45438, "Ice Block", {
classes = {"MAGE"},
category = "defensive",
state = { kind = "cooldown", cooldown = 240 },
}),
Spell(110959, "Greater Invisibility", {
classes = {"MAGE"},
category = "defensive",
state = { kind = "cooldown", cooldown = 120 },
}),
Spell(235450, "Prismatic Barrier", {
classes = {"MAGE"},
specs = {3},
category = "defensive",
state = { kind = "cooldown", cooldown = 25 },
}),
-- WARLOCK
Spell(104773, "Unending Resolve", {
classes = {"WARLOCK"},
category = "defensive",
state = { kind = "cooldown", cooldown = 180 },
}),
Spell(6229, "Twilight Ward", {
classes = {"WARLOCK"},
category = "defensive",
state = { kind = "cooldown", cooldown = 0 },
}),
Spell(212295, "Nether Ward", {
classes = {"WARLOCK"},
specs = {3},
category = "defensive",
state = { kind = "cooldown", cooldown = 45 },
}),
-- MONK
Spell(122783, "Diffuse Magic", {
classes = {"MONK"},
category = "defensive",
state = { kind = "cooldown", cooldown = 90 },
}),
Spell(122278, "Dampen Harm", {
classes = {"MONK"},
category = "defensive",
state = { kind = "cooldown", cooldown = 120 },
}),
Spell(120954, "Fortifying Brew", {
classes = {"MONK"},
specs = {1},
category = "tank",
state = { kind = "cooldown", cooldown = 420 },
}),
Spell(115176, "Zen Meditation", {
classes = {"MONK"},
specs = {2},
category = "defensive",
state = { kind = "cooldown", cooldown = 300 },
}),
Spell(322118, "Invoke Niuzao", {
classes = {"MONK"},
specs = {1},
category = "tank",
state = { kind = "cooldown", cooldown = 180 },
}),
-- DRUID
Spell(22812, "Barkskin", {
classes = {"DRUID"},
category = "defensive",
state = { kind = "cooldown", cooldown = 60 },
}),
Spell(61336, "Survival Instincts", {
classes = {"DRUID"},
specs = {2, 3},
category = "defensive",
state = { kind = "cooldown", cooldown = 180 },
}),
-- DEMON HUNTER
Spell(187827, "Metamorphosis", {
classes = {"DEMONHUNTER"},
specs = {1},
category = "offensive",
state = { kind = "cooldown", cooldown = 180 },
}),
Spell(162264, "Metamorphosis", {
classes = {"DEMONHUNTER"},
specs = {2},
category = "tank",
state = { kind = "cooldown", cooldown = 180 },
}),
Spell(1217605, "Metamorphosis", {
classes = {"DEMONHUNTER"},
specs = {3},
category = "offensive",
state = {
kind = "availability",
required = 50,
source = {
type = "auraStacks",
auraSpellId = 1225789,
fallbackSpellCountId = 1217605,
},
},
}),
Spell(203819, "Demon Spikes", {
classes = {"DEMONHUNTER"},
specs = {2},
category = "tank",
state = { kind = "cooldown", cooldown = 20 },
}),
Spell(212800, "Blur", {
classes = {"DEMONHUNTER"},
specs = {1},
category = "defensive",
state = { kind = "cooldown", cooldown = 60 },
}),
Spell(196555, "Netherwalk", {
classes = {"DEMONHUNTER"},
specs = {1},
category = "defensive",
state = { kind = "cooldown", cooldown = 180 },
}),
-- EVOKER
Spell(357214, "Time Stop", {
classes = {"EVOKER"},
specs = {2},
category = "cc",
state = { kind = "cooldown", cooldown = 120 },
}),
Spell(375087, "Tempest", {
classes = {"EVOKER"},
specs = {3},
category = "offensive",
state = { kind = "cooldown", cooldown = 30 },
}),
}
HMGT_SpellData.Relations = HMGT_SpellData.Relations or {}
local hasShieldSlamShieldWallRelation = false
local hasAngerManagementRelation = false
for _, relation in ipairs(HMGT_SpellData.Relations) do
local firstEffect = relation.effects and relation.effects[1]
if tonumber(relation.triggerSpellId) == 23922 and tonumber(firstEffect and firstEffect.targetSpellId) == 871 then
hasShieldSlamShieldWallRelation = true
end
if tostring(relation.when) == "powerSpent"
and tonumber(relation.talentRequired) == 152278
and tostring(relation.powerType) == "RAGE"
then
hasAngerManagementRelation = true
end
end
if not hasShieldSlamShieldWallRelation and Relation then
HMGT_SpellData.Relations[#HMGT_SpellData.Relations + 1] = Relation({
triggerSpellId = 23922, -- Shield Slam
classes = {"WARRIOR"},
specs = {3}, -- Protection
when = "cast",
talentRequired = 384072, -- Impenetrable Wall
effects = {
{
type = "reduceCooldown",
targetSpellId = 871, -- Shield Wall
amount = 6,
},
},
})
end
if not hasAngerManagementRelation and Relation then
HMGT_SpellData.Relations[#HMGT_SpellData.Relations + 1] = Relation({
classes = {"WARRIOR"},
specs = {3}, -- Protection
when = "powerSpent",
powerType = "RAGE",
amountPerTrigger = 20,
talentRequired = 152278, -- Anger Management
effects = {
{
type = "reduceCooldown",
targetSpellId = 107574, -- Avatar
amount = 1,
},
{
type = "reduceCooldown",
targetSpellId = 871, -- Shield Wall
amount = 1,
},
},
})
end
if HMGT_SpellData.RebuildLookups then
HMGT_SpellData.RebuildLookups()
end

View File

@@ -0,0 +1,692 @@
-- 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

View File

@@ -0,0 +1,247 @@
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT or not HMGT.TrackerManager then return end
local Manager = HMGT.TrackerManager
local S = Manager._shared or {}
function Manager:EnsurePlayerFrame(tracker, playerName)
local frameKey = S.GetTrackerFrameKey(tracker.id)
self.perPlayerFrames[frameKey] = self.perPlayerFrames[frameKey] or {}
local frame = self.perPlayerFrames[frameKey][playerName]
if not frame then
frame = HMGT.TrackerFrame:CreateTrackerFrame(S.GetTrackerPlayerFrameName(tracker.id, playerName), tracker)
frame._hmgtTrackerId = tonumber(tracker.id) or 0
frame._hmgtPlayerName = playerName
self.perPlayerFrames[frameKey][playerName] = frame
end
frame._settings = tracker
HMGT.TrackerFrame:SetTitle(frame, string.format("%s - %s", S.GetTrackerLabel(tracker), S.ShortName(playerName)))
HMGT.TrackerFrame:SetLocked(frame, tracker.locked)
return frame
end
function Manager:HidePlayerFrames(frameKey)
local frames = self.perPlayerFrames[frameKey]
if type(frames) ~= "table" then
self.activeOrders[frameKey] = nil
self.unitByPlayer[frameKey] = nil
self.anchorLayoutSignatures[frameKey] = nil
self.nextAnchorRetryAt[frameKey] = nil
self._displaySignatures[frameKey] = "0"
return
end
for _, frame in pairs(frames) do
frame:Hide()
end
self.activeOrders[frameKey] = nil
self.unitByPlayer[frameKey] = nil
self.anchorLayoutSignatures[frameKey] = nil
self.nextAnchorRetryAt[frameKey] = nil
self._displaySignatures[frameKey] = "0"
end
function Manager:BuildEntriesByPlayerForTracker(tracker)
local frameKey = S.GetTrackerFrameKey(tracker.id)
local ownName = HMGT:NormalizePlayerName(UnitName("player")) or "Player"
if tracker.testMode then
local entries = self:CollectTestEntries(tracker)
if S.IsGroupTracker(tracker) and tracker.attachToPartyFrame == true then
return S.BuildPartyPreviewEntries(entries)
end
local byPlayer, order, unitByPlayer = {}, {}, {}
if #entries > 0 then
byPlayer[ownName] = entries
order[1] = ownName
unitByPlayer[ownName] = "player"
end
return byPlayer, order, unitByPlayer, true
end
if tracker.demoMode then
local entries = HMGT:GetDemoEntries(frameKey, S.GetTrackerSpellPool(tracker.categories), tracker)
if S.IsGroupTracker(tracker) and tracker.attachToPartyFrame == true then
return S.BuildPartyPreviewEntries(entries)
end
for _, entry in ipairs(entries) do
entry.playerName = ownName
end
local byPlayer, order, unitByPlayer = {}, {}, {}
if #entries > 0 then
byPlayer[ownName] = entries
order[1] = ownName
unitByPlayer[ownName] = "player"
end
return byPlayer, order, unitByPlayer, true
end
if not tracker.enabled or not HMGT:IsVisibleForCurrentGroup(tracker) then
return {}, {}, {}, false
end
if IsInRaid() or not IsInGroup() then
return {}, {}, {}, false
end
local byPlayer, order, unitByPlayer = {}, {}, {}
for _, playerInfo in ipairs(S.GetGroupPlayers(tracker)) do
local entries = S.CollectEntriesForPlayer(tracker, playerInfo)
if #entries > 0 then
local playerName = playerInfo.name
byPlayer[playerName] = entries
order[#order + 1] = playerName
unitByPlayer[playerName] = playerInfo.unitId
end
end
return byPlayer, order, unitByPlayer, true
end
function Manager:RefreshPerGroupAnchors(tracker, force)
local frameKey = S.GetTrackerFrameKey(tracker.id)
local frames = self.perPlayerFrames[frameKey] or {}
local ordered = {}
for _, playerName in ipairs(self.activeOrders[frameKey] or {}) do
local frame = frames[playerName]
if frame and frame:IsShown() then
ordered[#ordered + 1] = playerName
end
end
if #ordered == 0 then
self.anchorLayoutSignatures[frameKey] = nil
self.nextAnchorRetryAt[frameKey] = nil
return
end
local now = GetTime()
local signature = S.BuildAnchorLayoutSignature(tracker, ordered, self.unitByPlayer[frameKey])
if not force and self.anchorLayoutSignatures[frameKey] == signature then
local retryAt = tonumber(self.nextAnchorRetryAt[frameKey]) or 0
if retryAt <= 0 or now < retryAt then
return
end
end
for _, playerName in ipairs(ordered) do
local frame = frames[playerName]
if frame and frame._hmgtDragging then
return
end
end
if tracker.attachToPartyFrame == true then
local side = tracker.partyAttachSide or "RIGHT"
local extraX = tonumber(tracker.partyAttachOffsetX) or 8
local extraY = tonumber(tracker.partyAttachOffsetY) or 0
local growsUp = tracker.showBar == true and tracker.growDirection == "UP"
local barHeight = tonumber(tracker.barHeight) or 20
local growUpAttachOffset = barHeight + 20
local prevPlaced = nil
local missingTargets = 0
for _, playerName in ipairs(ordered) do
local frame = frames[playerName]
local unitId = self.unitByPlayer[frameKey] and self.unitByPlayer[frameKey][playerName]
local target = S.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(tracker), "TrackerAttach fallback-stack tracker=%s player=%s unit=%s", tostring(tracker.id), tostring(playerName), tostring(unitId))
if growsUp then
frame:SetPoint("BOTTOMLEFT", prevPlaced, "TOPLEFT", 0, (tracker.barSpacing or 2) + 10)
else
frame:SetPoint("TOPLEFT", prevPlaced, "BOTTOMLEFT", 0, -((tracker.barSpacing or 2) + 10))
end
else
missingTargets = missingTargets + 1
HMGT:DebugScoped("info", HMGT:GetTrackerDebugScope(tracker), "TrackerAttach fallback-anchor tracker=%s player=%s unit=%s", tostring(tracker.id), tostring(playerName), tostring(unitId))
HMGT.TrackerFrame:ApplyAnchor(frame)
end
frame:EnableMouse(false)
prevPlaced = frame
end
if missingTargets > 0 then
self.anchorLayoutSignatures[frameKey] = nil
self.nextAnchorRetryAt[frameKey] = now + 1.0
else
self.anchorLayoutSignatures[frameKey] = signature
self.nextAnchorRetryAt[frameKey] = nil
end
return
end
local primary = frames[ordered[1]]
HMGT.TrackerFrame:ApplyAnchor(primary)
primary:EnableMouse(not tracker.locked)
local gap = (tracker.barSpacing or 2) + 10
local growsUp = tracker.showBar == true and tracker.growDirection == "UP"
for index = 2, #ordered do
local prev = frames[ordered[index - 1]]
local frame = frames[ordered[index]]
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.anchorLayoutSignatures[frameKey] = signature
self.nextAnchorRetryAt[frameKey] = nil
end
function Manager:UpdatePerGroupMemberTracker(tracker)
local frameKey = S.GetTrackerFrameKey(tracker.id)
local byPlayer, order, unitByPlayer, shouldShow = self:BuildEntriesByPlayerForTracker(tracker)
self.activeOrders[frameKey] = order
self.unitByPlayer[frameKey] = unitByPlayer
local active, shownOrder = {}, {}
local shownByPlayer = {}
local entryCount, shouldTick = 0, false
for _, playerName in ipairs(order) do
local frame = self:EnsurePlayerFrame(tracker, playerName)
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
HMGT.TrackerFrame:UpdateFrame(frame, entries, true)
frame:Show()
active[playerName] = true
shownOrder[#shownOrder + 1] = playerName
shownByPlayer[playerName] = entries
entryCount = entryCount + #entries
for _, entry in ipairs(entries) do
if S.EntryNeedsVisualTicker(entry) then
shouldTick = true
break
end
end
else
frame:Hide()
end
end
for playerName, frame in pairs(self.perPlayerFrames[frameKey] or {}) do
if not active[playerName] then
frame:Hide()
end
end
self.activeOrders[frameKey] = shownOrder
self.unitByPlayer[frameKey] = unitByPlayer
self._displaySignatures[frameKey] = S.BuildGroupDisplaySignature and S.BuildGroupDisplaySignature(shownOrder, shownByPlayer) or nil
if shouldShow and #shownOrder > 0 then
self:RefreshPerGroupAnchors(tracker, false)
return true, entryCount, shouldTick
end
self:HidePlayerFrames(frameKey)
self._displaySignatures[frameKey] = "0"
return false, 0, false
end

View File

@@ -0,0 +1,154 @@
-- Core/InterruptSpellDatabase.lua
HMGT_SpellData = HMGT_SpellData or {}
local Spell = HMGT_SpellData.Spell
if not Spell then return end
HMGT_SpellData.Interrupts = {
-- WARRIOR
Spell(6552, "Pummel", {
classes = {"WARRIOR"},
category = "interrupt",
state = { kind = "cooldown", cooldown = 15 },
mods = {
{ talentSpellId = 391271, value = 10, op = "reduceByPercent", target = "cooldown" },
},
}),
Spell(23920, "Spell Reflection", {
classes = {"WARRIOR"},
category = "interrupt",
state = { kind = "cooldown", cooldown = 20 },
mods = {
{ talentSpellId = 391271, value = 10, op = "reduceByPercent", target = "cooldown" },
},
}),
-- PALADIN
Spell(96231, "Rebuke", {
classes = {"PALADIN"},
specs = {3},
category = "interrupt",
state = { kind = "cooldown", cooldown = 15 },
}),
-- HUNTER
Spell(147362, "Counter Shot", {
classes = {"HUNTER"},
specs = {1, 2},
category = "interrupt",
state = { kind = "cooldown", cooldown = 24 },
}),
Spell(187707, "Muzzle", {
classes = {"HUNTER"},
specs = {3},
category = "interrupt",
state = { kind = "cooldown", cooldown = 15 },
}),
-- ROGUE
Spell(1766, "Kick", {
classes = {"ROGUE"},
category = "interrupt",
state = { kind = "cooldown", cooldown = 15 },
}),
-- PRIEST
Spell(15487, "Silence", {
classes = {"PRIEST"},
specs = {3},
category = "interrupt",
state = { kind = "cooldown", cooldown = 45 },
}),
-- DEATH KNIGHT
Spell(47528, "Mind Freeze", {
classes = {"DEATHKNIGHT"},
category = "interrupt",
state = { kind = "cooldown", cooldown = 15 },
relations = {
{
when = "interruptSuccess",
effects = {
{
type = "reduceCooldown",
targetSpellId = 47528,
amount = 3,
talentSpellId = 378848,
},
},
},
},
}),
-- SHAMAN
Spell(57994, "Wind Shear", {
classes = {"SHAMAN"},
category = "interrupt",
state = { kind = "cooldown", cooldown = 12 },
}),
-- MAGE
Spell(2139, "Counterspell", {
classes = {"MAGE"},
category = "interrupt",
state = { kind = "cooldown", cooldown = 24 },
}),
-- WARLOCK
Spell(19647, "Spell Lock", {
classes = {"WARLOCK"},
specs = {2},
category = "interrupt",
state = { kind = "cooldown", cooldown = 24 },
}),
Spell(132409, "Spell Lock (Grimoire)", {
classes = {"WARLOCK"},
specs = {1, 3},
category = "interrupt",
state = { kind = "cooldown", cooldown = 24 },
}),
-- MONK
Spell(116705, "Spear Hand Strike", {
classes = {"MONK"},
specs = {1, 3},
category = "interrupt",
state = { kind = "cooldown", cooldown = 15 },
}),
-- DEMON HUNTER
Spell(183752, "Disrupt", {
classes = {"DEMONHUNTER"},
specs = {nil},
category = "interrupt",
state = { kind = "cooldown", cooldown = 15 },
}),
-- DRUID
Spell(78675, "Solar Beam", {
classes = {"DRUID"},
specs = {1},
category = "interrupt",
state = { kind = "cooldown", cooldown = 60 },
}),
Spell(106839, "Skull Bash", {
classes = {"DRUID"},
specs = {2, 3},
category = "interrupt",
state = { kind = "cooldown", cooldown = 15 },
}),
-- EVOKER
Spell(351338, "Quell", {
classes = {"EVOKER"},
category = "interrupt",
state = { kind = "cooldown", cooldown = 40 },
mods = {
{ talentSpellId = 396371, value = 20, op = "set", target = "cooldown" },
},
}),
}
if HMGT_SpellData.RebuildLookups then
HMGT_SpellData.RebuildLookups()
end

View File

@@ -0,0 +1,21 @@
-- Modules/InterruptTracker.lua
-- Interrupt tracker based on the shared single-frame tracker base.
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME)
local Base = HMGT.SingleFrameTrackerBase
if not Base then return end
Base:CreateModule("InterruptTracker", {
profileKey = "interruptTracker",
frameName = "InterruptTracker",
title = function()
return L["IT_TITLE"]
end,
demoKey = "interruptTracker",
database = function()
return HMGT_SpellData.Interrupts
end,
})

View File

@@ -0,0 +1,68 @@
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT or not HMGT.TrackerManager then return end
local Manager = HMGT.TrackerManager
local S = Manager._shared or {}
function Manager:CollectEntries(tracker)
local entries = {}
local players = S.GetGroupPlayers(tracker)
for _, playerInfo in ipairs(players) do
local playerEntries = S.CollectEntriesForPlayer(tracker, playerInfo)
for _, entry in ipairs(playerEntries) do
entries[#entries + 1] = entry
end
end
return entries
end
function Manager:CollectTestEntries(tracker)
local playerName = HMGT:NormalizePlayerName(UnitName("player")) or "Player"
local classToken = select(2, UnitClass("player"))
if not classToken then
return {}
end
local entries = {}
local pData = HMGT.playerData[playerName]
local talents = pData and pData.talents or {}
local spells = S.GetTrackerSpellsForPlayer(classToken, GetSpecialization() or 0, 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 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
local hasAvailabilityState = isAvailabilitySpell and HMGT:HasAvailabilityState(playerName, spellEntry.spellId)
if spellKnown or hasActiveCd or hasAvailabilityState 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
function Manager:BuildEntriesForTracker(tracker)
if tracker.testMode then
return self:CollectTestEntries(tracker), true
end
if tracker.demoMode then
return HMGT:GetDemoEntries(S.GetTrackerFrameKey(tracker.id), S.GetTrackerSpellPool(tracker.categories), tracker), true
end
if not tracker.enabled or not HMGT:IsVisibleForCurrentGroup(tracker) then
return {}, false
end
return self:CollectEntries(tracker), true
end

View File

@@ -0,0 +1,128 @@
-- Core/RaidCooldownSpellDatabase.lua
-- Raid cooldown database.
HMGT_SpellData = HMGT_SpellData or {}
local Spell = HMGT_SpellData.Spell
if not Spell then return end
HMGT_SpellData.RaidCooldowns = {
-- WARRIOR
Spell(97462, "Rallying Cry", {
classes = {"WARRIOR"},
category = "defensive",
state = { kind = "cooldown", cooldown = 180 },
}),
-- PALADIN
Spell(31821, "Aura Mastery", {
classes = {"PALADIN"},
specs = {1},
category = "defensive",
state = { kind = "cooldown", cooldown = 180 },
}),
-- PRIEST
Spell(62618, "Power Word: Barrier", {
classes = {"PRIEST"},
specs = {1},
category = "defensive",
state = { kind = "cooldown", cooldown = 180 },
}),
Spell(64843, "Divine Hymn", {
classes = {"PRIEST"},
specs = {1, 2},
category = "healing",
state = { kind = "cooldown", cooldown = 180 },
}),
Spell(47536, "Rapture", {
classes = {"PRIEST"},
specs = {1},
category = "healing",
state = { kind = "cooldown", cooldown = 90 },
}),
-- DEATH KNIGHT
Spell(51052, "Anti-Magic Zone", {
classes = {"DEATHKNIGHT"},
specs = {1, 2, 3},
category = "defensive",
state = { kind = "cooldown", cooldown = 120 },
mods = {
{ talentSpellId = 145629, value = 90, op = "set", target = "cooldown" },
},
}),
-- SHAMAN
Spell(98008, "Spirit Link Totem", {
classes = {"SHAMAN"},
specs = {3},
category = "defensive",
state = { kind = "cooldown", cooldown = 180 },
}),
Spell(108280, "Healing Tide Totem", {
classes = {"SHAMAN"},
specs = {3},
category = "healing",
state = { kind = "cooldown", cooldown = 180 },
}),
Spell(192077, "Wind Rush Totem", {
classes = {"SHAMAN"},
category = "utility",
state = { kind = "cooldown", cooldown = 120 },
}),
-- MONK
Spell(115310, "Revival", {
classes = {"MONK"},
specs = {2},
category = "healing",
state = { kind = "cooldown", cooldown = 180 },
}),
-- DRUID
Spell(740, "Tranquility", {
classes = {"DRUID"},
specs = {4},
category = "healing",
state = { kind = "cooldown", cooldown = 180 },
}),
Spell(106898, "Stampeding Roar", {
classes = {"DRUID"},
category = "utility",
state = { kind = "cooldown", cooldown = 120 },
}),
Spell(33891, "Incarnation: Tree of Life", {
classes = {"DRUID"},
specs = {4},
category = "healing",
state = { kind = "cooldown", cooldown = 180 },
}),
-- DEMON HUNTER
Spell(196718, "Darkness", {
classes = {"DEMONHUNTER"},
specs = {1},
category = "defensive",
state = { kind = "cooldown", cooldown = 180 },
}),
-- EVOKER
Spell(363534, "Rewind", {
classes = {"EVOKER"},
specs = {2},
category = "healing",
state = { kind = "cooldown", cooldown = 240 },
mods = {
{ talentSpellId = 373862, value = 120, op = "set", target = "cooldown" },
},
}),
Spell(406732, "Dream Projection", {
classes = {"EVOKER"},
specs = {2},
category = "healing",
state = { kind = "cooldown", cooldown = 45 },
}),
}
if HMGT_SpellData.RebuildLookups then
HMGT_SpellData.RebuildLookups()
end

View File

@@ -0,0 +1,21 @@
-- Modules/RaidCooldownTracker.lua
-- Raid cooldown tracker based on the shared single-frame tracker base.
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME)
local Base = HMGT.SingleFrameTrackerBase
if not Base then return end
Base:CreateModule("RaidCooldownTracker", {
profileKey = "raidCooldownTracker",
frameName = "RaidCooldownTracker",
title = function()
return L["RCD_TITLE"]
end,
demoKey = "raidCooldownTracker",
database = function()
return HMGT_SpellData.RaidCooldowns
end,
})

View File

@@ -0,0 +1,305 @@
-- Modules/Tracker/SingleFrameTrackerBase.lua
-- Shared implementation for single-frame tracker modules.
local ADDON_NAME = "HailMaryGuildTools"
local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME)
if not HMGT then return end
HMGT.SingleFrameTrackerBase = HMGT.SingleFrameTrackerBase or {}
local Base = HMGT.SingleFrameTrackerBase
local function GetDefaultGroupPlayers()
local players = {}
local ownName = HMGT:NormalizePlayerName(UnitName("player"))
local ownClass = select(2, UnitClass("player"))
table.insert(players, { name = ownName, class = ownClass, isOwn = true, unitId = "player" })
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
table.insert(players, { 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
table.insert(players, { name = name, class = class, unitId = unitId })
end
end
end
return players
end
local function ResolveConfigValue(configValue, self)
if type(configValue) == "function" then
return configValue(self)
end
return configValue
end
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
function Base:Create(config)
local tracker = {
frame = nil,
updateTicker = nil,
lastEntryCount = 0,
}
function tracker:GetSettings()
return HMGT.db.profile[config.profileKey]
end
function tracker:GetDatabase()
return ResolveConfigValue(config.database, self) or {}
end
function tracker:GetTitle()
return ResolveConfigValue(config.title, self) or config.frameName
end
function tracker:GetDemoKey()
return ResolveConfigValue(config.demoKey, self) or config.profileKey
end
function tracker:GetCooldownInfoOpts()
return ResolveConfigValue(config.cooldownInfoOpts, self)
end
function tracker:GetGroupPlayers()
local custom = ResolveConfigValue(config.groupPlayersProvider, self)
if type(custom) == "table" then
return custom
end
return GetDefaultGroupPlayers()
end
function tracker:EnsureUpdateTicker()
if self.updateTicker then
return
end
self.updateTicker = C_Timer.NewTicker(0.1, function()
self:UpdateDisplay()
end)
end
function tracker:StopUpdateTicker()
if self.updateTicker then
self.updateTicker:Cancel()
self.updateTicker = nil
end
end
function tracker:SetUpdateTickerEnabled(enabled)
if enabled then
self:EnsureUpdateTicker()
else
self:StopUpdateTicker()
end
end
function tracker:Enable()
local s = self:GetSettings()
if not s.enabled and not s.demoMode and not s.testMode then return end
if not self.frame then
self.frame = HMGT.TrackerFrame:CreateTrackerFrame(config.frameName, s)
HMGT.TrackerFrame:SetTitle(self.frame, self:GetTitle())
end
if HMGT:IsVisibleForCurrentGroup(s) then
self.frame:Show()
else
self.frame:Hide()
end
self:UpdateDisplay()
end
function tracker:Disable()
self:StopUpdateTicker()
if self.frame then
self.frame:Hide()
end
end
function tracker:UpdateDisplay()
if not self.frame then
self:StopUpdateTicker()
return
end
local s = self:GetSettings()
local database = self:GetDatabase()
local cooldownInfoOpts = self:GetCooldownInfoOpts()
if s.testMode then
HMGT.TrackerFrame:SetLocked(self.frame, s.locked)
local entries = HMGT:GetOwnTestEntries(database, s, cooldownInfoOpts)
self.lastEntryCount = #entries
HMGT.TrackerFrame:UpdateFrame(self.frame, entries)
self.frame:Show()
local shouldTick = false
for _, entry in ipairs(entries) do
if EntryNeedsVisualTicker(entry) then
shouldTick = true
break
end
end
self:SetUpdateTickerEnabled(shouldTick)
return
end
if s.demoMode then
HMGT.TrackerFrame:SetLocked(self.frame, s.locked)
local entries = HMGT:GetDemoEntries(self:GetDemoKey(), database, s)
self.lastEntryCount = #entries
HMGT.TrackerFrame:UpdateFrame(self.frame, entries)
self.frame:Show()
self:SetUpdateTickerEnabled(#entries > 0)
return
end
if not s.enabled then
self.lastEntryCount = 0
self.frame:Hide()
self:StopUpdateTicker()
return
end
if not HMGT:IsVisibleForCurrentGroup(s) then
self.lastEntryCount = 0
self.frame:Hide()
self:StopUpdateTicker()
return
end
HMGT.TrackerFrame:SetLocked(self.frame, s.locked)
local entries = self:CollectEntries()
self.lastEntryCount = #entries
if HMGT.SortDisplayEntries then
HMGT:SortDisplayEntries(entries, config.profileKey)
end
HMGT.TrackerFrame:UpdateFrame(self.frame, entries)
self.frame:Show()
local shouldTick = false
for _, entry in ipairs(entries) do
if EntryNeedsVisualTicker(entry) then
shouldTick = true
break
end
end
self:SetUpdateTickerEnabled(shouldTick)
end
function tracker:CollectEntries()
local entries = {}
local s = self:GetSettings()
local database = self:GetDatabase()
local cooldownInfoOpts = self:GetCooldownInfoOpts()
local players = self:GetGroupPlayers()
for _, playerInfo in ipairs(players) do
repeat
local name = playerInfo.name
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 knownSpells = HMGT_SpellData.GetSpellsForSpec(class, specIdx, database)
for _, spellEntry in ipairs(knownSpells) do
if s.enabledSpells[spellEntry.spellId] ~= false then
local remaining, total, curCharges, maxCharges = HMGT:GetCooldownInfo(name, spellEntry.spellId, cooldownInfoOpts)
local isAvailabilitySpell = HMGT.IsAvailabilitySpell and HMGT:IsAvailabilitySpell(spellEntry)
local effectiveCd = HMGT_SpellData.GetEffectiveCooldown(spellEntry, talents)
local include = HMGT:ShouldDisplayEntry(s, remaining, curCharges, maxCharges, spellEntry)
local spellKnown = HMGT:IsTrackedSpellKnownForPlayer(name, spellEntry.spellId)
local hasPartialCharges = (tonumber(maxCharges) or 0) > 0
and (tonumber(curCharges) 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 then
if isAvailabilitySpell and not HMGT:HasAvailabilityState(name, spellEntry.spellId) then
include = false
end
end
if include then
entries[#entries + 1] = {
playerName = name,
class = class,
spellEntry = spellEntry,
remaining = remaining,
total = total > 0 and total or effectiveCd,
currentCharges = curCharges,
maxCharges = maxCharges,
}
end
end
end
until true
end
return entries
end
return tracker
end
function Base:CreateModule(moduleName, config, ...)
if type(moduleName) ~= "string" or moduleName == "" then
return self:Create(config)
end
local module = HMGT:NewModule(moduleName, ...)
local tracker = self:Create(config)
for key, value in pairs(tracker) do
module[key] = value
end
HMGT[moduleName] = module
return module
end

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,803 @@
-- 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

File diff suppressed because it is too large Load Diff