Files
Torsten Brendgen fc5a8aa361 initial commit
2026-04-10 21:30:31 +02:00

2815 lines
101 KiB
Lua

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 LSM = LibStub("LibSharedMedia-3.0", true)
local AceGUI = LibStub("AceGUI-3.0", true)
local RT = HMGT:NewModule("RaidTimeline")
HMGT.RaidTimeline = RT
RT.runtimeEnabled = false
RT.eventFrame = nil
RT.ticker = nil
RT.alertFrame = nil
RT.activeEncounterId = nil
RT.activeEncounterStartTime = nil
RT.activeSchedule = nil
RT.firedEntries = nil
RT.currentAlert = nil
RT.receivedAssignments = nil
RT.timelineEditor = nil
RT.timelineEditorState = nil
RT.activeEncounterDifficulty = nil
RT.previewAlertActive = false
RT.activeBossAbilityDispatches = nil
RT.activeBossAbilityCounts = nil
RT.testActive = false
RT.testEncounterId = nil
RT.testDuration = nil
RT.dispatchedEntries = nil
local encounterInstanceInfoCache = {}
local encounterDirtyFlags = {}
local encounterNormalizedFlags = {}
local bossAbilityDefinitionsCache = {}
local ENCOUNTER_DIFFICULTY_DEFAULTS = {
lfr = true,
normal = true,
heroic = true,
mythic = true,
}
local function Clamp(value, minValue, maxValue, fallback)
local num = tonumber(value)
if not num then
num = tonumber(fallback) or minValue
end
if num < minValue then num = minValue end
if num > maxValue then num = maxValue end
return num
end
local function NormalizeName(name)
return HMGT.NormalizePlayerName and HMGT:NormalizePlayerName(name) or name
end
local function GetOwnName()
return NormalizeName(UnitName("player"))
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 StableHash(...)
local hash = 0
for argIndex = 1, select("#", ...) do
local value = tostring(select(argIndex, ...) or "")
for i = 1, #value do
hash = (hash * 33 + string.byte(value, i)) % 2147483647
end
end
return hash
end
local function GetTimelineMessagePrefix()
return HMGT.MSG_RAID_TIMELINE or "RTL"
end
local function GetTimelineTestMessagePrefix()
return HMGT.MSG_RAID_TIMELINE_TEST or "RTS"
end
local function NormalizeEncounterDifficulties(difficulties)
local source = type(difficulties) == "table" and difficulties or {}
return {
lfr = source.lfr ~= false,
normal = source.normal ~= false,
heroic = source.heroic ~= false,
mythic = source.mythic ~= false,
}
end
local function GetDifficultyKey(difficultyId)
local id = tonumber(difficultyId)
if id == 17 then
return "lfr"
elseif id == 14 then
return "normal"
elseif id == 15 then
return "heroic"
elseif id == 16 then
return "mythic"
end
return nil
end
local TEXT_REMINDER_ITEM_ID = 134332
local function GetTextReminderIcon()
if C_Item and type(C_Item.GetItemIconByID) == "function" then
local icon = C_Item.GetItemIconByID(TEXT_REMINDER_ITEM_ID)
if icon and icon ~= 0 and icon ~= "" then
return icon
end
end
if type(GetItemIcon) == "function" then
local icon = GetItemIcon(TEXT_REMINDER_ITEM_ID)
if icon and icon ~= 0 and icon ~= "" then
return icon
end
end
return 136243
end
local function GetPreferredTestDifficultyId(encounter)
local difficulties = encounter and encounter.difficulties or {}
if difficulties.normal ~= false then
return 14
elseif difficulties.heroic ~= false then
return 15
elseif difficulties.mythic ~= false then
return 16
elseif difficulties.lfr ~= false then
return 17
end
return 14
end
local function NormalizeEntryType(entryType, spellId)
local kind = tostring(entryType or (((tonumber(spellId) or 0) > 0) and "spell" or "text"))
if kind == "text" then
return "text"
elseif kind == "bossAbility" then
return "bossAbility"
end
return "spell"
end
local function NormalizeLegacyEntry(triggerType, actionType, entryType, spellId)
local legacyType = NormalizeEntryType(entryType, spellId)
local trigger = tostring(triggerType or "")
local action = tostring(actionType or "")
if trigger == "" or action == "" then
if legacyType == "spell" then
trigger = "time"
action = "raidCooldown"
elseif legacyType == "text" then
trigger = "time"
action = "text"
elseif legacyType == "bossAbility" then
trigger = "bossAbility"
action = ((tonumber(spellId) or 0) > 0) and "raidCooldown" or "text"
end
end
if trigger ~= "bossAbility" then
trigger = "time"
end
if action ~= "text" then
action = "raidCooldown"
end
return trigger, action
end
local function GetLegacyEntryTypeFromAxes(triggerType, actionType)
if tostring(triggerType or "") == "bossAbility" then
return "bossAbility"
end
if tostring(actionType or "") == "text" then
return "text"
end
return "spell"
end
local function TrimText(value)
return tostring(value or ""):gsub("^%s+", ""):gsub("%s+$", "")
end
local function NormalizeBossAbilityBarName(value)
local text = TrimText(value)
if text == "" then
return ""
end
text = text:gsub("%s*%(%d+%)%s*$", "")
text = text:gsub("%s*%([Cc]ount%)%s*$", "")
return TrimText(text)
end
local function NormalizeCastCountSelector(value)
local text = string.lower(TrimText(value))
if text == "" then
return "1"
end
if text == "all" or text == "odd" or text == "even" then
return text
end
local numeric = tonumber(text)
if numeric and numeric >= 1 then
return tostring(math.max(1, math.floor(numeric + 0.5)))
end
return "1"
end
local function FormatCastCountSelector(value)
local selector = NormalizeCastCountSelector(value)
if selector == "all" then
return L["OPT_RT_CAST_ALL"] or "All"
end
if selector == "odd" then
return L["OPT_RT_CAST_ODD"] or "Odd"
end
if selector == "even" then
return L["OPT_RT_CAST_EVEN"] or "Even"
end
return selector
end
local function DoesCastCountSelectorMatch(selector, currentCount)
local normalizedSelector = NormalizeCastCountSelector(selector)
local observedCount = math.max(1, tonumber(currentCount) or 1)
if normalizedSelector == "all" then
return true
end
if normalizedSelector == "odd" then
return (observedCount % 2) == 1
end
if normalizedSelector == "even" then
return (observedCount % 2) == 0
end
return observedCount == math.max(1, tonumber(normalizedSelector) or 1)
end
local function ParseTimelineClock(value)
local text = TrimText(value)
if text == "" then
return nil
end
local minutes, seconds = text:match("^(%d+)%s*:%s*(%d%d)$")
if minutes and seconds then
local mm = tonumber(minutes)
local ss = tonumber(seconds)
if mm and ss and ss >= 0 and ss <= 59 then
return (mm * 60) + ss
end
end
local numeric = tonumber(text)
if numeric and numeric >= 0 then
return numeric
end
return nil
end
local function NormalizeInstanceName(name)
local text = TrimText(name)
if text == "" then
text = L["OPT_RT_RAID_DEFAULT"] or "Encounter"
end
return text
end
local function EnsureEncounterJournalReady()
if type(EncounterJournal_LoadUI) == "function" then
pcall(EncounterJournal_LoadUI)
return true
end
if C_AddOns and type(C_AddOns.LoadAddOn) == "function" then
pcall(C_AddOns.LoadAddOn, "Blizzard_EncounterJournal")
return type(EJ_GetEncounterInfo) == "function"
end
if type(LoadAddOn) == "function" then
pcall(LoadAddOn, "Blizzard_EncounterJournal")
return type(EJ_GetEncounterInfo) == "function"
end
return false
end
function RT:NormalizeCastCountSelector(value)
return NormalizeCastCountSelector(value)
end
function RT:FormatCastCountSelector(value)
return FormatCastCountSelector(value)
end
function RT:DoesCastCountSelectorMatch(selector, currentCount)
return DoesCastCountSelectorMatch(selector, currentCount)
end
local function ResolveEncounterInstanceByJournalScan(encounterId)
EnsureEncounterJournalReady()
if type(EJ_GetNumTiers) ~= "function"
or type(EJ_SelectTier) ~= "function"
or type(EJ_GetInstanceByIndex) ~= "function"
or type(EJ_GetEncounterInfoByIndex) ~= "function"
then
return nil, nil
end
local targetEncounterId = tonumber(encounterId)
if not targetEncounterId or targetEncounterId <= 0 then
return nil, nil
end
local numTiers = tonumber(EJ_GetNumTiers()) or 0
for tierIndex = 1, numTiers do
pcall(EJ_SelectTier, tierIndex)
for instanceIndex = 1, 200 do
local instanceId, instanceName = EJ_GetInstanceByIndex(instanceIndex, true)
instanceId = tonumber(instanceId)
if not instanceId or instanceId <= 0 then
break
end
for encounterIndex = 1, 200 do
local _, _, journalEncounterId, _, _, _, dungeonEncounterId = EJ_GetEncounterInfoByIndex(encounterIndex, instanceId)
journalEncounterId = tonumber(journalEncounterId)
dungeonEncounterId = tonumber(dungeonEncounterId)
if (not journalEncounterId or journalEncounterId <= 0) and (not dungeonEncounterId or dungeonEncounterId <= 0) then
break
end
if journalEncounterId == targetEncounterId or dungeonEncounterId == targetEncounterId then
return instanceId, NormalizeInstanceName(instanceName)
end
end
end
end
return nil, nil
end
function RT:GetEncounterInstanceInfo(encounterId)
local encounter = tonumber(encounterId)
if not encounter or encounter <= 0 then
return nil, NormalizeInstanceName(nil)
end
local cached = encounterInstanceInfoCache[encounter]
if cached then
return cached.journalInstanceId, cached.instanceName
end
EnsureEncounterJournalReady()
local journalInstanceId
if type(EJ_GetEncounterInfo) == "function" then
local ok = pcall(function()
journalInstanceId = select(6, EJ_GetEncounterInfo(encounter))
end)
if not ok then
journalInstanceId = nil
end
end
journalInstanceId = tonumber(journalInstanceId) or 0
local instanceName
if journalInstanceId > 0 and type(EJ_GetInstanceInfo) == "function" then
local ok = pcall(function()
instanceName = EJ_GetInstanceInfo(journalInstanceId)
end)
if not ok then
instanceName = nil
end
end
if (journalInstanceId <= 0 or not instanceName or instanceName == "") then
local fallbackInstanceId, fallbackInstanceName = ResolveEncounterInstanceByJournalScan(encounter)
if fallbackInstanceId and fallbackInstanceId > 0 then
journalInstanceId = fallbackInstanceId
end
if fallbackInstanceName and fallbackInstanceName ~= "" then
instanceName = fallbackInstanceName
end
end
local resolvedId = (journalInstanceId > 0 and journalInstanceId or nil)
local resolvedName = NormalizeInstanceName(instanceName)
encounterInstanceInfoCache[encounter] = {
journalInstanceId = resolvedId,
instanceName = resolvedName,
}
return resolvedId, resolvedName
end
local function GetBossAbilityDataRoot()
local root = HMGT.RaidTimelineBossAbilityData
if type(root) ~= "table" then
return nil
end
if type(root.raids) ~= "table" then
root.raids = {}
end
return root
end
local function NormalizeBossAbilityDifficulties(difficulties)
local source = type(difficulties) == "table" and difficulties or {}
return {
lfr = source.lfr == true,
normal = source.normal == true,
heroic = source.heroic == true,
mythic = source.mythic == true,
}
end
local function GetBossAbilityDifficultySuffix(definition)
local difficulties = NormalizeBossAbilityDifficulties(definition and definition.difficulties)
local enabled = {}
if difficulties.lfr then enabled[#enabled + 1] = "LFR" end
if difficulties.normal then enabled[#enabled + 1] = "N" end
if difficulties.heroic then enabled[#enabled + 1] = "HC" end
if difficulties.mythic then enabled[#enabled + 1] = "M" end
if #enabled == 0 or #enabled == 4 then
return ""
end
return string.format(" [%s]", table.concat(enabled, "/"))
end
function RT:GetBossAbilityDefinitions(encounterId)
local targetEncounterId = tonumber(encounterId)
local cached = bossAbilityDefinitionsCache[targetEncounterId or 0]
if cached then
return cached
end
local root = GetBossAbilityDataRoot()
local definitions = {}
if not root or not targetEncounterId or targetEncounterId <= 0 then
return definitions
end
for _, raid in ipairs(root.raids or {}) do
local bosses = type(raid) == "table" and raid.bosses or nil
local boss = bosses and bosses[targetEncounterId]
if type(boss) == "table" and type(boss.abilities) == "table" then
for _, ability in ipairs(boss.abilities) do
if type(ability) == "table" then
local key = TrimText(ability.key or "")
if key ~= "" then
definitions[#definitions + 1] = {
key = key,
name = TrimText(ability.name or ""),
spellId = tonumber(ability.spellId) or 0,
icon = ability.icon,
difficulties = ability.difficulties,
triggers = ability.triggers,
encounterId = targetEncounterId,
raidName = TrimText(raid.name or ""),
bossName = TrimText(boss.name or ""),
}
end
end
end
end
end
table.sort(definitions, function(a, b)
local aName = a.name ~= "" and a.name or tostring(a.key or "")
local bName = b.name ~= "" and b.name or tostring(b.key or "")
return aName < bName
end)
bossAbilityDefinitionsCache[targetEncounterId] = definitions
return definitions
end
function RT:MarkEncounterDirty(encounterId)
local id = tonumber(encounterId)
if id and id > 0 then
encounterDirtyFlags[id] = true
encounterNormalizedFlags[id] = nil
end
end
function RT:GetBossAbilityDefinition(encounterId, abilityKey)
local key = TrimText(abilityKey or "")
if key == "" then
return nil
end
for _, definition in ipairs(self:GetBossAbilityDefinitions(encounterId)) do
if tostring(definition.key or "") == key then
return definition
end
end
return nil
end
function RT:GetBossAbilityDisplayText(definition)
if type(definition) ~= "table" then
return L["OPT_RT_NO_BOSS_ABILITY"] or "No boss ability"
end
local text = TrimText(definition.name or "")
local spellId = tonumber(definition.spellId) or 0
if text == "" then
if spellId > 0 then
text = GetSpellName(spellId) or ("Spell " .. tostring(spellId))
else
text = tostring(definition.key or (L["OPT_RT_NO_BOSS_ABILITY"] or "No boss ability"))
end
end
text = text .. GetBossAbilityDifficultySuffix(definition)
local icon = definition.icon
if (not icon or icon == "") and spellId > 0 and HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then
icon = HMGT_SpellData.GetSpellIcon(spellId)
end
if (not icon or icon == "") and spellId > 0 and C_Spell and type(C_Spell.GetSpellTexture) == "function" then
icon = C_Spell.GetSpellTexture(spellId)
end
if icon and icon ~= "" then
return string.format("|T%s:16:16:0:0|t %s", tostring(icon), text)
end
return text
end
function RT:EncodeCommText(value)
local text = tostring(value or "")
text = text:gsub("%%", "%%25")
text = text:gsub("|", "%%7C")
text = text:gsub("\n", "%%0A")
text = text:gsub("\r", "")
return text
end
function RT:DecodeCommText(value)
local text = tostring(value or "")
text = text:gsub("%%0A", "\n")
text = text:gsub("%%7C", "|")
text = text:gsub("%%25", "%%")
return text
end
function RT:GetSettings()
local profile = HMGT.db and HMGT.db.profile
if not profile then
return nil
end
profile.raidTimeline = profile.raidTimeline or {}
local settings = profile.raidTimeline
if settings.enabled == nil then settings.enabled = false end
settings.leadTime = math.floor(Clamp(settings.leadTime, 1, 15, 5) + 0.5)
settings.assignmentLeadTime = math.floor(Clamp(settings.assignmentLeadTime, 0, 60, settings.leadTime or 5) + 0.5)
settings.unlocked = settings.unlocked == true
settings.alertPosX = Clamp(settings.alertPosX, -2000, 2000, 0)
settings.alertPosY = Clamp(settings.alertPosY, -2000, 2000, 180)
settings.alertFont = tostring(settings.alertFont or "Friz Quadrata TT")
settings.alertFontSize = math.floor(Clamp(settings.alertFontSize, 10, 72, 30) + 0.5)
settings.alertFontOutline = tostring(settings.alertFontOutline or "OUTLINE")
settings.alertColor = type(settings.alertColor) == "table" and settings.alertColor or { r = 1, g = 0.82, b = 0.15, a = 1 }
settings.alertColor.r = tonumber(settings.alertColor.r or settings.alertColor[1]) or 1
settings.alertColor.g = tonumber(settings.alertColor.g or settings.alertColor[2]) or 0.82
settings.alertColor.b = tonumber(settings.alertColor.b or settings.alertColor[3]) or 0.15
settings.alertColor.a = tonumber(settings.alertColor.a or settings.alertColor[4]) or 1
settings.encounters = type(settings.encounters) == "table" and settings.encounters or {}
return settings
end
function RT:GetEncounterIds()
local ids = {}
local encounters = (self:GetSettings() or {}).encounters or {}
for encounterId in pairs(encounters) do
local id = tonumber(encounterId)
if id and id > 0 then
ids[#ids + 1] = id
end
end
table.sort(ids)
return ids
end
function RT:GetEncounter(encounterId)
local id = tonumber(encounterId)
if not id or id <= 0 then
return nil
end
local settings = self:GetSettings()
local encounter = settings and settings.encounters and settings.encounters[id]
if type(encounter) ~= "table" then
return nil
end
if encounterDirtyFlags[id] ~= true and encounterNormalizedFlags[id] == true then
return encounter
end
local journalInstanceId, instanceName = self:GetEncounterInstanceInfo(id)
encounter.name = tostring(encounter.name or "")
encounter.journalInstanceId = journalInstanceId or tonumber(encounter.journalInstanceId) or 0
encounter.instanceName = instanceName
encounter.difficulties = NormalizeEncounterDifficulties(encounter.difficulties)
encounter.entries = type(encounter.entries) == "table" and encounter.entries or {}
for _, entry in ipairs(encounter.entries) do
entry.time = math.max(0, math.floor((ParseTimelineClock(entry.time) or tonumber(entry.time) or 0) + 0.5))
entry.spellId = math.max(0, tonumber(entry.spellId) or 0)
entry.playerName = TrimText(entry.playerName or "")
entry.entryType = NormalizeEntryType(entry.entryType, entry.spellId)
entry.triggerType, entry.actionType = NormalizeLegacyEntry(entry.triggerType, entry.actionType, entry.entryType, entry.spellId)
entry.entryType = GetLegacyEntryTypeFromAxes(entry.triggerType, entry.actionType)
entry.bossAbilityId = TrimText(entry.bossAbilityId or "")
entry.bossAbilityBarName = NormalizeBossAbilityBarName(entry.bossAbilityBarName or "")
entry.castCount = NormalizeCastCountSelector(entry.castCount)
entry.targetSpec = TrimText(entry.targetSpec or ((entry.actionType == "text") and entry.playerName or ""))
entry.alertText = tostring(entry.alertText or "")
if entry.actionType == "text" then
entry.spellId = 0
end
if entry.triggerType ~= "bossAbility" then
entry.bossAbilityId = ""
entry.bossAbilityBarName = ""
entry.castCount = "1"
else
entry.time = 0
end
end
table.sort(encounter.entries, function(a, b)
local aTime = tonumber(a and a.time) or 0
local bTime = tonumber(b and b.time) or 0
if aTime ~= bTime then
return aTime < bTime
end
local aSpell = tonumber(a and a.spellId) or 0
local bSpell = tonumber(b and b.spellId) or 0
if aSpell ~= bSpell then
return aSpell < bSpell
end
return tostring(a and a.playerName or "") < tostring(b and b.playerName or "")
end)
encounterDirtyFlags[id] = nil
encounterNormalizedFlags[id] = true
return encounter
end
function RT:EnsureEncounter(encounterId)
local id = tonumber(encounterId)
if not id or id <= 0 then
return nil
end
local settings = self:GetSettings()
settings.encounters[id] = settings.encounters[id] or {
name = "",
journalInstanceId = 0,
instanceName = NormalizeInstanceName(nil),
difficulties = NormalizeEncounterDifficulties(ENCOUNTER_DIFFICULTY_DEFAULTS),
entries = {},
}
self:MarkEncounterDirty(id)
return self:GetEncounter(id)
end
function RT:AddEncounter(encounterId, encounterName)
local id = tonumber(encounterId)
if not id or id <= 0 then
return false
end
local encounter = self:EnsureEncounter(id)
encounter.name = tostring(encounterName or encounter.name or "")
local journalInstanceId, instanceName = self:GetEncounterInstanceInfo(id)
encounter.journalInstanceId = journalInstanceId or 0
encounter.instanceName = instanceName
self:MarkEncounterDirty(id)
return true
end
function RT:IsEncounterEnabledForDifficulty(encounter, difficultyId)
if type(encounter) ~= "table" then
return false
end
local key = GetDifficultyKey(difficultyId)
if not key then
return true
end
local difficulties = NormalizeEncounterDifficulties(encounter.difficulties)
encounter.difficulties = difficulties
return difficulties[key] == true
end
function RT:RemoveEncounter(encounterId)
local id = tonumber(encounterId)
local settings = self:GetSettings()
if not id or not settings or not settings.encounters[id] then
return false
end
settings.encounters[id] = nil
if self.activeEncounterId == id then
self:StopEncounter()
end
return true
end
function RT:AddEntry(encounterId, timeSec, spellId, playerName, alertText, entryType, targetSpec)
local encounter = self:EnsureEncounter(encounterId)
local atTime = ParseTimelineClock(timeSec)
local sid = math.max(0, tonumber(spellId) or 0)
local text = tostring(alertText or "")
local kind = NormalizeEntryType(entryType, sid)
local triggerType, actionType = NormalizeLegacyEntry(nil, nil, kind, sid)
local fixedPlayer = TrimText(playerName or "")
if kind == "text" then
sid = 0
end
local targets = TrimText(targetSpec or ((kind == "text") and fixedPlayer or ""))
local trimmedText = TrimText(text)
if not encounter or not atTime or atTime < 0 then
return false
end
if kind == "spell" and sid <= 0 then
return false
end
if kind == "text" and (trimmedText == "" or targets == "") then
return false
end
encounter.entries[#encounter.entries + 1] = {
time = math.floor(atTime + 0.5),
spellId = sid,
playerName = fixedPlayer,
entryType = GetLegacyEntryTypeFromAxes(triggerType, actionType),
triggerType = triggerType,
actionType = actionType,
targetSpec = targets,
alertText = text,
}
self:MarkEncounterDirty(encounterId)
self:GetEncounter(encounterId)
self:Refresh()
return true
end
function RT:AddDetailedEntry(encounterId, entryData)
local data = type(entryData) == "table" and entryData or {}
local encounter = self:EnsureEncounter(encounterId)
if not encounter then
return false
end
local triggerType, actionType = NormalizeLegacyEntry(data.triggerType, data.actionType, data.entryType, data.spellId)
local timeSec = ParseTimelineClock(data.time)
local spellId = math.max(0, tonumber(data.spellId) or 0)
local playerName = TrimText(data.playerName or "")
local alertText = tostring(data.alertText or "")
local targetSpec = TrimText(data.targetSpec or "")
local bossAbilityId = TrimText(data.bossAbilityId or "")
local bossAbilityBarName = NormalizeBossAbilityBarName(data.bossAbilityBarName or "")
local castCount = NormalizeCastCountSelector(data.castCount)
if triggerType == "time" and actionType == "raidCooldown" then
if timeSec == nil or timeSec < 0 or spellId <= 0 then
return false
end
elseif triggerType == "time" and actionType == "text" then
if timeSec == nil or timeSec < 0 or TrimText(alertText) == "" then
return false
end
spellId = 0
bossAbilityId = ""
castCount = "1"
elseif triggerType == "bossAbility" and actionType == "text" then
if bossAbilityBarName == "" or TrimText(alertText) == "" then
return false
end
spellId = 0
timeSec = 0
elseif triggerType == "bossAbility" and actionType == "raidCooldown" then
if bossAbilityBarName == "" or spellId <= 0 then
return false
end
timeSec = 0
else
return false
end
encounter.entries[#encounter.entries + 1] = {
time = math.floor((timeSec or 0) + 0.5),
spellId = spellId,
playerName = playerName,
entryType = GetLegacyEntryTypeFromAxes(triggerType, actionType),
triggerType = triggerType,
actionType = actionType,
targetSpec = targetSpec,
alertText = alertText,
bossAbilityId = bossAbilityId,
bossAbilityBarName = bossAbilityBarName,
castCount = castCount,
}
self:MarkEncounterDirty(encounterId)
self:GetEncounter(encounterId)
self:Refresh()
return true
end
function RT:RemoveEntry(encounterId, row)
local encounter = self:GetEncounter(encounterId)
local index = tonumber(row)
if not encounter or not index or not encounter.entries[index] then
return false
end
table.remove(encounter.entries, index)
self:MarkEncounterDirty(encounterId)
self:Refresh()
return true
end
function RT:SetEntryField(encounterId, row, field, value)
local encounter = self:GetEncounter(encounterId)
local index = tonumber(row)
if not encounter or not index or not encounter.entries[index] then
return false
end
local entry = encounter.entries[index]
if field == "time" then
local timeValue = ParseTimelineClock(value)
if not timeValue or timeValue < 0 then
return false
end
entry.time = math.floor(timeValue + 0.5)
elseif field == "spellId" then
entry.spellId = math.max(0, tonumber(value) or 0)
elseif field == "playerName" then
entry.playerName = TrimText(value or "")
elseif field == "triggerType" then
local triggerType = tostring(value or "")
entry.triggerType = (triggerType == "bossAbility") and "bossAbility" or "time"
if entry.triggerType ~= "bossAbility" then
entry.bossAbilityId = ""
entry.bossAbilityBarName = ""
entry.castCount = "1"
else
entry.time = 0
end
entry.entryType = GetLegacyEntryTypeFromAxes(entry.triggerType, entry.actionType)
elseif field == "actionType" then
local actionType = tostring(value or "")
entry.actionType = (actionType == "text") and "text" or "raidCooldown"
if entry.actionType == "text" then
entry.spellId = 0
if TrimText(entry.targetSpec or "") == "" then
entry.targetSpec = TrimText(entry.playerName or "")
end
end
entry.entryType = GetLegacyEntryTypeFromAxes(entry.triggerType, entry.actionType)
elseif field == "entryType" then
entry.entryType = NormalizeEntryType(value, entry.spellId)
entry.triggerType, entry.actionType = NormalizeLegacyEntry(entry.triggerType, entry.actionType, entry.entryType, entry.spellId)
entry.entryType = GetLegacyEntryTypeFromAxes(entry.triggerType, entry.actionType)
elseif field == "targetSpec" then
entry.targetSpec = TrimText(value or "")
elseif field == "alertText" then
entry.alertText = tostring(value or "")
elseif field == "bossAbilityId" then
entry.bossAbilityId = TrimText(value or "")
elseif field == "bossAbilityBarName" then
entry.bossAbilityBarName = NormalizeBossAbilityBarName(value or "")
elseif field == "castCount" then
entry.castCount = NormalizeCastCountSelector(value)
else
return false
end
self:MarkEncounterDirty(encounterId)
self:GetEncounter(encounterId)
self:Refresh()
return true
end
function RT:GetSpellEntry(spellId)
local sid = tonumber(spellId)
if not sid or sid <= 0 then
return nil
end
return HMGT_SpellData and HMGT_SpellData.CooldownLookup and HMGT_SpellData.CooldownLookup[sid] or nil
end
function RT:GetRaidCooldownSpellValues()
local values = {
[0] = L["OPT_RT_NO_SPELL"] or "No spell",
}
local seen = { [0] = true }
local spells = HMGT_SpellData and HMGT_SpellData.RaidCooldowns or {}
for _, entry in ipairs(spells) 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 = nil
if HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then
icon = HMGT_SpellData.GetSpellIcon(spellId)
end
if (not icon or icon == "") and C_Spell and type(C_Spell.GetSpellTexture) == "function" then
icon = C_Spell.GetSpellTexture(spellId)
end
if icon and icon ~= "" then
values[spellId] = string.format("|T%s:16:16:0:0|t %s (%d)", tostring(icon), spellName, spellId)
else
values[spellId] = string.format("%s (%d)", spellName, spellId)
end
end
end
return values
end
function RT:GetBossAbilityValues(encounterId)
local values = {
[""] = L["OPT_RT_NO_BOSS_ABILITY"] or "No boss ability",
}
local targetEncounterId = tonumber(encounterId)
if not targetEncounterId or targetEncounterId <= 0 then
return values
end
for _, definition in ipairs(self:GetBossAbilityDefinitions(targetEncounterId)) do
values[tostring(definition.key)] = self:GetBossAbilityDisplayText(definition)
end
return values
end
function RT:GetEntryType(entry)
return NormalizeEntryType(entry and entry.entryType, entry and entry.spellId)
end
function RT:GetTriggerType(entry)
local triggerType, _ = NormalizeLegacyEntry(
entry and entry.triggerType,
entry and entry.actionType,
entry and entry.entryType,
entry and entry.spellId
)
return triggerType
end
function RT:GetActionType(entry)
local _, actionType = NormalizeLegacyEntry(entry and entry.triggerType, entry and entry.actionType, entry and entry.entryType, entry and entry.spellId)
return actionType
end
function RT:GetTriggerTypeValues()
return {
time = L["OPT_RT_TRIGGER_TIME"] or "Time",
bossAbility = L["OPT_RT_TRIGGER_BOSS_ABILITY"] or "Boss ability",
}
end
function RT:GetActionTypeValues()
return {
text = L["OPT_RT_ACTION_TEXT"] or "Text",
raidCooldown = L["OPT_RT_ACTION_RAID_COOLDOWN"] or "Raid Cooldown",
}
end
function RT:GetEntryTypeValues()
return {
spell = L["OPT_RT_TYPE_SPELL"] or "Spell",
text = L["OPT_RT_TYPE_TEXT"] or "Text",
bossAbility = L["OPT_RT_TYPE_BOSS_ABILITY"] or "Boss ability",
}
end
function RT:GetEntryAlertText(entry)
local text = TrimText(entry and entry.alertText or "")
if text == "" then
return nil
end
return text
end
function RT:GetEntryDisplayText(entry)
local triggerType = self:GetTriggerType(entry)
local actionType = self:GetActionType(entry)
local alertText = self:GetEntryAlertText(entry)
if actionType == "text" and triggerType ~= "bossAbility" then
return alertText or (L["OPT_RT_ASSIGNMENT_NONE"] or "No cooldown selected")
end
if triggerType == "bossAbility" then
local encounterId = tonumber(entry and entry.encounterId) or tonumber(self.activeEncounterId) or 0
local definition = self:GetBossAbilityDefinition(encounterId, entry and entry.bossAbilityId)
local bossAbilityText = TrimText(entry and entry.bossAbilityBarName or "")
if bossAbilityText == "" then
bossAbilityText = self:GetBossAbilityDisplayText(definition)
end
local castCount = self:FormatCastCountSelector(entry and entry.castCount)
if actionType == "text" and alertText then
return string.format("%s (%s %s)", alertText, L["OPT_RT_CAST"] or "Cast", castCount)
elseif actionType == "raidCooldown" then
local spellId = tonumber(entry and entry.spellId) or 0
local spellName = spellId > 0 and (GetSpellName(spellId) or ("Spell " .. tostring(spellId))) or (L["OPT_RT_ASSIGNMENT_NONE"] or "No cooldown selected")
return string.format("%s -> %s (%s %s)", bossAbilityText, spellName, L["OPT_RT_CAST"] or "Cast", castCount)
end
return string.format("%s (%s %s)", bossAbilityText, L["OPT_RT_CAST"] or "Cast", castCount)
end
if alertText then
return alertText
end
local spellId = tonumber(entry and entry.spellId) or 0
if spellId > 0 then
return GetSpellName(spellId) or ("Spell " .. tostring(spellId))
end
return L["OPT_RT_ASSIGNMENT_NONE"] or "No cooldown selected"
end
function RT:GetEntryIcon(entry)
local triggerType = self:GetTriggerType(entry)
local actionType = self:GetActionType(entry)
local spellId = tonumber(entry and entry.spellId) or 0
if actionType == "raidCooldown" and spellId > 0 then
if HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then
local icon = HMGT_SpellData.GetSpellIcon(spellId)
if icon and icon ~= "" then
return icon
end
end
if C_Spell and type(C_Spell.GetSpellTexture) == "function" then
local icon = C_Spell.GetSpellTexture(spellId)
if icon and icon ~= "" then
return icon
end
end
end
if triggerType == "bossAbility" then
local encounterId = tonumber(entry and entry.encounterId) or tonumber(self.activeEncounterId) or 0
local definition = self:GetBossAbilityDefinition(encounterId, entry and entry.bossAbilityId)
local icon = definition and definition.icon
local numericAbilityId = tonumber(definition and definition.spellId) or 0
if (not icon or icon == "") and numericAbilityId > 0 and C_Spell and type(C_Spell.GetSpellTexture) == "function" then
icon = C_Spell.GetSpellTexture(numericAbilityId)
end
if icon and icon ~= "" then
return icon
end
end
if actionType == "text" then
return GetTextReminderIcon()
end
return 136243
end
local function FormatTimelineClock(value)
local total = math.max(0, math.floor((tonumber(value) or 0) + 0.5))
local minutes = math.floor(total / 60)
local seconds = total % 60
return string.format("%02d:%02d", minutes, seconds)
end
function RT:FormatTimelineClock(value)
return FormatTimelineClock(value)
end
function RT:ParseTimelineClock(value)
return ParseTimelineClock(value)
end
function RT:GetTimelineEditorState()
self.timelineEditorState = self.timelineEditorState or {
encounterId = nil,
viewportStart = 0,
viewportDuration = 120,
selectedIndex = nil,
}
local state = self.timelineEditorState
state.viewportStart = Clamp(state.viewportStart, 0, 540, 0)
state.viewportDuration = Clamp(state.viewportDuration, 30, 300, 120)
return state
end
function RT:CloseTimelineEditor()
if self.timelineEditor and self.timelineEditor.frame then
self.timelineEditor.frame:Release()
end
self.timelineEditor = nil
end
function RT:GetTimelineCanvasTime(canvas, cursorX)
if not canvas then
return 0
end
local width = math.max(1, canvas:GetWidth() or 1)
local left = canvas:GetLeft() or 0
local state = self:GetTimelineEditorState()
local ratio = (tonumber(cursorX) or left) - left
ratio = ratio / width
ratio = Clamp(ratio, 0, 1, 0)
return math.floor((state.viewportStart + (ratio * state.viewportDuration)) + 0.5)
end
function RT:SelectTimelineEntry(index)
local state = self:GetTimelineEditorState()
state.selectedIndex = tonumber(index)
self:RefreshTimelineEditor()
end
function RT:DeleteSelectedTimelineEntry()
local state = self:GetTimelineEditorState()
if not state.encounterId or not state.selectedIndex then
return
end
if self:RemoveEntry(state.encounterId, state.selectedIndex) then
state.selectedIndex = nil
self:RefreshTimelineEditor()
end
end
function RT:CreateTimelineEntryAtCursor(cursorX)
local state = self:GetTimelineEditorState()
if not self:IsLocalEditor() or not state.encounterId then
return
end
local values = self:GetRaidCooldownSpellValues()
local firstSpellId = nil
for spellId in pairs(values) do
if tonumber(spellId) and tonumber(spellId) > 0 and (not firstSpellId or spellId < firstSpellId) then
firstSpellId = spellId
end
end
if not firstSpellId then
return
end
local timeSec = self:GetTimelineCanvasTime(self.timelineEditor and self.timelineEditor.canvas, cursorX)
if self:AddEntry(state.encounterId, timeSec, firstSpellId, "", "") then
local encounter = self:GetEncounter(state.encounterId)
state.selectedIndex = encounter and #encounter.entries or nil
self:RefreshTimelineEditor()
end
end
function RT:HandleTimelineMarkerDrag(index, cursorX)
local state = self:GetTimelineEditorState()
if not self:IsLocalEditor() or not state.encounterId or not index then
return
end
local encounter = self:GetEncounter(state.encounterId)
local entry = encounter and encounter.entries and encounter.entries[index]
if not entry then
return
end
local newTime = self:GetTimelineCanvasTime(self.timelineEditor and self.timelineEditor.canvas, cursorX)
entry.time = math.max(0, newTime)
self:GetEncounter(state.encounterId)
state.selectedIndex = index
self:RefreshTimelineEditor()
end
function RT:RefreshTimelineEditorDetails()
local editor = self.timelineEditor
if not editor then
return
end
local state = self:GetTimelineEditorState()
local encounter = state.encounterId and self:GetEncounter(state.encounterId) or nil
local entry = encounter and encounter.entries and encounter.entries[state.selectedIndex or 0] or nil
local selected = entry ~= nil
local triggerType = entry and self:GetTriggerType(entry) or "time"
local actionType = entry and self:GetActionType(entry) or "raidCooldown"
local editable = selected and self:IsLocalEditor()
if editor.selectionHeader then
local encounterLabel = encounter and (encounter.name ~= "" and encounter.name or (L["OPT_RT_ENCOUNTER"] or "Encounter")) or (L["OPT_RT_ENCOUNTER"] or "Encounter")
local suffix = ""
if entry then
local displayEntry = entry
if triggerType == "bossAbility" then
displayEntry = {}
for key, value in pairs(entry) do
displayEntry[key] = value
end
displayEntry.encounterId = state.encounterId
suffix = string.format(" - %s", self:GetEntryDisplayText(displayEntry))
else
suffix = string.format(" - %s @ %s", self:GetEntryDisplayText(entry), FormatTimelineClock(entry.time))
end
end
local headerText = string.format("%s (%s)%s", encounterLabel, tostring(state.encounterId or "-"), suffix)
editor.selectionHeader:SetText(headerText)
if editor.selectionHeader.label then
local stringHeight = editor.selectionHeader.label.GetStringHeight and editor.selectionHeader.label:GetStringHeight() or 0
editor.selectionHeader:SetHeight(math.max(42, math.ceil((tonumber(stringHeight) or 0) + 8)))
end
end
if editor.timeValue then
editor.timeValue:SetDisabled(not editable or triggerType == "bossAbility")
editor.timeValue:SetText(entry and FormatTimelineClock(entry.time or 0) or "")
if editor.timeValue.frame then
editor.timeValue.frame:SetShown(triggerType ~= "bossAbility")
end
end
if editor.triggerTypeValue then
editor.triggerTypeValue:SetDisabled(not editable)
editor.triggerTypeValue:SetValue(triggerType)
end
if editor.actionTypeValue then
editor.actionTypeValue:SetDisabled(not editable)
editor.actionTypeValue:SetValue(actionType)
end
if editor.spellValue then
editor.spellValue:SetDisabled(not (editable and actionType == "raidCooldown"))
editor.spellValue:SetValue(entry and math.max(0, tonumber(entry.spellId) or 0) or 0)
if editor.spellValue.frame then
editor.spellValue.frame:SetShown(actionType == "raidCooldown")
end
end
if editor.castCountValue then
editor.castCountValue:SetDisabled(not (editable and triggerType == "bossAbility"))
editor.castCountValue:SetText(entry and self:FormatCastCountSelector(entry.castCount) or "1")
if editor.castCountValue.frame then
editor.castCountValue.frame:SetShown(triggerType == "bossAbility")
end
end
if editor.bossAbilityBarNameValue then
editor.bossAbilityBarNameValue:SetDisabled(not (editable and triggerType == "bossAbility"))
editor.bossAbilityBarNameValue:SetText((triggerType == "bossAbility" and entry) and tostring(entry.bossAbilityBarName or "") or "")
if editor.bossAbilityBarNameValue.frame then
editor.bossAbilityBarNameValue.frame:SetShown(triggerType == "bossAbility")
end
end
if editor.textValue then
editor.textValue:SetDisabled(not editable or actionType ~= "text")
if editor.textValue.SetLabel then
editor.textValue:SetLabel(L["OPT_RT_ENTRY_TEXT"] or "Custom text")
end
editor.textValue:SetText((actionType == "text" and entry) and tostring(entry.alertText or "") or "")
if editor.textValue.frame then
editor.textValue.frame:SetShown(actionType == "text")
end
end
if editor.targetValue then
editor.targetValue:SetDisabled(not editable)
if editor.targetValue.SetLabel then
editor.targetValue:SetLabel((actionType == "text") and (L["OPT_RT_ENTRY_TARGETS"] or "Targets") or (L["OPT_RT_ENTRY_PLAYER"] or "Fixed player"))
end
editor.targetValue:SetText(entry and tostring((actionType == "text") and (entry.targetSpec or "") or (entry.playerName or "")) or "")
end
if editor.deleteButton then
editor.deleteButton:SetDisabled(not editable)
end
end
function RT:RefreshTimelineEditorCanvas()
local editor = self.timelineEditor
if not editor or not editor.canvas then
return
end
local canvas = editor.canvas
local state = self:GetTimelineEditorState()
local encounter = state.encounterId and self:GetEncounter(state.encounterId) or nil
local entries = encounter and encounter.entries or {}
local width = math.max(1, canvas:GetWidth() or 1)
local duration = math.max(30, tonumber(state.viewportDuration) or 120)
local startSec = Clamp(state.viewportStart, 0, math.max(0, 600 - duration), 0)
state.viewportStart = startSec
if editor.viewportSlider then
editor.viewportSlider:SetSliderValues(0, math.max(0, 600 - duration), 1)
editor.viewportSlider:SetValue(startSec)
end
if editor.zoomSlider then
editor.zoomSlider:SetValue(duration)
end
if editor.viewportLabel then
editor.viewportLabel:SetText(string.format("%s - %s", FormatTimelineClock(startSec), FormatTimelineClock(startSec + duration)))
end
editor.markers = editor.markers or {}
for _, marker in ipairs(editor.markers) do
marker:Hide()
end
local tickSpacing = 30
local tickIndex = 1
for tick = startSec, startSec + duration, tickSpacing do
local ratio = (tick - startSec) / duration
local x = math.floor(ratio * (width - 20)) + 10
local line = editor.tickLines[tickIndex]
local label = editor.tickLabels[tickIndex]
if line and label then
line:ClearAllPoints()
line:SetPoint("BOTTOMLEFT", canvas, "BOTTOMLEFT", x, 26)
line:SetSize(1, 28)
line:Show()
label:ClearAllPoints()
label:SetPoint("TOP", line, "BOTTOM", 0, -2)
label:SetText(FormatTimelineClock(tick))
label:Show()
end
tickIndex = tickIndex + 1
end
for i = tickIndex, #editor.tickLines do
editor.tickLines[i]:Hide()
editor.tickLabels[i]:Hide()
end
local visibleCount = 0
for index, entry in ipairs(entries) do
local timeSec = tonumber(entry.time) or 0
if self:GetTriggerType(entry) ~= "bossAbility" and timeSec >= startSec and timeSec <= startSec + duration then
visibleCount = visibleCount + 1
local marker = editor.markers[visibleCount]
if not marker then
marker = CreateFrame("Button", nil, canvas, "BackdropTemplate")
marker:SetSize(30, 30)
marker:SetMovable(false)
marker:RegisterForDrag("LeftButton")
marker.icon = marker:CreateTexture(nil, "ARTWORK")
marker.icon:SetAllPoints(marker)
marker.stem = marker:CreateTexture(nil, "BORDER")
marker.stem:SetColorTexture(1, 0.82, 0.15, 0.9)
marker.stem:SetWidth(2)
marker.border = marker:CreateTexture(nil, "OVERLAY")
marker.border:SetTexture("Interface\\Buttons\\UI-Debuff-Overlays")
marker.border:SetTexCoord(.296875, .5703125, 0, .515625)
marker.border:SetAllPoints(marker)
marker.label = marker:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
marker.label:SetPoint("TOP", marker, "BOTTOM", 0, -2)
marker.label:SetTextColor(1, 0.82, 0.15)
marker:SetScript("OnClick", function(button)
RT:SelectTimelineEntry(button.entryIndex)
end)
marker:SetScript("OnEnter", function(button)
local entryData = button.entryData
if not entryData then
return
end
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
local alertText = RT:GetEntryAlertText(entryData)
local spellId = tonumber(entryData.spellId) or 0
if alertText then
GameTooltip:SetText(alertText)
if spellId > 0 then
GameTooltip:AddLine(GetSpellName(spellId) or ("Spell " .. tostring(spellId)), 1, 0.82, 0.15)
end
elseif spellId > 0 and type(GameTooltip.SetSpellByID) == "function" then
GameTooltip:SetSpellByID(spellId)
else
GameTooltip:SetText(RT:GetEntryDisplayText(entryData))
end
if RT:GetActionType(entryData) == "text" then
local targets = TrimText(entryData.targetSpec or "")
if targets ~= "" then
GameTooltip:AddLine(string.format("%s: %s", L["OPT_RT_ENTRY_TARGETS"] or "Targets", targets), 0.85, 0.85, 0.85)
end
elseif RT:GetTriggerType(entryData) == "bossAbility" then
local barName = TrimText(entryData.bossAbilityBarName or "")
if barName == "" then
local definition = RT:GetBossAbilityDefinition(RT:GetTimelineEditorState().encounterId, entryData.bossAbilityId)
barName = RT:GetBossAbilityDisplayText(definition)
end
GameTooltip:AddLine(string.format("%s: %s", L["OPT_RT_BOSS_BAR_NAME"] or "Bossmod bar name", barName ~= "" and barName or (L["OPT_RT_NO_BOSS_ABILITY"] or "No boss ability")), 0.85, 0.85, 0.85)
GameTooltip:AddLine(string.format("%s: %s", L["OPT_RT_CAST_COUNT"] or "Cast count", RT:FormatCastCountSelector(entryData.castCount)), 0.85, 0.85, 0.85)
elseif entryData.playerName and entryData.playerName ~= "" then
GameTooltip:AddLine(string.format("%s: %s", L["OPT_RT_ENTRY_PLAYER"] or "Fixed player", tostring(entryData.playerName)), 0.85, 0.85, 0.85)
end
HMGT:SafeShowTooltip(GameTooltip)
end)
marker:SetScript("OnLeave", function()
GameTooltip:Hide()
end)
marker:SetScript("OnDragStart", function(button)
if not RT:IsLocalEditor() then
return
end
button.dragging = true
end)
marker:SetScript("OnDragStop", function(button)
button.dragging = nil
end)
marker:SetScript("OnUpdate", function(button)
if not button.dragging then
return
end
local cursorX = GetCursorPosition()
local scale = UIParent:GetEffectiveScale() or 1
RT:HandleTimelineMarkerDrag(button.entryIndex, cursorX / scale)
end)
editor.markers[visibleCount] = marker
end
marker.entryIndex = index
marker.entryData = entry
local ratio = (timeSec - startSec) / duration
local x = math.floor(ratio * (width - 20)) + 10
marker:ClearAllPoints()
marker:SetPoint("BOTTOM", canvas, "BOTTOMLEFT", x, 84)
marker.stem:ClearAllPoints()
marker.stem:SetPoint("TOP", marker, "CENTER", 0, 0)
marker.stem:SetPoint("BOTTOM", canvas, "BOTTOMLEFT", x, 47)
marker.icon:SetTexture(self:GetEntryIcon(entry))
marker.label:SetText(FormatTimelineClock(timeSec))
if state.selectedIndex == index then
marker:SetBackdrop({
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
edgeSize = 12,
})
marker:SetBackdropBorderColor(0.25, 1, 0.6, 1)
else
marker:SetBackdrop(nil)
end
marker:Show()
end
end
for i = visibleCount + 1, #editor.markers do
editor.markers[i]:Hide()
end
if editor.emptyText then
editor.emptyText:SetShown(visibleCount == 0)
end
end
function RT:RefreshTimelineEditor()
if not self.timelineEditor then
return
end
self:RefreshTimelineEditorCanvas()
self:RefreshTimelineEditorDetails()
end
function RT:EnsureTimelineEditor()
if self.timelineEditor or not AceGUI then
return self.timelineEditor
end
local frame = AceGUI:Create("Frame")
frame:SetTitle(L["OPT_RT_TIMELINE_EDITOR"] or "Raid Timeline Editor")
frame:SetStatusText("")
frame:SetLayout("Fill")
frame:SetWidth(1080)
frame:SetHeight(580)
frame:EnableResize(true)
frame:SetCallback("OnClose", function(widget)
RT.timelineEditor = nil
if widget and widget.Release then
widget:Release()
end
end)
local root = AceGUI:Create("SimpleGroup")
root:SetFullWidth(true)
root:SetFullHeight(true)
root:SetLayout("Flow")
frame:AddChild(root)
local left = AceGUI:Create("SimpleGroup")
left:SetWidth(760)
left:SetFullHeight(true)
left:SetLayout("List")
root:AddChild(left)
local canvasGroup = AceGUI:Create("SimpleGroup")
canvasGroup:SetFullWidth(true)
canvasGroup:SetHeight(300)
canvasGroup:SetLayout("Fill")
left:AddChild(canvasGroup)
local viewportLabel = canvasGroup.frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight")
viewportLabel:SetPoint("TOPLEFT", canvasGroup.content, "TOPLEFT", 10, -8)
local canvasHolder = CreateFrame("Frame", nil, canvasGroup.content, "BackdropTemplate")
canvasHolder:SetPoint("TOPLEFT", canvasGroup.content, "TOPLEFT", 8, -30)
canvasHolder:SetPoint("TOPRIGHT", canvasGroup.content, "TOPRIGHT", -8, -30)
canvasHolder:SetHeight(250)
canvasHolder:SetBackdrop({
bgFile = "Interface\\DialogFrame\\UI-DialogBox-Background-Dark",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
edgeSize = 12,
insets = { left = 3, right = 3, top = 3, bottom = 3 },
})
canvasHolder:SetBackdropBorderColor(1, 0.82, 0.15, 0.55)
canvasHolder:EnableMouse(true)
canvasHolder:EnableMouseWheel(true)
local axis = canvasHolder:CreateTexture(nil, "BACKGROUND")
axis:SetColorTexture(1, 0.82, 0.15, 0.9)
axis:SetPoint("BOTTOMLEFT", canvasHolder, "BOTTOMLEFT", 10, 44)
axis:SetPoint("BOTTOMRIGHT", canvasHolder, "BOTTOMRIGHT", -10, 44)
axis:SetHeight(3)
local emptyText = canvasHolder:CreateFontString(nil, "OVERLAY", "GameFontDisable")
emptyText:SetPoint("CENTER", canvasHolder, "CENTER", 0, 20)
emptyText:SetText(L["OPT_RT_TIMELINE_EMPTY_WINDOW"] or "No cooldowns in the current window.")
local tickLines, tickLabels = {}, {}
for i = 1, 12 do
local line = canvasHolder:CreateTexture(nil, "BORDER")
line:SetColorTexture(1, 1, 1, 0.2)
line:Hide()
tickLines[i] = line
local label = canvasHolder:CreateFontString(nil, "OVERLAY", "GameFontNormalSmall")
label:SetTextColor(1, 0.82, 0.15)
label:Hide()
tickLabels[i] = label
end
canvasHolder:SetScript("OnMouseDown", function(_, button)
if button ~= "LeftButton" then
return
end
local cursorX = GetCursorPosition()
local scale = UIParent:GetEffectiveScale() or 1
RT:CreateTimelineEntryAtCursor(cursorX / scale)
end)
canvasHolder:SetScript("OnMouseWheel", function(_, delta)
local state = RT:GetTimelineEditorState()
if IsControlKeyDown() then
state.viewportDuration = Clamp(state.viewportDuration - (delta * 15), 30, 300, state.viewportDuration)
else
state.viewportStart = Clamp(state.viewportStart - (delta * 15), 0, math.max(0, 600 - state.viewportDuration), state.viewportStart)
end
RT:RefreshTimelineEditor()
end)
local viewportSlider = AceGUI:Create("Slider")
viewportSlider:SetLabel(L["OPT_RT_TIMELINE_SCROLL"] or "Scroll timeline")
viewportSlider:SetSliderValues(0, 480, 1)
viewportSlider:SetValue(0)
viewportSlider:SetWidth(360)
viewportSlider:SetCallback("OnValueChanged", function(_, _, value)
RT:GetTimelineEditorState().viewportStart = math.floor((tonumber(value) or 0) + 0.5)
RT:RefreshTimelineEditor()
end)
left:AddChild(viewportSlider)
local zoomSlider = AceGUI:Create("Slider")
zoomSlider:SetLabel(L["OPT_RT_TIMELINE_ZOOM"] or "Zoom")
zoomSlider:SetSliderValues(30, 300, 15)
zoomSlider:SetValue(120)
zoomSlider:SetWidth(300)
zoomSlider:SetCallback("OnValueChanged", function(_, _, value)
RT:GetTimelineEditorState().viewportDuration = math.floor((tonumber(value) or 120) + 0.5)
RT:RefreshTimelineEditor()
end)
left:AddChild(zoomSlider)
local hint = AceGUI:Create("Label")
hint:SetFullWidth(true)
hint:SetText(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.")
left:AddChild(hint)
local right = AceGUI:Create("InlineGroup")
right:SetTitle(L["OPT_RT_ASSIGNMENT_EDITOR"] or "Assignment")
right:SetWidth(280)
right:SetFullHeight(true)
right:SetLayout("Fill")
root:AddChild(right)
local rightScroll = AceGUI:Create("ScrollFrame")
rightScroll:SetLayout("List")
right:AddChild(rightScroll)
local selectionHeader = AceGUI:Create("Label")
selectionHeader:SetFullWidth(true)
selectionHeader:SetHeight(42)
selectionHeader:SetText(L["OPT_RT_ASSIGNMENT_NONE"] or "No cooldown selected")
if selectionHeader.label then
selectionHeader.label:SetJustifyH("LEFT")
selectionHeader.label:SetJustifyV("TOP")
end
rightScroll:AddChild(selectionHeader)
local timeValue = AceGUI:Create("EditBox")
timeValue:SetLabel(L["OPT_RT_ENTRY_TIME"] or "Time")
timeValue:SetFullWidth(true)
timeValue:SetCallback("OnEnterPressed", function(widget, _, value)
local state = RT:GetTimelineEditorState()
if state.encounterId and state.selectedIndex then
RT:SetEntryField(state.encounterId, state.selectedIndex, "time", value)
RT:RefreshTimelineEditor()
end
widget:ClearFocus()
end)
rightScroll:AddChild(timeValue)
local triggerTypeValue = AceGUI:Create("Dropdown")
triggerTypeValue:SetLabel(L["OPT_RT_TRIGGER"] or "Trigger")
triggerTypeValue:SetFullWidth(true)
triggerTypeValue:SetList(RT:GetTriggerTypeValues())
triggerTypeValue:SetCallback("OnValueChanged", function(_, _, value)
local state = RT:GetTimelineEditorState()
if state.encounterId and state.selectedIndex then
RT:SetEntryField(state.encounterId, state.selectedIndex, "triggerType", value)
RT:RefreshTimelineEditor()
end
end)
rightScroll:AddChild(triggerTypeValue)
local actionTypeValue = AceGUI:Create("Dropdown")
actionTypeValue:SetLabel(L["OPT_RT_ACTION"] or "Action")
actionTypeValue:SetFullWidth(true)
actionTypeValue:SetList(RT:GetActionTypeValues())
actionTypeValue:SetCallback("OnValueChanged", function(_, _, value)
local state = RT:GetTimelineEditorState()
if state.encounterId and state.selectedIndex then
RT:SetEntryField(state.encounterId, state.selectedIndex, "actionType", value)
RT:RefreshTimelineEditor()
end
end)
rightScroll:AddChild(actionTypeValue)
local spellValue = AceGUI:Create("Dropdown")
spellValue:SetLabel(L["OPT_RT_ENTRY_SPELL"] or "Spell")
spellValue:SetFullWidth(true)
spellValue:SetList(RT:GetRaidCooldownSpellValues())
spellValue:SetCallback("OnValueChanged", function(_, _, value)
local state = RT:GetTimelineEditorState()
if state.encounterId and state.selectedIndex then
RT:SetEntryField(state.encounterId, state.selectedIndex, "spellId", value)
RT:RefreshTimelineEditor()
end
end)
rightScroll:AddChild(spellValue)
local castCountValue = AceGUI:Create("EditBox")
castCountValue:SetLabel(string.format(
"%s (1 / %s / %s / %s)",
L["OPT_RT_CAST_COUNT"] or "Cast count",
L["OPT_RT_CAST_ALL"] or "All",
L["OPT_RT_CAST_ODD"] or "Odd",
L["OPT_RT_CAST_EVEN"] or "Even"
))
castCountValue:SetFullWidth(true)
castCountValue:SetCallback("OnEnterPressed", function(widget, _, value)
local state = RT:GetTimelineEditorState()
if state.encounterId and state.selectedIndex then
RT:SetEntryField(state.encounterId, state.selectedIndex, "castCount", value)
RT:RefreshTimelineEditor()
end
widget:ClearFocus()
end)
rightScroll:AddChild(castCountValue)
local bossAbilityBarNameValue = AceGUI:Create("EditBox")
bossAbilityBarNameValue:SetLabel(L["OPT_RT_BOSS_BAR_NAME"] or "Bossmod bar name")
bossAbilityBarNameValue:SetFullWidth(true)
bossAbilityBarNameValue:SetCallback("OnEnterPressed", function(widget, _, value)
local state = RT:GetTimelineEditorState()
if state.encounterId and state.selectedIndex then
RT:SetEntryField(state.encounterId, state.selectedIndex, "bossAbilityBarName", value)
RT:RefreshTimelineEditor()
end
widget:ClearFocus()
end)
rightScroll:AddChild(bossAbilityBarNameValue)
local textValue = AceGUI:Create("EditBox")
textValue:SetLabel(L["OPT_RT_ENTRY_TEXT"] or "Custom text")
textValue:SetFullWidth(true)
textValue:SetCallback("OnEnterPressed", function(widget, _, value)
local state = RT:GetTimelineEditorState()
if state.encounterId and state.selectedIndex then
RT:SetEntryField(state.encounterId, state.selectedIndex, "alertText", value)
RT:RefreshTimelineEditor()
end
widget:ClearFocus()
end)
rightScroll:AddChild(textValue)
local targetValue = AceGUI:Create("EditBox")
targetValue:SetLabel(L["OPT_RT_ENTRY_PLAYER"] or "Fixed player")
targetValue:SetFullWidth(true)
targetValue:SetCallback("OnEnterPressed", function(widget, _, value)
local state = RT:GetTimelineEditorState()
if state.encounterId and state.selectedIndex then
local encounter = RT:GetEncounter(state.encounterId)
local entry = encounter and encounter.entries and encounter.entries[state.selectedIndex] or nil
local field = (entry and RT:GetActionType(entry) == "text") and "targetSpec" or "playerName"
RT:SetEntryField(state.encounterId, state.selectedIndex, field, value)
RT:RefreshTimelineEditor()
end
widget:ClearFocus()
end)
rightScroll:AddChild(targetValue)
local deleteButton = AceGUI:Create("Button")
deleteButton:SetText(REMOVE or "Remove")
deleteButton:SetFullWidth(true)
deleteButton:SetCallback("OnClick", function()
RT:DeleteSelectedTimelineEntry()
end)
rightScroll:AddChild(deleteButton)
self.timelineEditor = {
frame = frame,
canvas = canvasHolder,
viewportLabel = viewportLabel,
viewportSlider = viewportSlider,
zoomSlider = zoomSlider,
selectionHeader = selectionHeader,
timeValue = timeValue,
triggerTypeValue = triggerTypeValue,
actionTypeValue = actionTypeValue,
spellValue = spellValue,
castCountValue = castCountValue,
bossAbilityBarNameValue = bossAbilityBarNameValue,
textValue = textValue,
targetValue = targetValue,
deleteButton = deleteButton,
tickLines = tickLines,
tickLabels = tickLabels,
emptyText = emptyText,
markers = {},
}
return self.timelineEditor
end
function RT:OpenTimelineEditor(encounterId)
if not AceGUI then
return
end
local encounter = self:GetEncounter(encounterId)
if not encounter then
return
end
local state = self:GetTimelineEditorState()
state.encounterId = tonumber(encounterId)
state.selectedIndex = nil
state.viewportStart = Clamp(state.viewportStart, 0, 540, 0)
local editor = self:EnsureTimelineEditor()
if not editor then
return
end
editor.frame:SetTitle(string.format("%s: %s (%d)", L["OPT_RT_TIMELINE_EDITOR"] or "Raid Timeline Editor", encounter.name ~= "" and encounter.name or (L["OPT_RT_ENCOUNTER"] or "Encounter"), tonumber(encounterId) or 0))
self:RefreshTimelineEditor()
end
function RT:GetAlertFontPath()
local settings = self:GetSettings()
if LSM and settings and type(LSM.Fetch) == "function" then
local fontPath = LSM:Fetch("font", settings.alertFont, true)
if fontPath and fontPath ~= "" then
return fontPath
end
end
return STANDARD_TEXT_FONT
end
function RT:SaveAlertPosition()
local frame = self.alertFrame
local settings = self:GetSettings()
if not frame or not settings then
return
end
local frameCenterX, frameCenterY = frame:GetCenter()
local parentCenterX, parentCenterY = UIParent:GetCenter()
if not frameCenterX or not frameCenterY or not parentCenterX or not parentCenterY then
return
end
settings.alertPosX = math.floor(frameCenterX - parentCenterX + 0.5)
settings.alertPosY = math.floor(frameCenterY - parentCenterY + 0.5)
end
function RT:ApplyAlertStyle()
local frame = self.alertFrame
local settings = self:GetSettings()
if not frame or not settings then
return
end
frame:ClearAllPoints()
frame:SetPoint("CENTER", UIParent, "CENTER", settings.alertPosX or 0, settings.alertPosY or 180)
frame:EnableMouse(settings.unlocked == true)
if frame.handle then
frame.handle:SetShown(settings.unlocked == true)
end
if frame.text then
frame.text:SetFont(self:GetAlertFontPath(), settings.alertFontSize or 30, settings.alertFontOutline or "OUTLINE")
frame.text:SetTextColor(settings.alertColor.r, settings.alertColor.g, settings.alertColor.b, settings.alertColor.a)
end
end
function RT:EnsureAlertFrame()
if self.alertFrame then
return self.alertFrame
end
local frame = CreateFrame("Frame", "HMGT_RaidTimelineAlertFrame", UIParent, "BackdropTemplate")
frame:SetSize(640, 120)
frame:SetFrameStrata("FULLSCREEN_DIALOG")
frame:SetFrameLevel(200)
frame:SetClampedToScreen(true)
frame:SetMovable(true)
frame:RegisterForDrag("LeftButton")
frame:SetScript("OnDragStart", function(selfFrame)
if RT:GetSettings().unlocked == true then
selfFrame:StartMoving()
end
end)
frame:SetScript("OnDragStop", function(selfFrame)
selfFrame:StopMovingOrSizing()
RT:SaveAlertPosition()
RT:ApplyAlertStyle()
end)
frame:Hide()
local handle = frame:CreateTexture(nil, "BACKGROUND")
handle:SetAllPoints(frame)
handle:SetColorTexture(0, 0, 0, 0.35)
frame.handle = handle
local text = frame:CreateFontString(nil, "OVERLAY")
text:SetPoint("CENTER", frame, "CENTER", 0, 0)
text:SetPoint("LEFT", frame, "LEFT", 18, 0)
text:SetPoint("RIGHT", frame, "RIGHT", -18, 0)
text:SetJustifyH("CENTER")
text:SetJustifyV("MIDDLE")
frame.text = text
self.alertFrame = frame
self:ApplyAlertStyle()
if frame.text then
frame.text:SetText(L["OPT_RT_ALERT_PREVIEW"] or "Tranquility in 5")
end
return frame
end
function RT:ShowPreview()
local frame = self:EnsureAlertFrame()
if not frame then
return
end
self.previewAlertActive = true
frame:Show()
if frame.text then
frame.text:SetText(L["OPT_RT_ALERT_PREVIEW"] or "Tranquility in 5")
end
end
function RT:HideAlert()
self.previewAlertActive = false
self.currentAlert = nil
if not self.alertFrame then
return
end
if self:GetSettings().unlocked == true then
self:ApplyAlertStyle()
self.alertFrame:Show()
if self.alertFrame.text then
self.alertFrame.text:SetText(L["OPT_RT_ALERT_UNLOCKED_HINT"] or "Raid Timeline Alert\nDrag to move")
end
else
self.alertFrame:Hide()
end
end
function RT:ShowCountdownAlert(spellId, secondsRemaining, alertText)
local frame = self:EnsureAlertFrame()
if not frame then
return
end
self.currentAlert = {
spellId = math.max(0, tonumber(spellId) or 0),
alertText = tostring(alertText or ""),
endTime = GetTime() + math.max(0, tonumber(secondsRemaining) or 0),
hideTime = GetTime() + math.max(0, tonumber(secondsRemaining) or 0) + 1,
}
self:UpdateCurrentAlert(true)
end
local function GetAlertSpellLabel(spellId)
local sid = tonumber(spellId) or 0
if sid <= 0 then
return nil
end
local icon = RT:GetEntryIcon({ spellId = sid, actionType = "raidCooldown", triggerType = "time" })
local spellName = GetSpellName(sid) or ("Spell " .. tostring(sid))
if icon and icon ~= "" then
return string.format("|T%s:22:22:0:0:64:64:5:59:5:59|t %s", tostring(icon), spellName)
end
return spellName
end
local function GetAlertTextLabel(alertText)
local text = TrimText(alertText)
if text == "" then
return nil
end
return text
end
function RT:UpdateCurrentAlert(forceShow)
local alert = self.currentAlert
if not alert then
return
end
local frame = self:EnsureAlertFrame()
local now = GetTime()
local remaining = (tonumber(alert.endTime) or 0) - now
local displayValue = math.ceil(math.max(0, remaining))
local label = nil
if (tonumber(alert.spellId) or 0) > 0 then
label = GetAlertSpellLabel(alert.spellId)
end
if not label or label == "" then
label = GetAlertTextLabel(alert.alertText) or self:GetEntryDisplayText({ spellId = alert.spellId, alertText = alert.alertText, actionType = "text", triggerType = "time" })
end
if frame and frame.text then
if remaining <= 0.05 then
frame.text:SetText(label)
else
frame.text:SetText(string.format(L["OPT_RT_ALERT_TEMPLATE"] or "%s in %ds", label, displayValue))
end
end
if frame and (forceShow or not frame:IsShown()) then
frame:Show()
end
if now >= (tonumber(alert.hideTime) or 0) then
self:HideAlert()
end
end
function RT:GetRosterEntries()
local entries = {}
local seen = {}
local function addUnit(unitId, rosterIndex, subgroup)
if not unitId or not UnitExists(unitId) or not UnitIsPlayer(unitId) then
return
end
local name = NormalizeName(UnitName(unitId))
if not name or seen[name] then
return
end
seen[name] = true
entries[#entries + 1] = {
name = name,
index = tonumber(rosterIndex) or (#entries + 1),
subgroup = tonumber(subgroup) or 1,
unitId = unitId,
}
end
if IsInRaid() then
for i = 1, GetNumGroupMembers() do
local _, _, subgroup = GetRaidRosterInfo(i)
addUnit("raid" .. i, i, subgroup)
end
else
addUnit("player", 1, 1)
if IsInGroup() then
for i = 1, GetNumSubgroupMembers() do
addUnit("party" .. i, i + 1, 1)
end
end
end
table.sort(entries, function(a, b)
return (tonumber(a.index) or 0) < (tonumber(b.index) or 0)
end)
return entries
end
function RT:GetRosterPlayers()
local players = {}
for _, entry in ipairs(self:GetRosterEntries()) do
players[#players + 1] = entry.name
end
return players
end
function RT:GetResolvedTargetPlayers(targetSpec)
local spec = tostring(targetSpec or "")
spec = spec:gsub("^%s+", ""):gsub("%s+$", "")
if spec == "" then
return {}
end
local roster = self:GetRosterEntries()
local selected = {}
local seen = {}
local function addName(name)
local normalized = NormalizeName(name)
if normalized and normalized ~= "" and not seen[normalized] and HMGT:IsPlayerInCurrentGroup(normalized) then
seen[normalized] = true
selected[#selected + 1] = normalized
end
end
local function addEntry(entry)
if entry and entry.name then
addName(entry.name)
end
end
local function addNumericTarget(startValue, endValue)
local fromValue = math.min(tonumber(startValue) or 0, tonumber(endValue) or 0)
local toValue = math.max(tonumber(startValue) or 0, tonumber(endValue) or 0)
for _, entry in ipairs(roster) do
local rosterValue = IsInRaid() and tonumber(entry.subgroup) or tonumber(entry.index)
if rosterValue and rosterValue >= fromValue and rosterValue <= toValue then
addEntry(entry)
end
end
end
for token in string.gmatch(spec, "([^,]+)") do
local trimmed = tostring(token or ""):gsub("^%s+", ""):gsub("%s+$", "")
local lower = trimmed:lower()
if trimmed ~= "" then
if lower == "all" then
for _, entry in ipairs(roster) do addEntry(entry) end
elseif lower == "groupodd" then
for _, entry in ipairs(roster) do
local groupValue = IsInRaid() and tonumber(entry.subgroup) or tonumber(entry.index)
if ((groupValue or 0) % 2) == 1 then addEntry(entry) end
end
elseif lower == "groupeven" then
for _, entry in ipairs(roster) do
local groupValue = IsInRaid() and tonumber(entry.subgroup) or tonumber(entry.index)
if ((groupValue or 0) % 2) == 0 and (groupValue or 0) > 0 then addEntry(entry) end
end
elseif lower == "odd" then
for _, entry in ipairs(roster) do
if ((tonumber(entry.index) or 0) % 2) == 1 then addEntry(entry) end
end
elseif lower == "even" then
for _, entry in ipairs(roster) do
if ((tonumber(entry.index) or 0) % 2) == 0 then addEntry(entry) end
end
else
local groupNumber = trimmed:match("^[Gg]roup(%d+)$")
if groupNumber then
addNumericTarget(groupNumber, groupNumber)
else
local rangeStart, rangeEnd = trimmed:match("^(%d+)%s*%-%s*(%d+)$")
if rangeStart and rangeEnd then
addNumericTarget(rangeStart, rangeEnd)
else
local num = tonumber(trimmed)
if num then
addNumericTarget(num, num)
else
addName(trimmed)
end
end
end
end
end
end
table.sort(selected, function(a, b)
local aIndex, bIndex = 999, 999
for _, entry in ipairs(roster) do
if entry.name == a then aIndex = tonumber(entry.index) or 999 end
if entry.name == b then bIndex = tonumber(entry.index) or 999 end
end
if aIndex ~= bIndex then return aIndex < bIndex end
return tostring(a) < tostring(b)
end)
return selected
end
function RT:GetTextTargetPlayers(targetSpec)
return self:GetResolvedTargetPlayers(targetSpec)
end
function RT:IsSpellReadyForPlayer(playerName, spellId)
local name = NormalizeName(playerName)
local sid = tonumber(spellId)
if not name or not sid or sid <= 0 then
return false
end
if not HMGT:IsPlayerInCurrentGroup(name) then
return false
end
if not HMGT:IsTrackedSpellKnownForPlayer(name, sid) then
return false
end
local spellEntry = self:GetSpellEntry(sid)
local remaining, _, currentCharges, maxCharges = HMGT:GetCooldownInfo(name, sid)
if spellEntry and HMGT:IsAvailabilitySpell(spellEntry) then
return (tonumber(maxCharges) or 0) > 0 and (tonumber(currentCharges) or 0) >= (tonumber(maxCharges) or 0)
end
if (tonumber(maxCharges) or 0) > 0 then
return (tonumber(currentCharges) or 0) > 0
end
return (tonumber(remaining) or 0) <= 0
end
function RT:GetReadyCandidates(spellId)
local candidates = {}
for _, playerName in ipairs(self:GetRosterPlayers()) do
if self:IsSpellReadyForPlayer(playerName, spellId) then
candidates[#candidates + 1] = playerName
end
end
table.sort(candidates)
return candidates
end
function RT:IsLocalEditor()
return HMGT.IsRaidTimelineEditor and HMGT:IsRaidTimelineEditor() or true
end
function RT:IsAuthorizedSender(playerName)
return HMGT.IsRaidTimelineEditor and HMGT:IsRaidTimelineEditor(playerName) or false
end
function RT:GetBossAbilityDispatchKey(entryIndex, castCount)
return string.format("%s:%s", tostring(entryIndex or 0), tostring(math.max(1, tonumber(castCount) or 1)))
end
function RT:LogBossAbilityProbe(entryIndex, entry, stateKey, fmt, ...)
self._bossAbilityProbeStates = self._bossAbilityProbeStates or {}
local encounterId = tonumber(self.activeEncounterId) or 0
local slot = tonumber(entryIndex) or 0
local cacheKey = string.format("%d:%d", encounterId, slot)
if self._bossAbilityProbeStates[cacheKey] == stateKey then
return
end
self._bossAbilityProbeStates[cacheKey] = stateKey
HMGT:DebugScoped("verbose", "RaidTimeline", fmt, ...)
end
function RT:ProbeBossAbilityEntry(entryIndex, entry)
-- Bossmod-specific matching lives in separate integration files.
end
function RT:GetNextBossAbilityCount(bossAbilityId, explicitCount)
local numericCount = tonumber(explicitCount)
if numericCount and numericCount > 0 then
return math.floor(numericCount + 0.5)
end
self.activeBossAbilityCounts = self.activeBossAbilityCounts or {}
local key = tostring(bossAbilityId or "")
self.activeBossAbilityCounts[key] = math.max(0, tonumber(self.activeBossAbilityCounts[key]) or 0) + 1
return self.activeBossAbilityCounts[key]
end
function RT:GetWhisperTargetForPlayer(playerName)
local unitId = HMGT.GetUnitForPlayer and HMGT:GetUnitForPlayer(playerName) or nil
if unitId and UnitExists(unitId) then
local fullName = GetUnitName(unitId, true)
if fullName and fullName ~= "" then
return fullName
end
end
return playerName
end
function RT:SelectAssignedPlayer(encounterId, entry, entryIndex)
local spellId = tonumber(entry and entry.spellId) or 0
local desired = NormalizeName(entry and entry.playerName)
local targetSpec = TrimText(entry and (entry.targetSpec or entry.playerName) or "")
if spellId <= 0 then
return nil
end
local candidates = self:GetReadyCandidates(spellId)
local targetPlayers = self:GetResolvedTargetPlayers(targetSpec)
local isSingleExactTarget = desired and desired ~= "" and targetSpec ~= "" and not targetSpec:find(",") and #targetPlayers == 1 and targetPlayers[1] == desired
if #candidates == 0 then
HMGT:DebugScoped(
"verbose",
"RaidTimeline",
"RaidTimeline select encounter=%s slot=%s spellId=%s desired=%s target=%s candidates=0 selected=nil",
tostring(encounterId),
tostring(entryIndex),
tostring(spellId),
tostring(desired or ""),
tostring(targetSpec)
)
return nil
end
if isSingleExactTarget then
for i = 1, #candidates do
if candidates[i] == desired then
HMGT:DebugScoped(
"verbose",
"RaidTimeline",
"RaidTimeline select encounter=%s slot=%s spellId=%s desired=%s target=%s candidates=%s selected=%s mode=target-exact",
tostring(encounterId),
tostring(entryIndex),
tostring(spellId),
tostring(desired),
tostring(targetSpec),
table.concat(candidates, ", "),
tostring(candidates[i])
)
return candidates[i]
end
end
for i = 1, #candidates do
if candidates[i] > desired then
HMGT:DebugScoped(
"verbose",
"RaidTimeline",
"RaidTimeline select encounter=%s slot=%s spellId=%s desired=%s target=%s candidates=%s selected=%s mode=target-fallback-next",
tostring(encounterId),
tostring(entryIndex),
tostring(spellId),
tostring(desired),
tostring(targetSpec),
table.concat(candidates, ", "),
tostring(candidates[i])
)
return candidates[i]
end
end
HMGT:DebugScoped(
"verbose",
"RaidTimeline",
"RaidTimeline select encounter=%s slot=%s spellId=%s desired=%s target=%s candidates=%s selected=%s mode=target-fallback-wrap",
tostring(encounterId),
tostring(entryIndex),
tostring(spellId),
tostring(desired),
tostring(targetSpec),
table.concat(candidates, ", "),
tostring(candidates[1])
)
return candidates[1]
end
if #targetPlayers > 0 then
local allowed = {}
local filtered = {}
for _, playerName in ipairs(targetPlayers) do
allowed[playerName] = true
end
for _, playerName in ipairs(candidates) do
if allowed[playerName] then
filtered[#filtered + 1] = playerName
end
end
if #filtered > 0 then
local hash = StableHash(encounterId, spellId, targetSpec, tonumber(entry.time) or 0, tonumber(entryIndex) or 0)
local selected = filtered[(hash % #filtered) + 1]
HMGT:DebugScoped(
"verbose",
"RaidTimeline",
"RaidTimeline select encounter=%s slot=%s spellId=%s target=%s candidates=%s filtered=%s hash=%s selected=%s mode=target-filter",
tostring(encounterId),
tostring(entryIndex),
tostring(spellId),
tostring(targetSpec),
table.concat(candidates, ", "),
table.concat(filtered, ", "),
tostring(hash),
tostring(selected)
)
return selected
end
HMGT:DebugScoped(
"verbose",
"RaidTimeline",
"RaidTimeline select encounter=%s slot=%s spellId=%s target=%s candidates=%s filtered=0 selected=nil mode=target-filter-empty",
tostring(encounterId),
tostring(entryIndex),
tostring(spellId),
tostring(targetSpec),
table.concat(candidates, ", ")
)
return nil
end
local hash = StableHash(encounterId, spellId, tonumber(entry.time) or 0, tonumber(entryIndex) or 0)
local selected = candidates[(hash % #candidates) + 1]
HMGT:DebugScoped(
"verbose",
"RaidTimeline",
"RaidTimeline select encounter=%s slot=%s spellId=%s target=%s candidates=%s hash=%s selected=%s mode=hash",
tostring(encounterId),
tostring(entryIndex),
tostring(spellId),
tostring(targetSpec),
table.concat(candidates, ", "),
tostring(hash),
tostring(selected)
)
return selected
end
function RT:GetAssignedPlayersForEntry(encounterId, entry, entryIndex)
local actionType = self:GetActionType(entry)
if actionType == "text" then
local targetSpec = TrimText(entry and (entry.targetSpec or entry.playerName) or "")
if targetSpec == "" then
return self:GetRosterPlayers()
end
return self:GetResolvedTargetPlayers(targetSpec)
elseif self:GetTriggerType(entry) == "bossAbility" and actionType == "raidCooldown" then
local selected = self:SelectAssignedPlayer(encounterId, entry, entryIndex)
return selected and { selected } or {}
elseif self:GetTriggerType(entry) == "bossAbility" then
local targetSpec = TrimText(entry and (entry.targetSpec or entry.playerName) or "")
if targetSpec == "" then
return self:GetRosterPlayers()
end
return self:GetResolvedTargetPlayers(targetSpec)
end
local selected = self:SelectAssignedPlayer(encounterId, entry, entryIndex)
return selected and { selected } or {}
end
function RT:StoreAssignment(encounterId, timeSec, spellId, leadTime, alertText)
local encounterKey = tonumber(encounterId)
local timeValue = tonumber(timeSec)
local spellValue = math.max(0, tonumber(spellId) or 0)
local leadValue = tonumber(leadTime)
local textValue = tostring(alertText or "")
if not encounterKey or not timeValue or not leadValue then
return
end
self.receivedAssignments = self.receivedAssignments or {}
self.receivedAssignments[encounterKey] = self.receivedAssignments[encounterKey] or {}
local assignments = self.receivedAssignments[encounterKey]
local key = string.format("%d:%d:%s", timeValue, spellValue, self:EncodeCommText(textValue))
assignments[key] = {
time = timeValue,
spellId = spellValue,
leadTime = leadValue,
alertText = textValue,
}
end
function RT:DispatchEntryAssignments(encounterId, entry, entryIndex, relativeTime)
local leadTime = tonumber(self:GetSettings().leadTime) or 5
local dispatchTime = math.max(0, tonumber(relativeTime) or tonumber(entry and entry.time) or 0)
local assignedPlayers = self:GetAssignedPlayersForEntry(encounterId, entry, entryIndex)
local alertText = self:GetEntryAlertText(entry) or ""
for _, assignedPlayer in ipairs(assignedPlayers) do
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline assign encounter=%s slot=%s time=%s trigger=%s action=%s spellId=%s text=%s player=%s target=%s",
tostring(encounterId),
tostring(entryIndex),
tostring(dispatchTime),
tostring(self:GetTriggerType(entry)),
tostring(self:GetActionType(entry)),
tostring(entry.spellId),
tostring(alertText),
tostring(assignedPlayer),
tostring(entry.targetSpec or entry.playerName or "")
)
if NormalizeName(assignedPlayer) == GetOwnName() then
self:StoreAssignment(encounterId, dispatchTime, entry.spellId, leadTime, alertText)
else
local target = self:GetWhisperTargetForPlayer(assignedPlayer)
if target and target ~= "" then
HMGT:SendDirectMessage(
string.format("%s|%d|%d|%d|%d|%s", GetTimelineMessagePrefix(), tonumber(encounterId) or 0, dispatchTime, math.max(0, tonumber(entry.spellId) or 0), leadTime, self:EncodeCommText(alertText)),
target,
"ALERT"
)
end
end
end
end
function RT:DispatchAssignmentsForEncounter(encounterId)
if not self:IsLocalEditor() then
return
end
local schedule = self:GetEncounter(encounterId)
if not schedule or type(schedule.entries) ~= "table" then
return
end
for index, entry in ipairs(schedule.entries) do
if self:GetTriggerType(entry) == "bossAbility" then
self:DispatchEntryAssignments(encounterId, entry, index, entry.time)
end
end
end
function RT:TryDispatchBossAbilityEntry(entryIndex, entry, castCount, secondsUntilCast)
if not self:IsLocalEditor() or not self.activeEncounterId or self:GetTriggerType(entry) ~= "bossAbility" then
return
end
local currentCount = math.max(1, tonumber(castCount) or 1)
local desiredSelector = self:NormalizeCastCountSelector(entry and entry.castCount)
if not self:DoesCastCountSelectorMatch(desiredSelector, currentCount) then
return
end
local warningLeadTime = tonumber(self:GetSettings().leadTime) or 5
local secondsRemaining = math.max(0, tonumber(secondsUntilCast) or 0)
if secondsRemaining > warningLeadTime then
self:LogBossAbilityProbe(
entryIndex,
entry,
string.format("armed:%s:%d", tostring(desiredSelector), tonumber(currentCount) or 1),
"RaidTimeline boss ability armed encounter=%s slot=%s selector=%s cast=%s remaining=%.1f lead=%.1f",
tostring(self.activeEncounterId),
tostring(entryIndex),
tostring(self:FormatCastCountSelector(desiredSelector)),
tostring(currentCount),
tonumber(secondsRemaining) or 0,
tonumber(warningLeadTime) or 0
)
return
end
self.activeBossAbilityDispatches = self.activeBossAbilityDispatches or {}
local dispatchKey = self:GetBossAbilityDispatchKey(entryIndex, currentCount)
if self.activeBossAbilityDispatches[dispatchKey] then
return
end
self.activeBossAbilityDispatches[dispatchKey] = true
local elapsed = math.max(0, GetTime() - (tonumber(self.activeEncounterStartTime) or GetTime()))
local dispatchTime = elapsed + secondsRemaining
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline boss ability match encounter=%s slot=%s ability=%s selector=%s cast=%s triggerIn=%.1f lead=%.1f",
tostring(self.activeEncounterId),
tostring(entryIndex),
tostring(entry.bossAbilityId),
tostring(self:FormatCastCountSelector(desiredSelector)),
tostring(currentCount),
tonumber(secondsRemaining) or 0,
tonumber(warningLeadTime) or 0
)
self:DispatchEntryAssignments(self.activeEncounterId, entry, entryIndex, dispatchTime)
end
function RT:HandleAssignmentComm(senderName, encounterId, timeSec, spellId, leadTime, alertText)
if not self:IsAuthorizedSender(senderName) then
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline ignored assignment sender=%s encounter=%s time=%s spellId=%s",
tostring(senderName),
tostring(encounterId),
tostring(timeSec),
tostring(spellId)
)
return
end
local decodedText = self:DecodeCommText(alertText)
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline received assignment sender=%s encounter=%s time=%s spellId=%s lead=%s text=%s",
tostring(senderName),
tostring(encounterId),
tostring(timeSec),
tostring(spellId),
tostring(leadTime),
tostring(decodedText)
)
self:StoreAssignment(encounterId, timeSec, spellId, leadTime, decodedText)
end
function RT:HandleTestStartComm(senderName, encounterId, difficultyId, serverStartTime, duration)
if not self:IsAuthorizedSender(senderName) then
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline ignored test sender=%s encounter=%s",
tostring(senderName),
tostring(encounterId)
)
return
end
local id = tonumber(encounterId)
if not id or id <= 0 then
return
end
self.testActive = true
self.testEncounterId = id
self.testDuration = math.max(0, tonumber(duration) or 0)
self:StartEncounter(id, difficultyId)
if tonumber(self.activeEncounterId) ~= id then
self.testActive = false
self.testEncounterId = nil
self.testDuration = nil
return
end
local serverNow = tonumber(GetServerTime and GetServerTime() or 0) or 0
local startedAt = tonumber(serverStartTime) or serverNow
local elapsed = math.max(0, serverNow - startedAt)
self.activeEncounterStartTime = GetTime() - elapsed
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline remote test start sender=%s encounter=%s difficulty=%s elapsed=%.1f duration=%s",
tostring(senderName),
tostring(id),
tostring(GetDifficultyKey(difficultyId) or difficultyId),
tonumber(elapsed) or 0,
tostring(self.testDuration)
)
end
function RT:StartRuntime()
self.runtimeEnabled = true
if not self.eventFrame then
self.eventFrame = CreateFrame("Frame")
self.eventFrame:SetScript("OnEvent", function(_, event, ...)
RT:HandleEvent(event, ...)
end)
end
self.eventFrame:RegisterEvent("ENCOUNTER_START")
self.eventFrame:RegisterEvent("ENCOUNTER_END")
self.eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD")
self.eventFrame:RegisterEvent("GROUP_ROSTER_UPDATE")
if not self.ticker then
self.ticker = C_Timer.NewTicker(0.1, function()
RT:HandleDueEntries()
end)
end
self:EnsureAlertFrame()
self:ApplyAlertStyle()
self:HandleDueEntries()
end
function RT:StopRuntime()
self.runtimeEnabled = false
if self.eventFrame then
self.eventFrame:UnregisterAllEvents()
end
if self.ticker then
self.ticker:Cancel()
self.ticker = nil
end
self.activeEncounterId = nil
self.activeEncounterStartTime = nil
self.activeSchedule = nil
self.firedEntries = nil
self.currentAlert = nil
self.activeBossAbilityDispatches = nil
self.activeBossAbilityCounts = nil
self.dispatchedEntries = nil
self.testActive = false
self.testEncounterId = nil
self.testDuration = nil
if self.alertFrame then
self.alertFrame:Hide()
end
self:CloseTimelineEditor()
end
function RT:OnInitialize()
HMGT.RaidTimeline = self
end
function RT:OnEnable()
self:StartRuntime()
end
function RT:OnDisable()
self:StopRuntime()
end
function RT:StartEncounter(encounterId, difficultyId)
self.activeEncounterId = tonumber(encounterId)
self.activeEncounterDifficulty = GetDifficultyKey(difficultyId)
self.activeEncounterStartTime = GetTime()
self.activeSchedule = self:GetEncounter(encounterId)
if self.activeSchedule and not self:IsEncounterEnabledForDifficulty(self.activeSchedule, difficultyId) then
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline encounter skipped encounter=%s difficulty=%s",
tostring(self.activeEncounterId),
tostring(difficultyId)
)
self.activeSchedule = nil
self.activeEncounterId = nil
self.activeEncounterDifficulty = nil
self.activeEncounterStartTime = nil
self.firedEntries = nil
return
end
self.firedEntries = {}
self.dispatchedEntries = {}
self.currentAlert = nil
self.activeBossAbilityDispatches = {}
self.activeBossAbilityCounts = {}
self._bossAbilityProbeStates = {}
self.receivedAssignments = self.receivedAssignments or {}
self.receivedAssignments[self.activeEncounterId] = {}
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline encounter start encounter=%s difficulty=%s leader=%s entries=%s",
tostring(self.activeEncounterId),
tostring(self.activeEncounterDifficulty or difficultyId),
tostring(self:IsLocalEditor()),
tostring(self.activeSchedule and #self.activeSchedule.entries or 0)
)
end
function RT:StopEncounter()
self.activeEncounterId = nil
self.activeEncounterDifficulty = nil
self.activeEncounterStartTime = nil
self.activeSchedule = nil
self.firedEntries = nil
self.dispatchedEntries = nil
self.activeBossAbilityDispatches = nil
self.activeBossAbilityCounts = nil
self._bossAbilityProbeStates = nil
self.testActive = false
self.testEncounterId = nil
self.testDuration = nil
self:HideAlert()
end
function RT:GetEncounterTestDuration(encounterId)
local schedule = self:GetEncounter(encounterId)
local maxTime = 0
if schedule and type(schedule.entries) == "table" then
for _, entry in ipairs(schedule.entries) do
if self:GetTriggerType(entry) ~= "bossAbility" then
maxTime = math.max(maxTime, tonumber(entry.time) or 0)
end
end
end
local leadTime = tonumber(self:GetSettings().leadTime) or 5
return math.max(15, maxTime + leadTime + 3)
end
function RT:IsTestRunning(encounterId)
local id = tonumber(encounterId)
return self.testActive == true and id ~= nil and tonumber(self.testEncounterId) == id
end
function RT:StartEncounterTest(encounterId)
local id = tonumber(encounterId)
if not id or id <= 0 then
return false
end
local schedule = self:GetEncounter(id)
if not schedule then
return false
end
if self.activeEncounterId then
self:StopEncounter()
end
local difficultyId = GetPreferredTestDifficultyId(schedule)
self.testActive = true
self.testEncounterId = id
self.testDuration = self:GetEncounterTestDuration(id)
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline test start encounter=%s difficulty=%s duration=%s",
tostring(id),
tostring(GetDifficultyKey(difficultyId) or difficultyId),
tostring(self.testDuration)
)
self:StartEncounter(id, difficultyId)
if tonumber(self.activeEncounterId) ~= id then
self.testActive = false
self.testEncounterId = nil
self.testDuration = nil
return false
end
if IsInGroup() or IsInRaid() then
HMGT:SendGroupMessage(
string.format(
"%s|%d|%d|%d|%d",
GetTimelineTestMessagePrefix(),
id,
tonumber(difficultyId) or 0,
tonumber(GetServerTime and GetServerTime() or 0) or 0,
tonumber(self.testDuration) or 0
),
"ALERT"
)
end
self:Refresh()
return true
end
function RT:StopEncounterTest()
if self.testActive then
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline test stop encounter=%s",
tostring(self.testEncounterId or self.activeEncounterId)
)
end
self:StopEncounter()
end
function RT:HandleDueEntries()
if not self.runtimeEnabled or (self:GetSettings().enabled ~= true and not self.testActive) then
self:HideAlert()
return
end
if self.previewAlertActive == true then
self:ApplyAlertStyle()
return
end
if not self.activeEncounterId or not self.activeEncounterStartTime then
self:UpdateCurrentAlert()
return
end
local assignments = self.receivedAssignments and self.receivedAssignments[self.activeEncounterId]
if type(assignments) ~= "table" then
self:UpdateCurrentAlert()
return
end
local elapsed = GetTime() - self.activeEncounterStartTime
if self:IsLocalEditor() and self.activeSchedule and type(self.activeSchedule.entries) == "table" then
self.dispatchedEntries = self.dispatchedEntries or {}
local assignmentLeadTime = tonumber(self:GetSettings().assignmentLeadTime) or tonumber(self:GetSettings().leadTime) or 5
for index, entry in ipairs(self.activeSchedule.entries) do
if self:GetTriggerType(entry) == "bossAbility" then
self:ProbeBossAbilityEntry(index, entry)
elseif self.dispatchedEntries[index] ~= true then
local assignAt = math.max(0, (tonumber(entry.time) or 0) - assignmentLeadTime)
if elapsed >= assignAt then
self.dispatchedEntries[index] = true
HMGT:DebugScoped(
"verbose",
"RaidTimeline",
"RaidTimeline assignment due encounter=%s slot=%s time=%s assignAt=%.1f elapsed=%.1f",
tostring(self.activeEncounterId),
tostring(index),
tostring(entry.time),
tonumber(assignAt) or 0,
tonumber(elapsed) or 0
)
self:DispatchEntryAssignments(self.activeEncounterId, entry, index, entry.time)
end
end
end
end
for key, assignment in pairs(assignments) do
if self.firedEntries[key] ~= true then
local warnAt = math.max(0, (tonumber(assignment.time) or 0) - (tonumber(assignment.leadTime) or 5))
if elapsed >= warnAt then
self.firedEntries[key] = true
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline firing encounter=%s key=%s time=%s spellId=%s elapsed=%.1f",
tostring(self.activeEncounterId),
tostring(key),
tostring(assignment.time),
tostring(assignment.spellId),
tonumber(elapsed) or 0
)
local countdown = math.max(0, (tonumber(assignment.time) or 0) - elapsed)
self:ShowCountdownAlert(assignment.spellId, countdown, assignment.alertText)
end
end
end
self:UpdateCurrentAlert()
if self.testActive then
local duration = tonumber(self.testDuration) or 0
if duration > 0 and elapsed >= duration and not self.currentAlert then
HMGT:DebugScoped(
"info",
"RaidTimeline",
"RaidTimeline test complete encounter=%s elapsed=%.1f",
tostring(self.activeEncounterId),
tonumber(elapsed) or 0
)
self:StopEncounter()
end
end
end
function RT:Refresh()
self:ApplyAlertStyle()
self:HandleDueEntries()
self:RefreshTimelineEditor()
end
function RT:HandleEvent(event, ...)
if event == "ENCOUNTER_START" then
local encounterId, _, difficultyId = ...
self:StartEncounter(encounterId, difficultyId)
elseif event == "ENCOUNTER_END" or event == "PLAYER_ENTERING_WORLD" then
self:StopEncounter()
elseif event == "GROUP_ROSTER_UPDATE" then
self:HandleDueEntries()
end
end