From e2cc21726126a16e43cb22139b7eaffc3a06190b Mon Sep 17 00:00:00 2001 From: MistaSweeD Date: Thu, 15 Jan 2026 13:15:49 +0100 Subject: [PATCH 01/15] Implement first raw version of a timeline - via /ns timeline --- NorthernSkyRaidTools.toc | 1 + SlashCommands.lua | 2 + Timeline.lua | 375 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 Timeline.lua diff --git a/NorthernSkyRaidTools.toc b/NorthernSkyRaidTools.toc index b1468f4..c13058c 100755 --- a/NorthernSkyRaidTools.toc +++ b/NorthernSkyRaidTools.toc @@ -16,6 +16,7 @@ Libs\libs.xml NorthernSkyRaidTools.lua Functions.lua Reminders.lua +Timeline.lua Media.lua Comms.lua NickNames.lua diff --git a/SlashCommands.lua b/SlashCommands.lua index eb39cb2..8f104c9 100644 --- a/SlashCommands.lua +++ b/SlashCommands.lua @@ -31,6 +31,8 @@ SlashCmdList["NSUI"] = function(msg) else NSI.NSUI.cooldowns_frame:Show() end + elseif msg == "timeline" then + NSI:ToggleTimelineWindow() else NSI.NSUI:ToggleOptions() end diff --git a/Timeline.lua b/Timeline.lua new file mode 100644 index 0000000..c2b45cf --- /dev/null +++ b/Timeline.lua @@ -0,0 +1,375 @@ +local _, NSI = ... -- Internal namespace + +local DF = _G["DetailsFramework"] +local expressway = [[Interface\AddOns\NorthernSkyRaidTools\Media\Fonts\Expressway.TTF]] + +-- Get timeline data from a reminder set +-- Returns data in DetailsFramework timeline format +function NSI:GetTimelineData(reminderName, personal) + local source = personal and NSRT.PersonalReminders or NSRT.Reminders + local reminderStr = source[reminderName] + if not reminderStr then return nil end + + -- Data structure: playerReminders[playerName][spellKey] = {entries} + -- where spellKey is spellID or text (if no spellID) + local playerReminders = {} + local maxTime = 0 + + for line in reminderStr:gmatch('[^\r\n]+') do + local tag = line:match("tag:([^;]+)") + local time = line:match("time:(%d*%.?%d+)") + local spellID = line:match("spellid:(%d+)") + local dur = line:match("dur:(%d+)") or "8" + local text = line:match("text:([^;]+)") + local phase = line:match("ph:(%d+)") or "1" + + if tag and time then + time = tonumber(time) + dur = tonumber(dur) + phase = tonumber(phase) + spellID = spellID and tonumber(spellID) + + -- Track max time for timeline length + if time + dur > maxTime then + maxTime = time + dur + end + + -- Determine the key for this ability + -- For spells: use spellID so each spell gets its own lane + -- For text-only: use "text" so all text reminders for a player are on one lane + local abilityKey = spellID and tostring(spellID) or "text" + + -- Parse player names from tag + for player in tag:gmatch("([%w%-]+)") do + local lowerPlayer = strlower(player) + + -- Convert "everyone" and "all" to a unified "Everyone" lane + if lowerPlayer == "everyone" or lowerPlayer == "all" then + player = "Everyone" + -- Skip role/group tags + elseif lowerPlayer == "healer" or + lowerPlayer == "tank" or + lowerPlayer == "dps" or + lowerPlayer == "melee" or + lowerPlayer == "ranged" or + lowerPlayer:match("^group%d+$") or + lowerPlayer:match("^%d+$") then -- skip spec IDs + player = nil + end + + if player then + playerReminders[player] = playerReminders[player] or {} + playerReminders[player][abilityKey] = playerReminders[player][abilityKey] or { + spellID = spellID, + text = text, + entries = {} + } + + table.insert(playerReminders[player][abilityKey].entries, { + time = time, + dur = dur, + phase = phase, + text = text, -- store text per entry for tooltips + }) + end + end + end + end + + -- Convert to timeline format + local lines = {} + + -- First, get sorted list of players (Everyone first, then alphabetical) + local sortedPlayers = {} + for player in pairs(playerReminders) do + table.insert(sortedPlayers, player) + end + table.sort(sortedPlayers, function(a, b) + if a == "Everyone" then return true end + if b == "Everyone" then return false end + return a < b + end) + + -- For each player, add all their abilities as separate lines + for _, player in ipairs(sortedPlayers) do + local abilities = playerReminders[player] + + -- Sort abilities by spellID (numeric) or text (alphabetic) + local sortedAbilities = {} + for abilityKey, data in pairs(abilities) do + table.insert(sortedAbilities, {key = abilityKey, data = data}) + end + table.sort(sortedAbilities, function(a, b) + -- Numeric keys (spellIDs) come before text keys + local aNum = tonumber(a.key) + local bNum = tonumber(b.key) + if aNum and bNum then + return aNum < bNum + elseif aNum then + return true + elseif bNum then + return false + else + return a.key < b.key + end + end) + + -- Create a line for each ability + for _, ability in ipairs(sortedAbilities) do + local abilityData = ability.data + local spellID = abilityData.spellID + local timeline = {} + + -- Sort entries by time + table.sort(abilityData.entries, function(a, b) return a.time < b.time end) + + -- Create timeline blocks + for _, entry in ipairs(abilityData.entries) do + -- Format: {time, length, isAura, auraDuration, blockSpellId} + table.insert(timeline, { + entry.time, -- [1] time in seconds + 0, -- [2] length (0 for icon-based display) + true, -- [3] isAura (shows duration bar) + entry.dur, -- [4] auraDuration + spellID, -- [5] blockSpellId + payload = {phase = entry.phase, text = entry.text}, -- use entry-specific text + }) + end + + -- Get display info + local lineIcon = nil + local lineName = "" + local lineSpellId = spellID + + if spellID then + local spellInfo = C_Spell.GetSpellInfo(spellID) + if spellInfo then + lineIcon = spellInfo.iconID + lineName = spellInfo.name or "" + end + else + -- Text-only reminders: label the lane as "Notes" + lineName = "Notes" + lineIcon = "Interface\\ICONS\\INV_Misc_Note_01" + end + + -- Get shortened player name with color + local shortPlayer = NSAPI:Shorten(player, 12, false, "GlobalNickNames") or player + + table.insert(lines, { + spellId = lineSpellId, + icon = lineIcon or "Interface\\ICONS\\INV_Misc_QuestionMark", + text = shortPlayer .. " - " .. lineName, + timeline = timeline, + }) + end + end + + -- Round up max time to nearest 30 seconds, minimum 60 seconds + local timelineLength = math.max(60, math.ceil(maxTime / 30) * 30) + + return { + length = timelineLength, + defaultColor = {1, 1, 1, 1}, + useIconOnBlocks = true, + lines = lines, + } +end + +-- Create the timeline window +function NSI:CreateTimelineWindow() + local window_width = 1100 + local window_height = 550 + + local timelineWindow = DF:CreateSimplePanel(UIParent, window_width, window_height, + "|cFF00FFFFNorthern Sky|r Timeline", "NSUITimelineWindow", { + DontRightClickClose = true, + UseStatusBar = false, + }) + timelineWindow:SetPoint("CENTER") + timelineWindow:SetFrameStrata("HIGH") + timelineWindow:EnableMouse(true) + timelineWindow:SetMovable(true) + timelineWindow:RegisterForDrag("LeftButton") + timelineWindow:SetScript("OnDragStart", timelineWindow.StartMoving) + timelineWindow:SetScript("OnDragStop", timelineWindow.StopMovingOrSizing) + + local options_dropdown_template = DF:GetTemplate("dropdown", "OPTIONS_DROPDOWN_TEMPLATE") + + -- Build dropdown options function + local function BuildReminderDropdownOptions() + local options = {} + + -- Add shared reminders + local sharedList = self:GetAllReminderNames(false) + for _, data in ipairs(sharedList) do + table.insert(options, { + label = data.name, + value = {name = data.name, personal = false}, + onclick = function(_, _, value) + self:RefreshTimeline(value.name, value.personal) + timelineWindow.currentReminder = value + end + }) + end + + -- Add personal reminders with separator + local personalList = self:GetAllReminderNames(true) + if #personalList > 0 then + table.insert(options, { + label = "--- Personal ---", + value = nil, + }) + for _, data in ipairs(personalList) do + table.insert(options, { + label = data.name .. " (Personal)", + value = {name = data.name, personal = true}, + onclick = function(_, _, value) + self:RefreshTimeline(value.name, value.personal) + timelineWindow.currentReminder = value + end + }) + end + end + + return options + end + + -- Reminder selection dropdown + local reminderLabel = DF:CreateLabel(timelineWindow, "Reminder Set:", 11, "white") + reminderLabel:SetPoint("TOPLEFT", timelineWindow, "TOPLEFT", 10, -30) + + local reminderDropdown = DF:CreateDropDown(timelineWindow, BuildReminderDropdownOptions, nil, 300) + reminderDropdown:SetTemplate(options_dropdown_template) + reminderDropdown:SetPoint("LEFT", reminderLabel, "RIGHT", 10, 0) + timelineWindow.reminderDropdown = reminderDropdown + + -- No data label (shown when no reminders) + local noDataLabel = DF:CreateLabel(timelineWindow, "No reminder set loaded. Select a reminder from the dropdown above.", 14, "gray") + noDataLabel:SetPoint("CENTER", timelineWindow, "CENTER", 0, 0) + timelineWindow.noDataLabel = noDataLabel + noDataLabel:Hide() + + -- Create timeline component + local timelineOptions = { + width = window_width - 40, + height = window_height - 90, + header_width = 180, + header_detached = false, + line_height = 22, + line_padding = 1, + pixels_per_second = 15, + scale_min = 0.1, + scale_max = 2.0, + show_elapsed_timeline = true, + elapsed_timeline_height = 20, + can_resize = false, + use_perpixel_buttons = false, + backdrop = {edgeFile = [[Interface\Buttons\WHITE8X8]], edgeSize = 1, bgFile = [[Interface\Tooltips\UI-Tooltip-Background]], tileSize = 64, tile = true}, + backdrop_color = {0.1, 0.1, 0.1, 0.8}, + backdrop_color_highlight = {0.2, 0.2, 0.3, 0.9}, + backdrop_border_color = {0.1, 0.1, 0.1, 0.3}, + + -- Block hover tooltip + block_on_enter = function(block) + if block.info and block.info.time then + GameTooltip:SetOwner(block, "ANCHOR_RIGHT") + local minutes = math.floor(block.info.time / 60) + local seconds = math.floor(block.info.time % 60) + local timeStr = string.format("%d:%02d", minutes, seconds) + + local spellName = "" + if block.info.spellId then + local spellInfo = C_Spell.GetSpellInfo(block.info.spellId) + if spellInfo then + spellName = spellInfo.name or "" + end + end + + GameTooltip:AddLine(spellName ~= "" and spellName or "Reminder", 1, 1, 1) + GameTooltip:AddLine("Time: " .. timeStr, 0.7, 0.7, 0.7) + if block.info.duration and block.info.duration > 0 then + GameTooltip:AddLine("Duration: " .. block.info.duration .. "s", 0.7, 0.7, 0.7) + end + if block.blockData and block.blockData.payload and block.blockData.payload.phase then + GameTooltip:AddLine("Phase: " .. block.blockData.payload.phase, 0.7, 0.7, 0.7) + end + if block.blockData and block.blockData.payload and block.blockData.payload.text then + GameTooltip:AddLine("Text: " .. block.blockData.payload.text, 0.5, 0.8, 0.5) + end + GameTooltip:Show() + end + end, + block_on_leave = function(block) + GameTooltip:Hide() + end, + } + + local timelineFrame = DF:CreateTimeLineFrame(timelineWindow, "$parentTimeLine", timelineOptions) + timelineFrame:SetPoint("TOPLEFT", timelineWindow, "TOPLEFT", 10, -60) + timelineWindow.timeline = timelineFrame + + -- Help text + local helpLabel = DF:CreateLabel(timelineWindow, "Scroll: Navigate | Ctrl+Scroll: Zoom | Shift+Scroll: Vertical", 10, "gray") + helpLabel:SetPoint("BOTTOMLEFT", timelineWindow, "BOTTOMLEFT", 10, 8) + + timelineWindow:Hide() + return timelineWindow +end + +-- Toggle the timeline window +function NSI:ToggleTimelineWindow() + if not self.TimelineWindow then + self.TimelineWindow = self:CreateTimelineWindow() + end + + if self.TimelineWindow:IsShown() then + self.TimelineWindow:Hide() + else + self.TimelineWindow:Show() + + -- Load active reminder by default + local activeReminder = NSRT.ActiveReminder + local isPersonal = false + + if not activeReminder or activeReminder == "" then + activeReminder = NSRT.ActivePersonalReminder + isPersonal = true + end + + if activeReminder and activeReminder ~= "" then + self:RefreshTimeline(activeReminder, isPersonal) + self.TimelineWindow.currentReminder = {name = activeReminder, personal = isPersonal} + -- Update dropdown selection + self.TimelineWindow.reminderDropdown:Select({name = activeReminder, personal = isPersonal}) + else + -- Show no data message + self.TimelineWindow.noDataLabel:Show() + self.TimelineWindow.timeline:Hide() + end + end +end + +-- Refresh the timeline with new data +function NSI:RefreshTimeline(reminderName, personal) + if not self.TimelineWindow or not self.TimelineWindow.timeline then return end + + local data = self:GetTimelineData(reminderName, personal) + + if data and data.lines and #data.lines > 0 then + self.TimelineWindow.noDataLabel:Hide() + self.TimelineWindow.timeline:Show() + self.TimelineWindow.timeline:SetData(data) + else + -- Show empty state message + self.TimelineWindow.noDataLabel:SetText("No player-specific reminders found in this reminder set.\n(Only showing named player assignments, not role/group tags)") + self.TimelineWindow.noDataLabel:Show() + -- Still show timeline but with empty data + self.TimelineWindow.timeline:SetData({ + length = 300, + defaultColor = {1, 1, 1, 1}, + useIconOnBlocks = true, + lines = {}, + }) + end +end From 3166cb3f2063867205caf48ee8e5a3e38a20d237 Mon Sep 17 00:00:00 2001 From: MistaSweeD Date: Thu, 15 Jan 2026 16:56:11 +0100 Subject: [PATCH 02/15] add boss abilities and phase markers to timeline --- BossTimelines/Beloren.lua | 25 + BossTimelines/BossTimelines.lua | 147 +++++ BossTimelines/BossTimelines.xml | 13 + BossTimelines/Chimaerus.lua | 49 ++ BossTimelines/FallenKingSalhadaar.lua | 22 + BossTimelines/ImperatorAverzian.lua | 47 ++ BossTimelines/LightblindedVanguard.lua | 28 + BossTimelines/VaelgorEzzorak.lua | 37 ++ BossTimelines/Vorasius.lua | 20 + NorthernSkyRaidTools.toc | 1 + Timeline.lua | 732 +++++++++++++++++++++++-- 11 files changed, 1077 insertions(+), 44 deletions(-) create mode 100644 BossTimelines/Beloren.lua create mode 100644 BossTimelines/BossTimelines.lua create mode 100644 BossTimelines/BossTimelines.xml create mode 100644 BossTimelines/Chimaerus.lua create mode 100644 BossTimelines/FallenKingSalhadaar.lua create mode 100644 BossTimelines/ImperatorAverzian.lua create mode 100644 BossTimelines/LightblindedVanguard.lua create mode 100644 BossTimelines/VaelgorEzzorak.lua create mode 100644 BossTimelines/Vorasius.lua diff --git a/BossTimelines/Beloren.lua b/BossTimelines/Beloren.lua new file mode 100644 index 0000000..f2002cb --- /dev/null +++ b/BossTimelines/Beloren.lua @@ -0,0 +1,25 @@ +local _, NSI = ... -- Internal namespace + +-------------------------------------------------------------------------------- +-- BELO'REN (3182) +-- Phoenix fight with ~110s cycles, intermissions at Death Drop +-------------------------------------------------------------------------------- +NSI.BossTimelines[3182] = { + duration = 540, + phases = { + [1] = {name = "Single Phase", start = 0, color = {0.23, 0.51, 0.96}}, + }, + abilities = { + {name = "Voidlight Convergence", spellID = 1242515, category = "damage", phase = 1, times = {1, 82, 192, 302, 412}, duration = 4.0, important = true}, + {name = "Light Dive", spellID = 1241291, category = "movement", phase = 1, times = {20, 101, 211, 321, 431}, duration = 3.0, important = false}, + {name = "Void Edict", spellID = 1261218, category = "soak", phase = 1, times = {21, 107, 137, 217, 247, 327, 357, 437, 467, 487}, duration = 3.0, important = false}, + {name = "Light Edict", spellID = 1261217, category = "soak", phase = 1, times = {26, 102, 132, 152, 212, 242, 262, 322, 352, 372, 432, 462, 482}, duration = 3.0, important = false}, + {name = "Holy Burn", spellID = 1244348, category = "damage", phase = 1, times = {27, 108, 126, 148, 218, 236, 258, 328, 346, 368, 438, 456, 478}, duration = 3.0, important = false}, + {name = "Death Drop", spellID = 1246709, category = "intermission", phase = 1, times = {40, 46, 150, 156, 260, 266, 370, 376}, duration = 3.0, important = true}, + {name = "Incubation of Flames", spellID = 1242792, category = "intermission", phase = 1, times = {47, 157, 267, 377}, duration = 8.0, important = true}, + {name = "Voidlight Edict", spellID = 1241640, category = "soak", phase = 1, times = {72, 112, 142, 222, 252, 332, 362}, duration = 3.0, important = false}, + {name = "Light Quill", spellID = 1241992, category = "damage", phase = 1, times = {122, 232, 342, 452, 472}, duration = 3.0, important = true}, + {name = "Guardian's Edict", spellID = 1260826, category = "soak", phase = 1, times = {128, 237, 347, 458}, duration = 4.0, important = true}, + {name = "Radiant Echoes", spellID = 1242981, category = "damage", phase = 1, times = {158, 268, 378, 442, 488}, duration = 4.0, important = true}, + }, +} diff --git a/BossTimelines/BossTimelines.lua b/BossTimelines/BossTimelines.lua new file mode 100644 index 0000000..e831a13 --- /dev/null +++ b/BossTimelines/BossTimelines.lua @@ -0,0 +1,147 @@ +local _, NSI = ... -- Internal namespace + +--[[ + Boss Timeline Data + + Structure: + NSI.BossTimelines[encounterID] = { + duration = number, -- Total fight duration in seconds + phases = { + [phaseNum] = { + name = string, -- Phase display name + start = number, -- Default phase start time in seconds + color = {r, g, b}, -- RGB color for phase (0-1 range) + }, + }, + abilities = { + { + name = string, -- Ability name + spellID = number, -- WoW spell ID for icon lookup + category = string, -- "damage", "tank", "movement", "soak", "intermission" + phase = number, -- Phase number (1, 2, 3, etc.) + times = {number, ...}, -- Array of cast times (seconds from phase start) + duration = number, -- Ability duration in seconds + important = boolean, -- Whether this is a major CD event + }, + }, + } + + Categories: + - damage: Raid-wide damage requiring healing cooldowns + - tank: Tank-specific mechanics (busters, swaps) + - movement: Positioning/movement mechanics + - soak: Soak mechanics requiring assignments + - intermission: Phase transition abilities +]] + +-- Initialize the BossTimelines table +NSI.BossTimelines = NSI.BossTimelines or {} + +-- Category colors for timeline display +NSI.BossTimelineColors = { + damage = {0.9, 0.3, 0.3}, -- Red + tank = {0.3, 0.5, 0.9}, -- Blue + movement = {0.9, 0.7, 0.2}, -- Yellow/Orange + soak = {0.5, 0.9, 0.5}, -- Green + intermission = {0.7, 0.4, 0.9}, -- Purple +} + +-- Encounter name lookup +NSI.BossTimelineNames = { + [3176] = "Imperator Averzian", + [3177] = "Vorasius", + [3178] = "Vaelgor & Ezzorak", + [3179] = "Fallen King Salhadaar", + [3180] = "Lightblinded Vanguard", + [3181] = "Crown of the Cosmos", + [3182] = "Belo'ren", + [3183] = "Midnight Falls", + [3306] = "Chimaerus", +} + +-------------------------------------------------------------------------------- +-- HELPER FUNCTIONS +-------------------------------------------------------------------------------- + +-- Get user-adjusted phase start time, or default if not set +function NSI:GetPhaseStart(encounterID, phaseNum) + -- Phase 1 always starts at 0 + if phaseNum == 1 then return 0 end + + local timeline = self.BossTimelines[encounterID] + if not timeline or not timeline.phases[phaseNum] then return 0 end + + -- Check for user adjustment + if NSRT.PhaseTimings and NSRT.PhaseTimings[encounterID] and NSRT.PhaseTimings[encounterID][phaseNum] then + return NSRT.PhaseTimings[encounterID][phaseNum] + end + + return timeline.phases[phaseNum].start +end + +-- Set user-adjusted phase start time +function NSI:SetPhaseStart(encounterID, phaseNum, time) + -- Cannot adjust phase 1 + if phaseNum == 1 then return end + + if not NSRT.PhaseTimings then + NSRT.PhaseTimings = {} + end + if not NSRT.PhaseTimings[encounterID] then + NSRT.PhaseTimings[encounterID] = {} + end + + NSRT.PhaseTimings[encounterID][phaseNum] = time +end + +-- Reset phase timing to default +function NSI:ResetPhaseStart(encounterID, phaseNum) + if NSRT.PhaseTimings and NSRT.PhaseTimings[encounterID] then + NSRT.PhaseTimings[encounterID][phaseNum] = nil + end +end + +-- Get all abilities for an encounter with absolute times +function NSI:GetBossTimelineAbilities(encounterID) + local timeline = self.BossTimelines[encounterID] + if not timeline then return nil end + + local result = {} + + for i, ability in ipairs(timeline.abilities) do + local phaseStart = self:GetPhaseStart(encounterID, ability.phase) + local absoluteTimes = {} + + for _, time in ipairs(ability.times) do + table.insert(absoluteTimes, phaseStart + time) + end + + table.insert(result, { + name = ability.name, + spellID = ability.spellID, + category = ability.category, + phase = ability.phase, + times = absoluteTimes, + duration = ability.duration, + important = ability.important, + color = self.BossTimelineColors[ability.category], + }) + end + + -- Build phases with adjusted times + local phases = {} + for phaseNum, phaseData in pairs(timeline.phases) do + phases[phaseNum] = { + name = phaseData.name, + start = self:GetPhaseStart(encounterID, phaseNum), + color = phaseData.color, + } + end + + return result, timeline.duration, phases +end + +-- Get encounter name from ID +function NSI:GetEncounterName(encounterID) + return self.BossTimelineNames[encounterID] or ("Encounter " .. encounterID) +end diff --git a/BossTimelines/BossTimelines.xml b/BossTimelines/BossTimelines.xml new file mode 100644 index 0000000..1a7e59d --- /dev/null +++ b/BossTimelines/BossTimelines.xml @@ -0,0 +1,13 @@ + + +