diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..569ec13 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.git +.gitea +.vscode +*.zip \ No newline at end of file diff --git a/HailMaryGuildTools.lua b/HailMaryGuildTools.lua index a06b20d..6b25c46 100644 --- a/HailMaryGuildTools.lua +++ b/HailMaryGuildTools.lua @@ -223,6 +223,17 @@ local defaults = { announceAtSec = 5, trackedBuffs = {}, }, + personalAuras = { + enabled = false, + unlocked = false, + posX = 0, + posY = 120, + width = 260, + rowHeight = 24, + iconSize = 20, + fontSize = 12, + trackedDebuffs = {}, + }, raidTimeline = { enabled = false, leadTime = 5, @@ -1254,6 +1265,30 @@ local function NormalizeBuffEndingAnnouncerSettings(settings) settings.trackedBuffs = normalized end +local function NormalizePersonalAurasSettings(settings) + if type(settings) ~= "table" then return end + if settings.enabled == nil then settings.enabled = false end + settings.unlocked = settings.unlocked == true + settings.posX = NormalizeLayoutValue(settings.posX, -2000, 2000, 0) + settings.posY = NormalizeLayoutValue(settings.posY, -2000, 2000, 120) + settings.width = math.floor(NormalizeLayoutValue(settings.width, 160, 420, 260) + 0.5) + settings.rowHeight = math.floor(NormalizeLayoutValue(settings.rowHeight, 18, 42, 24) + 0.5) + settings.iconSize = math.floor(NormalizeLayoutValue(settings.iconSize, 14, 32, 20) + 0.5) + settings.fontSize = math.floor(NormalizeLayoutValue(settings.fontSize, 8, 24, 12) + 0.5) + if type(settings.trackedDebuffs) ~= "table" then + settings.trackedDebuffs = {} + return + end + local normalized = {} + for sid, value in pairs(settings.trackedDebuffs) do + local id = tonumber(sid) + if id and id > 0 and value ~= false and value ~= nil then + normalized[id] = true + end + end + settings.trackedDebuffs = normalized +end + local function NormalizeRaidTimelineSettings(settings) if type(settings) ~= "table" then return end if settings.enabled == nil then settings.enabled = false end @@ -1691,6 +1726,8 @@ function HMGT:MigrateProfileSettings() NormalizeMapOverlaySettings(p.mapOverlay) p.buffEndingAnnouncer = p.buffEndingAnnouncer or {} NormalizeBuffEndingAnnouncerSettings(p.buffEndingAnnouncer) + p.personalAuras = p.personalAuras or {} + NormalizePersonalAurasSettings(p.personalAuras) p.raidTimeline = p.raidTimeline or {} NormalizeRaidTimelineSettings(p.raidTimeline) p.notes = p.notes or {} diff --git a/HailMaryGuildTools.toc b/HailMaryGuildTools.toc index 05e2c85..4b18d24 100644 --- a/HailMaryGuildTools.toc +++ b/HailMaryGuildTools.toc @@ -42,8 +42,12 @@ Modules\Tracker\GroupTrackerFrames.lua Modules\Tracker\TrackerOptions.lua # ────── AuraExpiry ─────────────────────────────────────────────────── -Modules\BuffEndingAnnouncer\BuffEndingAnnouncer.lua -Modules\BuffEndingAnnouncer\BuffEndingAnnouncerOptions.lua +Modules\AuraExpiry\AuraExpiry.lua +Modules\AuraExpiry\AuraExpiryOptions.lua + +# ────── PersonalAuras ──────────────────────────────────────────────── +Modules\PersonalAuras\PersonalAuras.lua +Modules\PersonalAuras\PersonalAurasOptions.lua # ────── MapOverlay ─────────────────────────────────────────────────── Modules\MapOverlay\MapOverlayIconConfig.lua diff --git a/HailMaryGuildToolsOptions.lua b/HailMaryGuildToolsOptions.lua index 8bc2d50..73ee6fc 100644 --- a/HailMaryGuildToolsOptions.lua +++ b/HailMaryGuildToolsOptions.lua @@ -1982,6 +1982,15 @@ function HMGT_Config:Initialize() modulesGroup.args.buffEnding = buffEndingGroup end + local personalAurasGroup = BuildNamedModuleGroup( + "personalAuras", + L["OPT_MODULE_PERSONAL_AURAS"] or "Personal Auras", + 25 + ) + if personalAurasGroup then + modulesGroup.args.personalAuras = personalAurasGroup + end + local mapOverlayGroup = BuildNamedModuleGroup( "map.overlay", L["OPT_MODULE_MAP_OVERLAY"] or "Map Overlay", diff --git a/Locales/deDE.lua b/Locales/deDE.lua index 81c3618..733e050 100644 --- a/Locales/deDE.lua +++ b/Locales/deDE.lua @@ -116,6 +116,7 @@ L["OPT_MAP_POI_REMOVE_INDEX"] = "Index entfernen" L["OPT_MAP_POI_REMOVE"] = "POI entfernen" L["OPT_MAP_POI_LIST"] = "Aktuelle POIs" L["OPT_MAP_POI_EMPTY"] = "Keine POIs konfiguriert." +L["OPT_MAP_POI_SELECT_HINT"] = "Waehle links im Baum einen POI aus, um ihn zu bearbeiten." L["OPT_MAP_POI_CURRENT_SET"] = "HMGT: aktuelle Position uebernommen" L["OPT_MAP_POI_CURRENT_FAILED"] = "HMGT: aktuelle Position konnte nicht ermittelt werden" L["OPT_MAP_POI_ADDED"] = "HMGT: POI hinzugefuegt" @@ -131,6 +132,7 @@ L["OPT_MODULES"] = "Modules" L["OPT_MODULE_TRACKER"] = "Tracker" L["OPT_MODULE_BUFF_ENDING"] = "Aura-Ablauf" L["OPT_MODULE_AURA_EXPIRY"] = "Aura-Ablauf" +L["OPT_MODULE_PERSONAL_AURAS"] = "Persoenliche Auren" L["OPT_MODULE_MAP_OVERLAY"] = "Map Overlay" -- ── Options: tracker shared ─────────────────────────────────── @@ -344,6 +346,7 @@ L["RCD_NAME"] = "Raid Cooldown Tracker" L["GCD_NAME"] = "Gruppen-Cooldown-Tracker" L["BEA_NAME"] = "Aura-Ablauf" L["AE_NAME"] = "Aura-Ablauf" +L["PA_NAME"] = "Persoenliche Auren" L["AEM_NAME"] = "Auto-Gegner-Markierung" L["OPT_BEA_ENABLED"] = "Aura-Ablauf aktivieren" @@ -371,6 +374,27 @@ L["OPT_BEA_MSG_REMOVED"] = "HMGT: Buff entfernt: %s" L["OPT_BEA_MSG_INVALID"] = "HMGT: ungueltige Buff-Spell-ID" L["OPT_BEA_MSG_NOT_FOUND"] = "HMGT: Buff nicht gefunden" L["OPT_BEA_MSG_THRESHOLD_INVALID"] = "HMGT: ungueltiger Threshold" +L["OPT_PA_ENABLED"] = "Persoenliche Auren aktivieren" +L["OPT_PA_ENABLED_DESC"] = "Zeigt verfolgte Debuffs auf deinem aktuellen Spieler in einem verschiebbaren Frame an" +L["OPT_PA_UNLOCK"] = "Frame entsperren" +L["OPT_PA_UNLOCK_DESC"] = "Zeigt den Platzhalter an und erlaubt das Verschieben des Frames" +L["OPT_PA_WIDTH"] = "Frame-Breite" +L["OPT_PA_ROW_HEIGHT"] = "Zeilenhoehe" +L["OPT_PA_ICON_SIZE"] = "Icon-Groesse" +L["OPT_PA_FONT_SIZE"] = "Schriftgroesse" +L["OPT_PA_SECTION_GENERAL"] = "Allgemein" +L["OPT_PA_SECTION_DEBUFFS"] = "Verfolgte Debuffs" +L["OPT_PA_ADD_ID"] = "Spell-ID hinzufuegen" +L["OPT_PA_ADD"] = "Debuff hinzufuegen" +L["OPT_PA_REMOVE"] = "Debuff entfernen" +L["OPT_PA_EMPTY"] = "Keine Debuffs konfiguriert." +L["OPT_PA_UNLOCK_HINT"] = "Persoenliche Auren\nZum Verschieben ziehen" +L["OPT_PA_MSG_ADDED"] = "HMGT: Debuff hinzugefuegt: %s" +L["OPT_PA_MSG_REMOVED"] = "HMGT: Debuff entfernt: %s" +L["OPT_PA_MSG_INVALID"] = "HMGT: ungueltige Debuff-Spell-ID" +L["OPT_PA_MSG_NOT_FOUND"] = "HMGT: Debuff nicht gefunden" +L["OPT_PA_ACTIVE"] = "Aktiv" +L["OPT_PA_INACTIVE"] = "Inaktiv" L["BEA_MSG_TEMPLATE"] = "%s endet in %d" L["OPT_AEM_ENABLED"] = "Automatische Gegner-Markierung aktivieren" diff --git a/Locales/enUS.lua b/Locales/enUS.lua index a6bc360..d0b59b8 100644 --- a/Locales/enUS.lua +++ b/Locales/enUS.lua @@ -116,6 +116,7 @@ L["OPT_MAP_POI_REMOVE_INDEX"] = "Remove index" L["OPT_MAP_POI_REMOVE"] = "Remove POI" L["OPT_MAP_POI_LIST"] = "Current POIs" L["OPT_MAP_POI_EMPTY"] = "No POIs configured." +L["OPT_MAP_POI_SELECT_HINT"] = "Select a POI in the tree on the left to edit it." L["OPT_MAP_POI_CURRENT_SET"] = "HMGT: current position copied" L["OPT_MAP_POI_CURRENT_FAILED"] = "HMGT: could not determine current position" L["OPT_MAP_POI_ADDED"] = "HMGT: POI added" @@ -131,6 +132,7 @@ L["OPT_MODULES"] = "Modules" L["OPT_MODULE_TRACKER"] = "Tracker" L["OPT_MODULE_BUFF_ENDING"] = "Aura Expiry" L["OPT_MODULE_AURA_EXPIRY"] = "Aura Expiry" +L["OPT_MODULE_PERSONAL_AURAS"] = "Personal Auras" L["OPT_MODULE_MAP_OVERLAY"] = "Map Overlay" -- ── Options: tracker shared ─────────────────────────────────── @@ -344,6 +346,7 @@ L["RCD_NAME"] = "Raid Cooldown Tracker" L["GCD_NAME"] = "Group Cooldown Tracker" L["BEA_NAME"] = "Aura Expiry" L["AE_NAME"] = "Aura Expiry" +L["PA_NAME"] = "Personal Auras" L["AEM_NAME"] = "Auto Enemy Marker" L["OPT_BEA_ENABLED"] = "Enable aura expiry" @@ -371,6 +374,27 @@ L["OPT_BEA_MSG_REMOVED"] = "HMGT: buff removed: %s" L["OPT_BEA_MSG_INVALID"] = "HMGT: invalid buff spell ID" L["OPT_BEA_MSG_NOT_FOUND"] = "HMGT: buff not found" L["OPT_BEA_MSG_THRESHOLD_INVALID"] = "HMGT: invalid threshold" +L["OPT_PA_ENABLED"] = "Enable personal auras" +L["OPT_PA_ENABLED_DESC"] = "Show tracked debuffs on your current player in a movable frame" +L["OPT_PA_UNLOCK"] = "Unlock frame" +L["OPT_PA_UNLOCK_DESC"] = "Show the frame placeholder and allow it to be moved" +L["OPT_PA_WIDTH"] = "Frame width" +L["OPT_PA_ROW_HEIGHT"] = "Row height" +L["OPT_PA_ICON_SIZE"] = "Icon size" +L["OPT_PA_FONT_SIZE"] = "Font size" +L["OPT_PA_SECTION_GENERAL"] = "General" +L["OPT_PA_SECTION_DEBUFFS"] = "Tracked debuffs" +L["OPT_PA_ADD_ID"] = "Add Spell ID" +L["OPT_PA_ADD"] = "Add debuff" +L["OPT_PA_REMOVE"] = "Remove debuff" +L["OPT_PA_EMPTY"] = "No debuffs configured." +L["OPT_PA_UNLOCK_HINT"] = "Personal Auras\nDrag to move" +L["OPT_PA_MSG_ADDED"] = "HMGT: debuff added: %s" +L["OPT_PA_MSG_REMOVED"] = "HMGT: debuff removed: %s" +L["OPT_PA_MSG_INVALID"] = "HMGT: invalid debuff spell ID" +L["OPT_PA_MSG_NOT_FOUND"] = "HMGT: debuff not found" +L["OPT_PA_ACTIVE"] = "Active" +L["OPT_PA_INACTIVE"] = "Inactive" L["BEA_MSG_TEMPLATE"] = "%s ending in %d" L["OPT_AEM_ENABLED"] = "Enable auto enemy marking" diff --git a/Modules/BuffEndingAnnouncer/BuffEndingAnnouncer.lua b/Modules/AuraExpiry/AuraExpiry.lua similarity index 99% rename from Modules/BuffEndingAnnouncer/BuffEndingAnnouncer.lua rename to Modules/AuraExpiry/AuraExpiry.lua index 23530ff..1c8b2b5 100644 --- a/Modules/BuffEndingAnnouncer/BuffEndingAnnouncer.lua +++ b/Modules/AuraExpiry/AuraExpiry.lua @@ -1,4 +1,4 @@ --- Modules/BuffEndingAnnouncer/BuffEndingAnnouncer.lua +-- Modules/AuraExpiry/AuraExpiry.lua -- Announces tracked buffs in SAY shortly before they expire. local ADDON_NAME = "HailMaryGuildTools" diff --git a/Modules/BuffEndingAnnouncer/BuffEndingAnnouncerOptions.lua b/Modules/AuraExpiry/AuraExpiryOptions.lua similarity index 99% rename from Modules/BuffEndingAnnouncer/BuffEndingAnnouncerOptions.lua rename to Modules/AuraExpiry/AuraExpiryOptions.lua index 16fd8e1..8d7239c 100644 --- a/Modules/BuffEndingAnnouncer/BuffEndingAnnouncerOptions.lua +++ b/Modules/AuraExpiry/AuraExpiryOptions.lua @@ -1,4 +1,4 @@ --- Modules/BuffEndingAnnouncer/BuffEndingAnnouncerOptions.lua +-- Modules/AuraExpiry/AuraExpiryOptions.lua local ADDON_NAME = "HailMaryGuildTools" local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) diff --git a/Modules/PersonalAuras/PersonalAuras.lua b/Modules/PersonalAuras/PersonalAuras.lua new file mode 100644 index 0000000..5df4fad --- /dev/null +++ b/Modules/PersonalAuras/PersonalAuras.lua @@ -0,0 +1,612 @@ +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 PA = HMGT:NewModule("PersonalAuras") +HMGT.PersonalAuras = PA + +PA.runtimeEnabled = false +PA.eventFrame = nil +PA.ticker = nil +PA.frame = nil +PA.rows = nil + +local function ClampNumber(value, minValue, maxValue, fallback) + local numericValue = tonumber(value) + if numericValue == nil then + return fallback + end + if numericValue < minValue then + return minValue + end + if numericValue > maxValue then + return maxValue + end + return numericValue +end + +local function GetSpellName(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then return nil end + if C_Spell and type(C_Spell.GetSpellName) == "function" then + local name = C_Spell.GetSpellName(sid) + if type(name) == "string" and name ~= "" then + return name + end + end + if type(GetSpellInfo) == "function" then + local name = GetSpellInfo(sid) + if type(name) == "string" and name ~= "" then + return name + end + end + return nil +end + +local function GetSpellIcon(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then return nil end + if HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then + local icon = HMGT_SpellData.GetSpellIcon(sid) + if icon and icon ~= "" then + return icon + end + end + if C_Spell and type(C_Spell.GetSpellTexture) == "function" then + local icon = C_Spell.GetSpellTexture(sid) + if icon and icon ~= "" then + return icon + end + end + if type(GetSpellInfo) == "function" then + local _, _, icon = GetSpellInfo(sid) + if icon and icon ~= "" then + return icon + end + end + return 136243 +end + +local function NormalizeAuraApplications(auraData) + if type(auraData) ~= "table" then + return 0 + end + local count = tonumber(auraData.applications or auraData.stackCount or auraData.stacks or auraData.charges or auraData.count) + if count ~= nil then + return math.max(0, count) + end + return 0 +end + +local function FormatRemainingTime(secondsRemaining) + local seconds = tonumber(secondsRemaining) + if not seconds or seconds <= 0 then + return "" + end + if seconds >= 60 then + local total = math.floor(seconds + 0.5) + local minutes = math.floor(total / 60) + local rest = total % 60 + return string.format("%d:%02d", minutes, rest) + end + if seconds >= 10 then + return string.format("%ds", math.floor(seconds + 0.5)) + end + return string.format("%.1fs", seconds) +end + +local function NormalizeSettings(settings) + if type(settings) ~= "table" then + settings = {} + end + if settings.enabled == nil then settings.enabled = false end + settings.unlocked = settings.unlocked == true + settings.posX = ClampNumber(settings.posX, -2000, 2000, 0) + settings.posY = ClampNumber(settings.posY, -2000, 2000, 120) + settings.width = math.floor(ClampNumber(settings.width, 160, 420, 260) + 0.5) + settings.rowHeight = math.floor(ClampNumber(settings.rowHeight, 18, 42, 24) + 0.5) + settings.iconSize = math.floor(ClampNumber(settings.iconSize, 14, 32, 20) + 0.5) + settings.fontSize = math.floor(ClampNumber(settings.fontSize, 8, 24, 12) + 0.5) + if type(settings.trackedDebuffs) ~= "table" then + settings.trackedDebuffs = {} + return settings + end + local normalized = {} + for sid, value in pairs(settings.trackedDebuffs) do + local id = tonumber(sid) + if id and id > 0 and value ~= false and value ~= nil then + normalized[id] = true + end + end + settings.trackedDebuffs = normalized + return settings +end + +local function SetFontObject(fontString, size) + if not fontString or type(fontString.SetFont) ~= "function" then + return + end + local fontPath = STANDARD_TEXT_FONT or "Fonts\\FRIZQT__.TTF" + fontString:SetFont(fontPath, tonumber(size) or 12, "OUTLINE") +end + +local function IsAuraDataHarmful(auraData) + if type(auraData) ~= "table" then + return false + end + if auraData.isHarmful ~= nil then + return auraData.isHarmful == true + end + if auraData.isHelpful ~= nil then + return auraData.isHelpful ~= true + end + return true +end + +local function GetPlayerTrackedDebuff(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then + return nil + end + + if C_UnitAuras and type(C_UnitAuras.GetPlayerAuraBySpellID) == "function" then + local auraData = C_UnitAuras.GetPlayerAuraBySpellID(sid) + if auraData and IsAuraDataHarmful(auraData) then + return { + spellId = sid, + name = tostring(auraData.name or GetSpellName(sid) or ("Spell " .. tostring(sid))), + icon = auraData.icon or auraData.iconFileID or GetSpellIcon(sid), + applications = NormalizeAuraApplications(auraData), + duration = tonumber(auraData.duration) or 0, + expirationTime = tonumber(auraData.expirationTime) or 0, + } + end + end + + if AuraUtil and type(AuraUtil.FindAuraBySpellID) == "function" then + local name, icon, applications, _, duration, expirationTime = + AuraUtil.FindAuraBySpellID(sid, "player", "HARMFUL") + if name then + return { + spellId = sid, + name = tostring(name), + icon = icon or GetSpellIcon(sid), + applications = math.max(0, tonumber(applications) or 0), + duration = tonumber(duration) or 0, + expirationTime = tonumber(expirationTime) or 0, + } + end + end + + return nil +end + +function PA:GetSettings() + local profile = HMGT.db and HMGT.db.profile + if not profile then + return nil + end + profile.personalAuras = NormalizeSettings(profile.personalAuras) + return profile.personalAuras +end + +function PA:GetTrackedSpellIds() + local ids = {} + local settings = self:GetSettings() + local tracked = settings and settings.trackedDebuffs or {} + for sid, enabled in pairs(tracked) do + local id = tonumber(sid) + if id and id > 0 and enabled then + ids[#ids + 1] = id + end + end + table.sort(ids, function(a, b) + local nameA = tostring(GetSpellName(a) or a):lower() + local nameB = tostring(GetSpellName(b) or b):lower() + if nameA == nameB then + return a < b + end + return nameA < nameB + end) + return ids +end + +function PA:GetTrackedEntries() + local entries = {} + for _, sid in ipairs(self:GetTrackedSpellIds()) do + entries[#entries + 1] = { + spellId = sid, + name = GetSpellName(sid) or ("Spell " .. tostring(sid)), + icon = GetSpellIcon(sid), + } + end + return entries +end + +function PA:AddTrackedDebuff(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then + return false, "invalid" + end + local name = GetSpellName(sid) + if not name then + return false, "invalid" + end + local settings = self:GetSettings() + settings.trackedDebuffs[sid] = true + self:Refresh() + return true, name +end + +function PA:RemoveTrackedDebuff(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then + return false, "invalid" + end + local settings = self:GetSettings() + if not settings.trackedDebuffs[sid] then + return false, "missing" + end + settings.trackedDebuffs[sid] = nil + self:Refresh() + return true, GetSpellName(sid) or ("Spell " .. tostring(sid)) +end + +function PA:HasTrackedDebuffsConfigured() + return #self:GetTrackedSpellIds() > 0 +end + +function PA:GetActiveDebuffEntries() + local now = GetTime() + local entries = {} + for _, sid in ipairs(self:GetTrackedSpellIds()) do + local auraData = GetPlayerTrackedDebuff(sid) + if auraData then + local expirationTime = tonumber(auraData.expirationTime) or 0 + local remaining = 0 + if expirationTime > 0 then + remaining = math.max(0, expirationTime - now) + end + auraData.remaining = remaining + entries[#entries + 1] = auraData + end + end + table.sort(entries, function(a, b) + local expA = tonumber(a.expirationTime) or 0 + local expB = tonumber(b.expirationTime) or 0 + if expA > 0 and expB > 0 and expA ~= expB then + return expA < expB + end + if expA > 0 and expB <= 0 then + return true + end + if expB > 0 and expA <= 0 then + return false + end + return tostring(a.name or "") < tostring(b.name or "") + end) + return entries +end + +function PA:SaveFramePosition() + local frame = self.frame + local settings = self:GetSettings() + if not frame or not settings then + return + end + local centerX, centerY = frame:GetCenter() + local parentX, parentY = UIParent:GetCenter() + if not centerX or not centerY or not parentX or not parentY then + return + end + settings.posX = math.floor((centerX - parentX) + 0.5) + settings.posY = math.floor((centerY - parentY) + 0.5) +end + +function PA:ApplyFramePosition() + local frame = self.frame + local settings = self:GetSettings() + if not frame or not settings then + return + end + frame:ClearAllPoints() + frame:SetPoint("CENTER", UIParent, "CENTER", tonumber(settings.posX) or 0, tonumber(settings.posY) or 120) +end + +function PA:AcquireRow(index) + self.rows = self.rows or {} + local row = self.rows[index] + if row then + return row + end + + row = CreateFrame("Frame", nil, self.frame) + row:EnableMouse(true) + row.icon = row:CreateTexture(nil, "ARTWORK") + row.name = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + row.timer = row:CreateFontString(nil, "OVERLAY", "GameFontHighlightSmall") + + row.icon:SetPoint("LEFT", row, "LEFT", 8, 0) + row.name:SetPoint("LEFT", row.icon, "RIGHT", 8, 0) + row.name:SetPoint("RIGHT", row.timer, "LEFT", -8, 0) + row.name:SetJustifyH("LEFT") + row.timer:SetPoint("RIGHT", row, "RIGHT", -8, 0) + row.timer:SetJustifyH("RIGHT") + + row:SetScript("OnEnter", function(selfRow) + if not selfRow.spellId or not GameTooltip then + return + end + GameTooltip:SetOwner(selfRow, "ANCHOR_RIGHT") + if type(GameTooltip.SetSpellByID) == "function" then + GameTooltip:SetSpellByID(selfRow.spellId) + else + GameTooltip:SetText(GetSpellName(selfRow.spellId) or ("Spell " .. tostring(selfRow.spellId))) + end + if HMGT.SafeShowTooltip then + HMGT:SafeShowTooltip(GameTooltip) + else + GameTooltip:Show() + end + end) + row:SetScript("OnLeave", function() + if GameTooltip then + GameTooltip:Hide() + end + end) + + self.rows[index] = row + return row +end + +function PA:EnsureFrame() + if self.frame then + return self.frame + end + + local frame = CreateFrame("Frame", "HMGTPersonalAurasFrame", UIParent, BackdropTemplateMixin and "BackdropTemplate" or nil) + frame:SetClampedToScreen(true) + frame:SetMovable(true) + frame:EnableMouse(true) + frame:RegisterForDrag("LeftButton") + frame:SetScript("OnDragStart", function(selfFrame) + local settings = self:GetSettings() + if settings and settings.unlocked then + selfFrame:StartMoving() + end + end) + frame:SetScript("OnDragStop", function(selfFrame) + selfFrame:StopMovingOrSizing() + self:SaveFramePosition() + end) + frame:SetBackdrop({ + bgFile = "Interface\\Tooltips\\UI-Tooltip-Background", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = true, + tileSize = 16, + edgeSize = 12, + insets = { left = 3, right = 3, top = 3, bottom = 3 }, + }) + frame:SetBackdropColor(0.02, 0.02, 0.04, 0.88) + frame:SetBackdropBorderColor(0.35, 0.35, 0.4, 1) + + frame.title = frame:CreateFontString(nil, "OVERLAY", "GameFontHighlight") + frame.title:SetPoint("TOPLEFT", frame, "TOPLEFT", 10, -9) + frame.title:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -10, -9) + frame.title:SetJustifyH("LEFT") + + frame.placeholder = frame:CreateFontString(nil, "OVERLAY", "GameFontDisable") + frame.placeholder:SetPoint("CENTER", frame, "CENTER", 0, -4) + frame.placeholder:SetJustifyH("CENTER") + frame.placeholder:SetJustifyV("MIDDLE") + + self.frame = frame + self.rows = {} + self:ApplyFramePosition() + return frame +end + +function PA:UpdateFrameInteractivity() + local frame = self.frame + local settings = self:GetSettings() + if not frame or not settings then + return + end + frame:EnableMouse(settings.unlocked == true) + if settings.unlocked then + frame:SetBackdropBorderColor(1, 0.82, 0.15, 0.9) + else + frame:SetBackdropBorderColor(0.35, 0.35, 0.4, 1) + end +end + +function PA:RenderFrame(entries) + local settings = self:GetSettings() + local frame = self:EnsureFrame() + entries = entries or {} + + self:ApplyFramePosition() + self:UpdateFrameInteractivity() + + if not settings or settings.enabled ~= true then + frame:Hide() + return + end + + local showFrame = (#entries > 0) or settings.unlocked + if not showFrame then + frame:Hide() + return + end + + local width = tonumber(settings.width) or 260 + local rowHeight = tonumber(settings.rowHeight) or 24 + local iconSize = tonumber(settings.iconSize) or 20 + local fontSize = tonumber(settings.fontSize) or 12 + local headerHeight = 26 + local bottomPadding = 8 + local visibleRows = math.max(#entries, settings.unlocked and 1 or 0) + local height = headerHeight + bottomPadding + (visibleRows * rowHeight) + if height < 72 then + height = 72 + end + + frame:SetSize(width, height) + SetFontObject(frame.title, fontSize + 1) + SetFontObject(frame.placeholder, fontSize) + frame.title:SetText((L["PA_NAME"] or "Personal Auras") .. ((#entries > 0) and (" (" .. tostring(#entries) .. ")") or "")) + + frame.placeholder:SetText(L["OPT_PA_UNLOCK_HINT"] or "Personal Auras\nDrag to move") + frame.placeholder:SetShown(#entries == 0 and settings.unlocked) + + for index, entry in ipairs(entries) do + local row = self:AcquireRow(index) + row:ClearAllPoints() + row:SetPoint("TOPLEFT", frame, "TOPLEFT", 6, -(headerHeight + ((index - 1) * rowHeight))) + row:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -6, -(headerHeight + ((index - 1) * rowHeight))) + row:SetHeight(rowHeight) + row.spellId = tonumber(entry.spellId) or 0 + row.icon:SetSize(iconSize, iconSize) + row.icon:SetTexture(entry.icon or GetSpellIcon(entry.spellId)) + if (tonumber(entry.applications) or 0) > 1 then + row.name:SetText(string.format("%s x%d", tostring(entry.name or ("Spell " .. tostring(entry.spellId))), tonumber(entry.applications) or 0)) + else + row.name:SetText(tostring(entry.name or ("Spell " .. tostring(entry.spellId)))) + end + row.timer:SetText(FormatRemainingTime(entry.remaining)) + SetFontObject(row.name, fontSize) + SetFontObject(row.timer, fontSize) + row:Show() + end + + if self.rows then + for index = #entries + 1, #self.rows do + self.rows[index]:Hide() + end + end + + frame:Show() +end + +function PA:StopTicker() + if self.ticker then + self.ticker:Cancel() + self.ticker = nil + end +end + +function PA:EnsureTicker() + if self.ticker then + return + end + self.ticker = C_Timer.NewTicker(0.1, function() + self:OnTicker() + end) +end + +function PA:UpdateRuntimeEventRegistrations() + if not self.eventFrame then + return + end + + self.eventFrame:UnregisterEvent("PLAYER_ENTERING_WORLD") + self.eventFrame:UnregisterEvent("UNIT_AURA") + + if not self.runtimeEnabled then + return + end + + self.eventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") + local settings = self:GetSettings() + if settings and settings.enabled and self:HasTrackedDebuffsConfigured() then + self.eventFrame:RegisterUnitEvent("UNIT_AURA", "player") + end +end + +function PA:Refresh() + if not self.runtimeEnabled then + return + end + + self:UpdateRuntimeEventRegistrations() + + local settings = self:GetSettings() + if not settings or settings.enabled ~= true then + self:StopTicker() + if self.frame then + self.frame:Hide() + end + return + end + + local entries = self:GetActiveDebuffEntries() + self:RenderFrame(entries) + if #entries > 0 then + self:EnsureTicker() + else + self:StopTicker() + end +end + +function PA:OnTicker() + if not self.runtimeEnabled then + self:StopTicker() + return + end + local entries = self:GetActiveDebuffEntries() + self:RenderFrame(entries) + if #entries == 0 then + self:StopTicker() + end +end + +function PA:OnEvent(event, ...) + if event == "UNIT_AURA" then + local unit = ... + if unit ~= "player" then + return + end + end + self:Refresh() +end + +function PA:StartRuntime() + if self.runtimeEnabled then + self:Refresh() + return + end + self.runtimeEnabled = true + if not self.eventFrame then + self.eventFrame = CreateFrame("Frame") + self.eventFrame:SetScript("OnEvent", function(_, event, ...) + self:OnEvent(event, ...) + end) + end + self:Refresh() +end + +function PA:StopRuntime() + self.runtimeEnabled = false + if self.eventFrame then + self.eventFrame:UnregisterEvent("PLAYER_ENTERING_WORLD") + self.eventFrame:UnregisterEvent("UNIT_AURA") + end + self:StopTicker() + if self.frame then + self.frame:Hide() + end +end + +function PA:OnInitialize() + HMGT.PersonalAuras = self +end + +function PA:OnEnable() + self:StartRuntime() +end + +function PA:OnDisable() + self:StopRuntime() +end diff --git a/Modules/PersonalAuras/PersonalAurasOptions.lua b/Modules/PersonalAuras/PersonalAurasOptions.lua new file mode 100644 index 0000000..3e8e5e9 --- /dev/null +++ b/Modules/PersonalAuras/PersonalAurasOptions.lua @@ -0,0 +1,308 @@ +local ADDON_NAME = "HailMaryGuildTools" +local HMGT = LibStub("AceAddon-3.0"):GetAddon(ADDON_NAME) +if not HMGT then return end + +local PA = HMGT.PersonalAuras +if not PA then return end +if not HMGT_Config or not HMGT_Config.RegisterOptionsProvider then return end + +local L = LibStub("AceLocale-3.0"):GetLocale(ADDON_NAME, true) or {} +local AceConfigRegistry = LibStub("AceConfigRegistry-3.0", true) +local optionsGroup + +local function GetSpellName(spellId) + local sid = tonumber(spellId) + if not sid or sid <= 0 then return nil end + if C_Spell and type(C_Spell.GetSpellName) == "function" then + local name = C_Spell.GetSpellName(sid) + if type(name) == "string" and name ~= "" then + return name + end + end + if type(GetSpellInfo) == "function" then + local name = GetSpellInfo(sid) + if type(name) == "string" and name ~= "" then + return name + end + end + return nil +end + +local function GetSpellIcon(spellId) + if HMGT_SpellData and type(HMGT_SpellData.GetSpellIcon) == "function" then + local icon = HMGT_SpellData.GetSpellIcon(tonumber(spellId) or 0) + if icon and icon ~= "" then + return icon + end + end + if C_Spell and type(C_Spell.GetSpellTexture) == "function" then + local icon = C_Spell.GetSpellTexture(tonumber(spellId) or 0) + if icon and icon ~= "" then + return icon + end + end + return 136243 +end + +local function GetDraft() + HMGT._personalAurasDraft = HMGT._personalAurasDraft or {} + return HMGT._personalAurasDraft +end + +local function NotifyOptionsChanged() + if optionsGroup then + local fresh = PA:BuildOptionsGroup() + if type(fresh) == "table" and type(fresh.args) == "table" then + optionsGroup.args = fresh.args + optionsGroup.childGroups = fresh.childGroups + end + end + if AceConfigRegistry and type(AceConfigRegistry.NotifyChange) == "function" then + AceConfigRegistry:NotifyChange(ADDON_NAME) + end +end + +local function BuildTrackedLeaf(entry) + local spellId = tonumber(entry and entry.spellId) or 0 + return { + type = "group", + name = tostring(entry and entry.name or ("Spell " .. tostring(spellId))), + args = { + header = { + type = "header", + order = 1, + name = function() + local icon = GetSpellIcon(spellId) + local name = tostring(GetSpellName(spellId) or ("Spell " .. tostring(spellId))) + return string.format("|T%s:16:16:0:0|t %s", tostring(icon), name) + end, + }, + details = { + type = "description", + order = 2, + width = "full", + name = function() + local isActive = false + for _, activeEntry in ipairs(PA:GetActiveDebuffEntries()) do + if tonumber(activeEntry.spellId) == spellId then + isActive = true + break + end + end + return string.format( + "|cffffd100Spell ID|r: %d\n|cffffd100%s|r: %s", + spellId, + L["OPT_PA_ACTIVE"] or "Active", + isActive and (YES or "Yes") or (NO or "No") + ) + end, + }, + remove = { + type = "execute", + order = 3, + width = "full", + name = L["OPT_PA_REMOVE"] or "Remove debuff", + func = function() + local ok, info = PA:RemoveTrackedDebuff(spellId) + if ok then + HMGT:Print(string.format(L["OPT_PA_MSG_REMOVED"] or "HMGT: debuff removed: %s", tostring(info or spellId))) + else + HMGT:Print(L["OPT_PA_MSG_NOT_FOUND"] or "HMGT: debuff not found") + end + NotifyOptionsChanged() + end, + }, + }, + } +end + +function PA:BuildOptionsGroup() + local draft = GetDraft() + local entries = self:GetTrackedEntries() + local group = { + type = "group", + name = L["PA_NAME"] or "Personal Auras", + order = 4, + childGroups = "tree", + args = { + general = { + type = "group", + order = 1, + name = L["OPT_PA_SECTION_GENERAL"] or "General", + args = { + enabled = { + type = "toggle", + order = 1, + width = "full", + name = L["OPT_PA_ENABLED"] or "Enable personal auras", + desc = L["OPT_PA_ENABLED_DESC"] or "Show tracked debuffs on your current player in a movable frame", + get = function() + return self:GetSettings().enabled == true + end, + set = function(_, val) + self:GetSettings().enabled = val and true or false + self:Refresh() + end, + }, + unlocked = { + type = "toggle", + order = 2, + width = "full", + name = L["OPT_PA_UNLOCK"] or "Unlock frame", + desc = L["OPT_PA_UNLOCK_DESC"] or "Show the frame placeholder and allow it to be moved", + get = function() + return self:GetSettings().unlocked == true + end, + set = function(_, val) + self:GetSettings().unlocked = val and true or false + self:Refresh() + end, + }, + width = { + type = "range", + order = 3, + min = 160, + max = 420, + step = 1, + width = "full", + name = L["OPT_PA_WIDTH"] or "Frame width", + get = function() + return tonumber(self:GetSettings().width) or 260 + end, + set = function(_, val) + self:GetSettings().width = math.floor((tonumber(val) or 260) + 0.5) + self:Refresh() + end, + }, + rowHeight = { + type = "range", + order = 4, + min = 18, + max = 42, + step = 1, + width = "full", + name = L["OPT_PA_ROW_HEIGHT"] or "Row height", + get = function() + return tonumber(self:GetSettings().rowHeight) or 24 + end, + set = function(_, val) + self:GetSettings().rowHeight = math.floor((tonumber(val) or 24) + 0.5) + self:Refresh() + end, + }, + iconSize = { + type = "range", + order = 5, + min = 14, + max = 32, + step = 1, + width = "full", + name = L["OPT_PA_ICON_SIZE"] or "Icon size", + get = function() + return tonumber(self:GetSettings().iconSize) or 20 + end, + set = function(_, val) + self:GetSettings().iconSize = math.floor((tonumber(val) or 20) + 0.5) + self:Refresh() + end, + }, + fontSize = { + type = "range", + order = 6, + min = 8, + max = 24, + step = 1, + width = "full", + name = L["OPT_PA_FONT_SIZE"] or "Font size", + get = function() + return tonumber(self:GetSettings().fontSize) or 12 + end, + set = function(_, val) + self:GetSettings().fontSize = math.floor((tonumber(val) or 12) + 0.5) + self:Refresh() + end, + }, + }, + }, + tracked = { + type = "group", + order = 2, + name = string.format( + "%s (%d)", + L["OPT_PA_SECTION_DEBUFFS"] or "Tracked debuffs", + #entries + ), + args = { + addSpellId = { + type = "input", + order = 1, + width = "full", + name = L["OPT_PA_ADD_ID"] or "Add Spell ID", + get = function() + return tostring(draft.spellId or "") + end, + set = function(_, value) + draft.spellId = tostring(value or "") + end, + }, + add = { + type = "execute", + order = 2, + width = "full", + name = L["OPT_PA_ADD"] or "Add debuff", + func = function() + local sid = tonumber(draft.spellId) + local ok, info = self:AddTrackedDebuff(sid) + if ok then + HMGT:Print(string.format(L["OPT_PA_MSG_ADDED"] or "HMGT: debuff added: %s", tostring(info or sid))) + draft.spellId = "" + NotifyOptionsChanged() + else + HMGT:Print(L["OPT_PA_MSG_INVALID"] or "HMGT: invalid debuff spell ID") + end + end, + }, + }, + }, + }, + } + + if #entries == 0 then + group.args.tracked.args.empty = { + type = "description", + order = 10, + width = "full", + name = L["OPT_PA_EMPTY"] or "No debuffs configured.", + } + else + for index, entry in ipairs(entries) do + local spellId = tonumber(entry.spellId) or index + group.args.tracked.args["debuff_" .. tostring(spellId)] = { + type = "group", + order = 10 + index, + name = function() + return string.format("|T%s:16:16:0:0|t %s", tostring(entry.icon or GetSpellIcon(spellId)), tostring(entry.name or GetSpellName(spellId) or ("Spell " .. tostring(spellId)))) + end, + inline = true, + args = BuildTrackedLeaf(entry).args, + } + end + end + + return group +end + +function PA:GetOptionsGroup() + if not optionsGroup then + optionsGroup = self:BuildOptionsGroup() + end + return optionsGroup +end + +HMGT_Config:RegisterOptionsProvider("personalAuras", function() + return { + path = "personalAuras", + order = 4, + group = PA:GetOptionsGroup(), + } +end) diff --git a/readme.md b/readme.md index cc5fe65..b3cdaca 100644 --- a/readme.md +++ b/readme.md @@ -15,6 +15,7 @@ It combines cooldown tracking, encounter reminders, notes, and map utilities in - Interrupt, raid cooldown, and group cooldown tracking - Per-tracker bar and icon layouts - Aura Expiry for selected buffs and channels +- Personal Auras for tracked debuffs on your player - Raid Timeline for encounter-based text reminders and raid cooldown assignments - Notes window for raid or personal note management - Map Overlay with custom world map POIs @@ -32,6 +33,10 @@ It supports multiple independent tracker bars with custom spell categories, disp Tracks selected buffs and channel-based spells and warns before they expire. +### Personal Auras + +Shows selected debuffs on your player in a movable frame. + ### Map Overlay Lets you create custom POIs on the world map and assign curated icon presets. @@ -83,8 +88,10 @@ Provides a dedicated notes window for raid notes, personal notes, and drafts. Shared windows and developer tooling - `Modules/Tracker/` Tracker rendering, data, and options -- `Modules/BuffEndingAnnouncer/` +- `Modules/AuraExpiry/` Aura expiry module and options +- `Modules/PersonalAuras/` + Personal debuff watcher module and options - `Modules/MapOverlay/` World map POIs, icon config, and options - `Modules/RaidTimeline/`