From c3c84906758557ca2a52deb0f95b367b7d85f1f8 Mon Sep 17 00:00:00 2001 From: wanghw Date: Fri, 7 Aug 2026 12:07:18 +0800 Subject: [PATCH] feat(mythicplus): add the Mythic+ Tools module A Group Finder companion built as one child addon with five independent, self-gating features: a dungeon score overlay drawn onto each Group Finder dungeon icon, one-click teleports on those same icons with an optional party announce, a weekly/season run panel docked to the window, a floating party keystone list, and an in-dungeon keystone owner banner. Every feature registers no events, builds no frames and hooks nothing while it is off. Season data is deliberately not duplicated. The current dungeons come from C_ChallengeMode.GetMapTable() and abbreviations plus teleport spells resolve out of EllesmereUI.SEASON_PORTALS by localized dungeon name, so the module follows a season rollover with no code change. Great Vault reward item levels ask C_MythicPlus.GetRewardLevelForDifficultyLevel first and fall back to a per-season table only when that data has not landed. Announce Teleports defaults OFF: it is the only thing here that speaks to other players, and installing an addon should not start broadcasting to the group. Also records the full SEASON_PORTALS consumer list at its definition, and the constraint that season entries must be replaced rather than appended -- the portal flyouts and the travel tooltip render that list whole. --- .pkgmeta | 1 + EUI_UnlockMode.lua | 4 + EUI__General_Options.lua | 42 ++ EllesmereUI.lua | 22 +- .../EUI_MythicPlus_Keystones.lua | 664 ++++++++++++++++++ .../EUI_MythicPlus_Options.lua | 442 ++++++++++++ .../EUI_MythicPlus_Score.lua | 173 +++++ .../EUI_MythicPlus_Teleport.lua | 296 ++++++++ .../EUI_MythicPlus_Weekly.lua | 304 ++++++++ .../EllesmereUIMythicPlus.lua | 384 ++++++++++ .../EllesmereUIMythicPlus.toc | 22 + EllesmereUI_FirstInstall.lua | 1 + EllesmereUI_GlobalSearch.lua | 1 + EllesmereUI_Profiles.lua | 5 + EllesmereUI_SpecOverrides.lua | 6 + Locales/_keys.txt | 14 +- Locales/zhCN.lua | 54 ++ Locales/zhTW.lua | 56 ++ 18 files changed, 2489 insertions(+), 2 deletions(-) create mode 100644 EllesmereUIMythicPlus/EUI_MythicPlus_Keystones.lua create mode 100644 EllesmereUIMythicPlus/EUI_MythicPlus_Options.lua create mode 100644 EllesmereUIMythicPlus/EUI_MythicPlus_Score.lua create mode 100644 EllesmereUIMythicPlus/EUI_MythicPlus_Teleport.lua create mode 100644 EllesmereUIMythicPlus/EUI_MythicPlus_Weekly.lua create mode 100644 EllesmereUIMythicPlus/EllesmereUIMythicPlus.lua create mode 100644 EllesmereUIMythicPlus/EllesmereUIMythicPlus.toc diff --git a/.pkgmeta b/.pkgmeta index b21614da8..3166b92ed 100644 --- a/.pkgmeta +++ b/.pkgmeta @@ -12,6 +12,7 @@ move-folders: EllesmereUI/EllesmereUIDataBars: EllesmereUIDataBars EllesmereUI/EllesmereUIFriends: EllesmereUIFriends EllesmereUI/EllesmereUIMinimap: EllesmereUIMinimap + EllesmereUI/EllesmereUIMythicPlus: EllesmereUIMythicPlus EllesmereUI/EllesmereUIMythicTimer: EllesmereUIMythicTimer EllesmereUI/EllesmereUINameplates: EllesmereUINameplates EllesmereUI/EllesmereUIQoL: EllesmereUIQoL diff --git a/EUI_UnlockMode.lua b/EUI_UnlockMode.lua index c59ac726a..fcdc4bad4 100644 --- a/EUI_UnlockMode.lua +++ b/EUI_UnlockMode.lua @@ -626,6 +626,10 @@ EllesmereUI._ELEMENT_SETTINGS_MAP = { -- Mythic+ Timer ["EMT_MythicTimer"] = { module = "EllesmereUIMythicTimer", page = "Mythic+ Timer", sectionName = "DISPLAY", highlightText = "Scale" }, + -- Mythic+ keystone displays + ["EMP_KeystoneList"] = { module = "EllesmereUIMythicPlus", page = "Keystones", sectionName = "PARTY KEYSTONES", highlightText = "Show Party Keystones" }, + ["EMP_KeystoneBanner"] = { module = "EllesmereUIMythicPlus", page = "Keystones", sectionName = "KEYSTONE OWNERS", highlightText = "Show Keystone Owners" }, + -- Dragon Riding HUD (Blizz UI Enhanced > Dragon Riding page) ["EDR_Cluster"] = { module = "EllesmereUIBlizzardSkin", page = "Dragon Riding", sectionName = "GENERAL", highlightText = "Enable Dragon Riding Bar" }, diff --git a/EUI__General_Options.lua b/EUI__General_Options.lua index fa523bc75..f063f3ac2 100644 --- a/EUI__General_Options.lua +++ b/EUI__General_Options.lua @@ -519,6 +519,46 @@ end -- EllesmereUI:NavigateToElementSettings(module, page, section, preSelect, highlight). ------------------------------------------------------------------------------- EllesmereUI._WHATSNEW_PATCHES = { + { + version = "8.7.7", + heroes = { + { + -- New module. The hero opens the module's first page rather + -- than a control: nothing here is a single setting, and the + -- master switch is the first row that page builds anyway. + module = "Mythic+ Tools", + title = "New Module", + desc = "A Mythic+ companion for the Group Finder: dungeon score and best key drawn onto each dungeon icon, click-to-teleport on the icons themselves, a docked panel listing this week's and the season's runs, plus floating displays for your party's keystones.", + nav = { module = "EllesmereUIMythicPlus", page = "Dungeon Icons", section = "GENERAL", highlight = "Enable Mythic+" }, + }, + }, + features = { + { + module = "Mythic+ Tools", + title = "Score Overlay", + desc = "Best key level and season score on every dungeon icon", + nav = { module = "EllesmereUIMythicPlus", page = "Dungeon Icons", section = "SCORE OVERLAY", highlight = "Show Score Overlay" }, + }, + { + module = "Mythic+ Tools", + title = "Dungeon Teleports", + desc = "Click a dungeon icon to cast its teleport, with an optional party announce", + nav = { module = "EllesmereUIMythicPlus", page = "Dungeon Icons", section = "TELEPORTS", highlight = "Teleport Buttons" }, + }, + { + module = "Mythic+ Tools", + title = "Weekly Panel", + desc = "This week's runs beside the Group Finder; hold Shift for season best", + nav = { module = "EllesmereUIMythicPlus", page = "Weekly Panel", section = "WEEKLY PANEL", highlight = "Show Weekly Panel" }, + }, + { + module = "Mythic+ Tools", + title = "Party Keystones", + desc = "A movable list of the group's keys, and who holds one for the dungeon you just entered", + nav = { module = "EllesmereUIMythicPlus", page = "Keystones", section = "PARTY KEYSTONES", highlight = "Show Party Keystones" }, + }, + }, + }, { version = "8.7.6", -- No hero tier this patch: features are present, so this is not a @@ -4108,6 +4148,7 @@ initFrame:SetScript("OnEvent", function(self) EllesmereUIBlizzardSkin = "Clean and beautiful visual refreshes for Blizzard UI elements.", EllesmereUIFriends = "A modern friends list with built-in organization tools.", EllesmereUIMythicTimer = "A simple Mythic+ timer with full tracking customizations.", + EllesmereUIMythicPlus = "Dungeon scores, weekly runs, teleports and party keystones.", EllesmereUIQuestTracker = "A clean, updated reskin of Blizzard's Quest Tracker.", EllesmereUIMinimap = "A new age minimap with clean styling and square layout options.", EllesmereUIDamageMeters = "Lightweight damage meters with simple but powerful customization.", @@ -6541,6 +6582,7 @@ initFrame:SetScript("OnEvent", function(self) EllesmereUIBlizzardSkin = "Clean and beautiful visual refreshes for Blizzard UI elements.", EllesmereUIFriends = "A modern friends list with built-in organization tools.", EllesmereUIMythicTimer = "A simple Mythic+ timer with full tracking customizations.", + EllesmereUIMythicPlus = "Dungeon scores, weekly runs, teleports and party keystones.", EllesmereUIQuestTracker = "A clean, updated reskin of Blizzard's Quest Tracker.", EllesmereUIMinimap = "A new age minimap with clean styling and square layout options.", EllesmereUIDamageMeters = "Lightweight damage meters with simple but powerful customization.", diff --git a/EllesmereUI.lua b/EllesmereUI.lua index 056bf0be7..f2bbeb176 100644 --- a/EllesmereUI.lua +++ b/EllesmereUI.lua @@ -293,7 +293,15 @@ local ICONS_PATH = MEDIA_PATH .. "icons\\" ------------------------------------------------------------------------------- -- Season M+ Portals -- single source of truth for every portal/teleport -- list in the suite (Chat sidebar flyout, Minimap flyout, QoL /keys name --- resolver, DataBars travel tooltip). Update ONCE here each season. +-- resolver, QoL LFG teleport prompt, DataBars travel tooltip, Mythic+ Tools +-- abbreviations and teleports). Update ONCE here each season. +-- +-- REPLACE the entries at a season rollover, never append: the two flyouts and +-- the travel tooltip render this list whole, so an early entry for next season +-- shows up as a dead portal button the moment it is committed. Consumers that +-- only look an entry up by dungeon name (QoL, Mythic+ Tools) are unaffected -- +-- they intersect against C_ChallengeMode.GetMapTable() and so follow the +-- season over on their own. -- spellID - primary teleport spell id -- short - abbreviated label used by the flyout buttons -- dungeonID - LFG dungeonID (GetLFGDungeonInfo name lookup) @@ -328,6 +336,7 @@ local ADDON_ROSTER = { { folder = "EllesmereUIBlizzardSkin", display = "Blizz UI Enhanced", search_name = "EllesmereUI Blizz UI Enhanced", syncFolder = "EllesmereUIDragonRiding", syncDisplay = "Dragon Riding" }, { folder = "EllesmereUIFriends", display = "Friends List", search_name = "EllesmereUI Friends List" }, { folder = "EllesmereUIMythicTimer", display = "Mythic+ Timer", search_name = "EllesmereUI Mythic+ Timer" }, + { folder = "EllesmereUIMythicPlus", display = "Mythic+ Tools", search_name = "EllesmereUI Mythic Plus Tools" }, { folder = "EllesmereUIQuestTracker", display = "Quest Tracker", search_name = "EllesmereUI Quest Tracker" }, { folder = "EllesmereUIMinimap", display = "Minimap", search_name = "EllesmereUI Minimap" }, { folder = "EllesmereUIChat", display = "Chat", search_name = "EllesmereUI Chat" }, @@ -377,6 +386,7 @@ EllesmereUI.ADDON_GROUPS = { "EllesmereUIBlizzardSkin", "EllesmereUIDamageMeters", "EllesmereUIMythicTimer", + "EllesmereUIMythicPlus", "EllesmereUIQuestTracker", "EllesmereUIFriends", "EllesmereUIMinimap", @@ -943,6 +953,13 @@ EllesmereUI.RegisterSyncExclusions("EllesmereUIMythicTimer", { "frameWidth", }) +-- The two keystone displays are placed through Unlock Mode, so their positions +-- stay per-profile on a mirror-group push, matching every other module. +EllesmereUI.RegisterSyncExclusions("EllesmereUIMythicPlus", { + "keystoneList.pos", + "centerBanner.pos", +}) + -- Dragon Riding stores its HUD position in its own profile blob (unlockPos). -- Keep each profile's placement when syncing, matching every other module. EllesmereUI.RegisterSyncExclusions("EllesmereUIDragonRiding", { @@ -1209,6 +1226,7 @@ do EllesmereUICooldownManager = true, EllesmereUIResourceBars = true, EllesmereUIMythicTimer = true, + EllesmereUIMythicPlus = true, EllesmereUIRaidFrames = true, } local hasWarning = WARN_MODULES[folder] @@ -4137,6 +4155,7 @@ EllesmereUI._addonKeyToFolder = { chat = "EllesmereUIChat", questTracker = "EllesmereUIQuestTracker", mythicTimer = "EllesmereUIMythicTimer", + mythicPlus = "EllesmereUIMythicPlus", blizzardSkin = "EllesmereUIBlizzardSkin", damageMeters = "EllesmereUIDamageMeters", dataBars = "EllesmereUIDataBars", @@ -10605,6 +10624,7 @@ function EllesmereUI:RegisterModule(folderName, config) EllesmereUIResourceBars = true, EllesmereUIUnitFrames = true, EllesmereUIMythicTimer = true, + EllesmereUIMythicPlus = true, -- v6.6 split EllesmereUIQoL = true, EllesmereUIBlizzardSkin = true, diff --git a/EllesmereUIMythicPlus/EUI_MythicPlus_Keystones.lua b/EllesmereUIMythicPlus/EUI_MythicPlus_Keystones.lua new file mode 100644 index 000000000..330554fe2 --- /dev/null +++ b/EllesmereUIMythicPlus/EUI_MythicPlus_Keystones.lua @@ -0,0 +1,664 @@ +------------------------------------------------------------------------------- +-- EUI_MythicPlus_Keystones.lua -- party keystone list + in-dungeon banner +-- +-- Two displays over one data source: a free-floating list of every party +-- member's keystone, and a banner that names who is holding a key for the +-- dungeon the group just walked into. +-- +-- Party data comes from LibKeystone (the BigWigs/DBM keystone exchange the +-- suite already vendors for /keys), so there is no comm channel of our own and +-- anyone running BigWigs, DBM or EllesmereUI answers. Both frames are placed +-- through the suite's global Unlock Mode -- there is no per-module lock toggle +-- or anchor dropdown. +-- +-- Refreshes are event-driven only. The upstream addon polled both frames on a +-- 3s OnUpdate; keystone data changes on roster, bag and challenge-mode events +-- and on LibKeystone replies, all of which are covered below. +------------------------------------------------------------------------------- +local ADDON_NAME, ns = ... + +local LibKeystone = LibStub and LibStub("LibKeystone", true) + +local ICON_TEXT_GAP = 8 +local LIST_PAD = 8 +local LIST_W = 320 +local BANNER_W = 260 +local BANNER_PAD = 10 + +-- Banner scope: Mythic 0 is the pre-insertion state, where the group still has +-- to decide whose key to run. Once a key goes in the instance flips to the +-- Mythic+ difficulty and who else holds one stops mattering; Normal and Heroic +-- can never host a keystone at all. +local MYTHIC_DIFFICULTY_ID = 23 + +------------------------------------------------------------------------------- +-- Keystone data +------------------------------------------------------------------------------- +-- shortName -> { level, mapID, rating }. Filled by LibKeystone replies; the +-- player's own row is read straight from the API on every rebuild. +local partyKeys = {} + +local function ShortName(name) + if not name then return nil end + if Ambiguate then return Ambiguate(name, "short") or name end + return name:match("^([^%-]+)") or name +end + +local function PartyUnits() + local units = { "player" } + if IsInGroup(LE_PARTY_CATEGORY_HOME) and not IsInRaid() then + for i = 1, GetNumSubgroupMembers() do + local unit = "party" .. i + if UnitExists(unit) then units[#units + 1] = unit end + end + end + return units +end + +local function KeystoneForUnit(unit) + local _, classFile = UnitClass(unit) + if unit == "player" then + return { + level = (C_MythicPlus.GetOwnedKeystoneLevel and C_MythicPlus.GetOwnedKeystoneLevel()) or 0, + mapID = (C_MythicPlus.GetOwnedKeystoneChallengeMapID and C_MythicPlus.GetOwnedKeystoneChallengeMapID()) or 0, + classFile = classFile, + } + end + local entry = partyKeys[ShortName(UnitName(unit))] + return { + level = (entry and entry.level) or 0, + mapID = (entry and entry.mapID) or 0, + classFile = classFile, + } +end + +--- Every party member with a keystone, best key first. `includeKeyless` keeps +--- the player's own row even when they are holding no key, so the list never +--- renders as an empty box for the one member whose state they already know. +local function CollectKeystones(includeKeyless) + local list = {} + for _, unit in ipairs(PartyUnits()) do + local info = KeystoneForUnit(unit) + if info.level > 0 or (includeKeyless and unit == "player") then + list[#list + 1] = { + name = UnitName(unit) or unit, + level = info.level, + mapID = info.mapID, + classFile = info.classFile, + } + end + end + table.sort(list, function(a, b) + if a.level ~= b.level then return a.level > b.level end + return (a.name or "") < (b.name or "") + end) + return list +end + +local requestPending = false +local function RequestPartyKeys() + if not LibKeystone or not IsInGroup() then return end + -- Roster updates arrive in bursts while a group forms; one settled request + -- per second instead of one per event. + if requestPending then return end + requestPending = true + C_Timer.After(1, function() + requestPending = false + LibKeystone.Request("PARTY") + end) +end + +------------------------------------------------------------------------------- +-- Shared frame helpers +------------------------------------------------------------------------------- +local function ApplyPosition(frame, pos, defaultX, defaultY) + if not frame then return end + frame:ClearAllPoints() + if pos and pos.centerX and pos.centerY then + frame:SetPoint("CENTER", UIParent, "CENTER", pos.centerX, pos.centerY) + else + frame:SetPoint("CENTER", UIParent, "CENTER", defaultX, defaultY) + end +end + +local function CapturePosition(frame) + if not frame then return nil end + local left, bottom = frame:GetLeft(), frame:GetBottom() + if not left or not bottom then return nil end + local w, h = frame:GetSize() + return { + centerX = left + w / 2 - UIParent:GetWidth() / 2, + centerY = bottom + h / 2 - UIParent:GetHeight() / 2, + } +end + +local function LevelText(level) + if level and level > 0 then + return "|c" .. ns.LevelColor(level) .. "+" .. level .. "|r" + end + return "|cff9d9d9d-|r" +end + +------------------------------------------------------------------------------- +-- Preview +-- +-- Driven by the eye toggles on the options page: both displays only appear +-- under conditions the player cannot conjure on demand (a full party, or +-- standing in a Mythic 0), so previewing them with sample data is the only way +-- to size and position them. +-- +-- Preview state is RUNTIME ONLY -- never written to the profile. It clears when +-- the options window and Unlock Mode are both gone, so a preview can never be +-- left stuck on across a session. Sample rows are generated once per enable so +-- the preview does not reshuffle on every event-driven refresh. +-- +-- Sample names are deliberately not translated: they stand in for character +-- names, which are never localized. +------------------------------------------------------------------------------- +local SAMPLE_NAMES = { + "Aramis", "Brynhild", "Corvyn", "Dashiel", "Elowen", "Fenrick", "Greyvale", +} +local SAMPLE_CLASSES = { + "MAGE", "PRIEST", "WARRIOR", "DRUID", "ROGUE", "PALADIN", "SHAMAN", "HUNTER", +} + +local preview = { list = false, banner = false } +local previewListRows, previewBannerRows + +local function RandomKeystone() + local mapIDs = ns.SeasonMapIDs() + local mapID = (mapIDs and #mapIDs > 0) and mapIDs[math.random(#mapIDs)] or 0 + return mapID, math.random(2, 20) +end + +--- `count` sample rows. With `useOwnName` the first row is the player's real +--- name and class, which is what the banner preview wants. +local function BuildPreviewRows(count, useOwnName) + local names = {} + for i, name in ipairs(SAMPLE_NAMES) do names[i] = name end + for i = #names, 2, -1 do + local j = math.random(i) + names[i], names[j] = names[j], names[i] + end + + local rows = {} + for i = 1, count do + local mapID, level = RandomKeystone() + local isSelf = useOwnName and i == 1 + rows[i] = { + name = isSelf and (UnitName("player") or names[i]) or names[i], + level = level, + mapID = mapID, + classFile = isSelf and select(2, UnitClass("player")) + or SAMPLE_CLASSES[math.random(#SAMPLE_CLASSES)], + } + end + table.sort(rows, function(a, b) + if a.level ~= b.level then return a.level > b.level end + return a.name < b.name + end) + return rows +end + +------------------------------------------------------------------------------- +-- Keystone list +------------------------------------------------------------------------------- +local list = { rows = {}, enabled = false } + +local function ListCfg() return ns.P().keystoneList end + +local function ListRow(index) + local row = list.rows[index] + if row then return row end + + row = CreateFrame("Frame", nil, list.frame) + + row.icon = row:CreateTexture(nil, "ARTWORK") + row.icon:SetPoint("TOPLEFT", row, "TOPLEFT", 0, 0) + row.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + + row.iconBorder = row:CreateTexture(nil, "BORDER") + row.iconBorder:SetColorTexture(0, 0, 0, 0.6) + row.iconBorder:SetPoint("TOPLEFT", row.icon, "TOPLEFT", -1, 1) + row.iconBorder:SetPoint("BOTTOMRIGHT", row.icon, "BOTTOMRIGHT", 1, -1) + + row.level = row:CreateFontString(nil, "OVERLAY") + row.level:SetPoint("CENTER", row.icon, "CENTER", 0, 0) + row.level:SetJustifyH("CENTER") + + row.dungeon = row:CreateFontString(nil, "OVERLAY") + row.dungeon:SetPoint("TOPLEFT", row.icon, "TOPRIGHT", ICON_TEXT_GAP, -4) + row.dungeon:SetPoint("RIGHT", row, "RIGHT", -6, 0) + row.dungeon:SetJustifyH("LEFT") + + row.holder = row:CreateFontString(nil, "OVERLAY") + row.holder:SetPoint("BOTTOMLEFT", row.icon, "BOTTOMRIGHT", ICON_TEXT_GAP, 4) + row.holder:SetPoint("RIGHT", row, "RIGHT", -6, 0) + row.holder:SetJustifyH("LEFT") + + list.rows[index] = row + return row +end + +local function ListHeight(rowCount) + local cfg = ListCfg() + local iconSize = cfg.iconSize or 42 + local gap = math.max(cfg.rowSpacing or 4, 0) + rowCount = math.max(rowCount, 1) + return LIST_PAD * 2 + iconSize * rowCount + gap * (rowCount - 1) +end + +local function ListShouldShow() + if not list.enabled then return false end + if preview.list then return true end + if not IsInGroup(LE_PARTY_CATEGORY_HOME) then return false end + if IsInRaid() then return false end + if list.inActiveRun then return false end + return true +end + +local function ListLayout() + local cfg = ListCfg() + local iconSize = cfg.iconSize or 42 + local gap = math.max(cfg.rowSpacing or 4, 0) + local size = cfg.fontSize or 12 + -- The level sits inside the icon, so it scales with the icon rather than + -- with the body text -- but never smaller than the body text. + local levelSize = math.max(math.floor(iconSize * 0.55), size + 2) + local entries = previewListRows or CollectKeystones(true) + + for i, entry in ipairs(entries) do + local row = ListRow(i) + row:ClearAllPoints() + if i == 1 then + row:SetPoint("TOPLEFT", list.frame, "TOPLEFT", LIST_PAD, -LIST_PAD) + else + row:SetPoint("TOPLEFT", list.rows[i - 1], "BOTTOMLEFT", 0, -gap) + end + row:SetPoint("RIGHT", list.frame, "RIGHT", -LIST_PAD, 0) + row:SetHeight(iconSize) + row.icon:SetSize(iconSize, iconSize) + row:Show() + + row.icon:SetTexture(ns.DungeonIcon(entry.mapID)) + ns.SetFont(row.level, levelSize) + ns.SetFont(row.dungeon, size) + ns.SetFont(row.holder, size) + + row.level:SetText(LevelText(entry.level)) + if entry.level > 0 then + row.dungeon:SetText(ns.DungeonName(entry.mapID, ns.P().useAbbreviation ~= false)) + else + row.dungeon:SetText("|cff9d9d9d" .. EllesmereUI.L("No keystone") .. "|r") + end + row.holder:SetText(ns.ClassColorText(entry.classFile, ShortName(entry.name) or "?")) + end + + for i = #entries + 1, #list.rows do + list.rows[i]:Hide() + end + list.frame:SetHeight(ListHeight(#entries)) +end + +local function ListRefresh() + if not list.frame then return end + -- Anchor and box size are applied even while hidden: Unlock Mode syncs its + -- mover from the frame's real position, and a frame that was never given a + -- point has none to read -- the mover would land at screen center. + ApplyPosition(list.frame, ListCfg().pos, -math.floor(UIParent:GetWidth() / 2) + 170, 200) + if not ListShouldShow() then + list.frame:SetHeight(ListHeight(1)) + list.frame:Hide() + return + end + list.frame:Show() + ListLayout() +end + +------------------------------------------------------------------------------- +-- Center banner +------------------------------------------------------------------------------- +local banner = { rows = {}, enabled = false } + +local function BannerCfg() return ns.P().centerBanner end + +--- The current season mapID of the Mythic 0 dungeon the player is standing in, +--- or 0 when the banner has nothing to say. +local function ActiveMapID() + local instanceName, instanceType, difficultyID = GetInstanceInfo() + if instanceType ~= "party" or not instanceName or instanceName == "" then return 0 end + if difficultyID ~= MYTHIC_DIFFICULTY_ID then return 0 end + return ns.MapIDFromName(instanceName) or 0 +end + +local function BannerRow(index) + local row = banner.rows[index] + if row then return row end + row = CreateFrame("Frame", nil, banner.frame) + row.text = row:CreateFontString(nil, "OVERLAY") + row.text:SetAllPoints(row) + row.text:SetJustifyH("CENTER") + row.text:SetJustifyV("MIDDLE") + banner.rows[index] = row + return row +end + +local function BannerHolders(activeMapID) + local holders = {} + for _, entry in ipairs(CollectKeystones(false)) do + if entry.mapID == activeMapID then holders[#holders + 1] = entry end + end + return holders +end + +local function BannerHeight(rowCount) + local cfg = BannerCfg() + local size = cfg.fontSize or 18 + local gap = cfg.rowSpacing or 6 + local h = BANNER_PAD * 2 + size + 6 + if rowCount > 0 then + h = h + gap + 4 + (size + 6) * rowCount + gap * (rowCount - 1) + end + return h +end + +local function BannerLayout(holders) + local cfg = BannerCfg() + local size = cfg.fontSize or 18 + local gap = cfg.rowSpacing or 6 + local rowH = size + 6 + local afterTitleGap = gap + 4 + + ns.SetFont(banner.frame.title, size) + banner.frame.title:SetText("|cffffd700" .. EllesmereUI.L("Keystone Owners") .. "|r") + + for i, entry in ipairs(holders) do + local row = BannerRow(i) + row:ClearAllPoints() + if i == 1 then + row:SetPoint("TOPLEFT", banner.frame.title, "BOTTOMLEFT", 0, -afterTitleGap) + row:SetPoint("TOPRIGHT", banner.frame.title, "BOTTOMRIGHT", 0, -afterTitleGap) + else + row:SetPoint("TOPLEFT", banner.rows[i - 1], "BOTTOMLEFT", 0, -gap) + row:SetPoint("TOPRIGHT", banner.rows[i - 1], "BOTTOMRIGHT", 0, -gap) + end + row:SetHeight(rowH) + row:Show() + ns.SetFont(row.text, size) + row.text:SetText(ns.ClassColorText(entry.classFile, ShortName(entry.name) or "?") + .. " " .. LevelText(entry.level)) + end + + for i = #holders + 1, #banner.rows do + banner.rows[i]:Hide() + end + banner.frame:SetHeight(BannerHeight(#holders)) +end + +local function BannerRefresh() + if not banner.frame then return end + -- See the note in ListRefresh: the anchor is applied even while hidden so + -- Unlock Mode's mover has a real position to sync from. + ApplyPosition(banner.frame, BannerCfg().pos, 0, 200) + + local holders + if banner.enabled and preview.banner then + holders = previewBannerRows + elseif banner.enabled then + local activeMapID = ActiveMapID() + holders = activeMapID ~= 0 and BannerHolders(activeMapID) or nil + end + if not holders or #holders == 0 then + banner.frame:SetHeight(BannerHeight(1)) + banner.frame:Hide() + return + end + banner.frame:Show() + BannerLayout(holders) +end + +------------------------------------------------------------------------------- +-- Shared events +------------------------------------------------------------------------------- +local function RefreshAll() + ListRefresh() + BannerRefresh() +end + +local EVENTS = { + "GROUP_ROSTER_UPDATE", + "BAG_UPDATE_DELAYED", + "PLAYER_ENTERING_WORLD", + "CHALLENGE_MODE_START", + "CHALLENGE_MODE_COMPLETED", + "CHALLENGE_MODE_RESET", +} + +local function OnEvent(event) + if event == "CHALLENGE_MODE_START" then + list.inActiveRun = true + elseif event == "CHALLENGE_MODE_COMPLETED" or event == "CHALLENGE_MODE_RESET" then + list.inActiveRun = false + elseif event == "PLAYER_ENTERING_WORLD" then + local _, instanceType = GetInstanceInfo() + local activeLevel = (C_ChallengeMode and C_ChallengeMode.GetActiveKeystoneInfo + and C_ChallengeMode.GetActiveKeystoneInfo()) or 0 + list.inActiveRun = (instanceType == "party" and activeLevel > 0) or false + RequestPartyKeys() + elseif event == "GROUP_ROSTER_UPDATE" then + RequestPartyKeys() + end + RefreshAll() +end + +-- LibKeystone hands every listener the same replies, so registering here is +-- independent of whether the /keys popup is also listening. The lib has no +-- unregister, so this is installed once on first enable and lives for the +-- session -- which is why the callback's first act is to bail when neither +-- display is on. +local libCallbacks = {} +local function InstallLibKeystone() + if not LibKeystone or libCallbacks.installed then return end + libCallbacks.installed = true + LibKeystone.Register(libCallbacks, function(keyLevel, keyMapID, _, playerName, channel) + -- LibKeystone exposes no unregister, so the callback stays installed for + -- the session; it must therefore cost nothing once both displays are off. + if not (list.enabled or banner.enabled) then return end + if channel == "GUILD" or not playerName then return end + partyKeys[ShortName(playerName)] = { level = keyLevel or 0, mapID = keyMapID or 0 } + RefreshAll() + end) +end + +------------------------------------------------------------------------------- +-- Unlock Mode +-- +-- Both frames are placed through the suite's global Unlock Mode -- that is the +-- single place in EllesmereUI where on-screen elements are moved, so neither +-- frame carries a drag handler or an anchor dropdown of its own. +------------------------------------------------------------------------------- +local function RegisterUnlock() + if not EllesmereUI.RegisterUnlockElements or not EllesmereUI.MakeUnlockElement then return end + local MK = EllesmereUI.MakeUnlockElement + + EllesmereUI:RegisterUnlockElements({ + MK({ + key = "EMP_KeystoneList", + label = "Party Keystones", + group = "Mythic+", + order = 530, + noResize = true, + isHidden = function() + local p = ns.P() + return not p or p.enabled == false or p.keystoneList.enabled == false + end, + getFrame = function() + if not list.frame then return nil end + return list.frame + end, + getSize = function() + if list.frame and list.frame:GetHeight() > 1 then + return list.frame:GetWidth(), list.frame:GetHeight() + end + return LIST_W, ListHeight(1) + end, + savePos = function(_, _, _, x, y) + local cfg = ListCfg(); if not cfg then return end + cfg.pos = CapturePosition(list.frame) or { centerX = x, centerY = y } + end, + loadPos = function() + local cfg = ListCfg() + if cfg and cfg.pos then + return { point = "CENTER", relPoint = "CENTER", x = cfg.pos.centerX, y = cfg.pos.centerY } + end + return nil + end, + clearPos = function() + local cfg = ListCfg(); if cfg then cfg.pos = nil end + end, + applyPos = function() ListRefresh() end, + }), + MK({ + key = "EMP_KeystoneBanner", + label = "Keystone Owners", + group = "Mythic+", + order = 540, + noResize = true, + isHidden = function() + local p = ns.P() + return not p or p.enabled == false or p.centerBanner.enabled == false + end, + getFrame = function() + if not banner.frame then return nil end + return banner.frame + end, + getSize = function() + if banner.frame and banner.frame:GetHeight() > 1 then + return banner.frame:GetWidth(), banner.frame:GetHeight() + end + return BANNER_W, BannerHeight(1) + end, + savePos = function(_, _, _, x, y) + local cfg = BannerCfg(); if not cfg then return end + cfg.pos = CapturePosition(banner.frame) or { centerX = x, centerY = y } + end, + loadPos = function() + local cfg = BannerCfg() + if cfg and cfg.pos then + return { point = "CENTER", relPoint = "CENTER", x = cfg.pos.centerX, y = cfg.pos.centerY } + end + return nil + end, + clearPos = function() + local cfg = BannerCfg(); if cfg then cfg.pos = nil end + end, + applyPos = function() BannerRefresh() end, + }), + }, "EllesmereUIMythicPlus") +end +-- Registered at module level, not on either feature: this one call owns BOTH +-- elements, so tying it to the list feature would drop the banner's mover the +-- moment that feature moved or went away. +ns.RegisterUnlockHook(RegisterUnlock) + +-- Unlock Mode has no live element to grab until the frame exists, so both are +-- built as soon as either display is first enabled -- the frames stay hidden +-- until their own show rules say otherwise. +local function BuildFrames() + if not list.frame then + list.frame = CreateFrame("Frame", "EllesmereUIMythicPlusKeystoneList", UIParent) + list.frame:SetSize(LIST_W, ListHeight(1)) + list.frame:SetFrameStrata("MEDIUM") + list.frame:Hide() + end + if not banner.frame then + banner.frame = CreateFrame("Frame", "EllesmereUIMythicPlusKeystoneBanner", UIParent) + banner.frame:SetSize(BANNER_W, BannerHeight(1)) + banner.frame:SetFrameStrata("HIGH") + banner.frame.title = banner.frame:CreateFontString(nil, "OVERLAY") + banner.frame.title:SetPoint("TOPLEFT", banner.frame, "TOPLEFT", BANNER_PAD, -BANNER_PAD) + banner.frame.title:SetPoint("TOPRIGHT", banner.frame, "TOPRIGHT", -BANNER_PAD, -BANNER_PAD) + banner.frame.title:SetJustifyH("CENTER") + banner.frame:Hide() + end +end + +------------------------------------------------------------------------------- +-- Preview API (consumed by the options page's eye toggles) +------------------------------------------------------------------------------- +--- @param which "list" | "banner" +function ns.SetKeystonePreview(which, on) + on = on and true or false + BuildFrames() + if which == "list" then + preview.list = on + previewListRows = on and BuildPreviewRows(5, false) or nil + ListRefresh() + else + preview.banner = on + previewBannerRows = on and BuildPreviewRows(1, true) or nil + BannerRefresh() + end +end + +function ns.IsKeystonePreview(which) + if which == "list" then return preview.list end + return preview.banner +end + +function ns.ClearKeystonePreviews() + if not (preview.list or preview.banner) then return end + preview.list, preview.banner = false, false + previewListRows, previewBannerRows = nil, nil + ListRefresh() + BannerRefresh() +end + +------------------------------------------------------------------------------- +-- Features +------------------------------------------------------------------------------- +local eventsArmed = false + +local function SyncEvents() + local on = list.enabled or banner.enabled + ns.SetEvents(OnEvent, EVENTS, on) + -- The party request is a real addon-channel broadcast, so it fires only on + -- the OFF -> ON transition and from the events above. SyncEvents itself runs + -- on every settings change to ANY feature in this module (ApplyAll re-applies + -- all of them), and a dungeon-name-size slider must not solicit the group. + if on and not eventsArmed then + InstallLibKeystone() + RequestPartyKeys() + end + eventsArmed = on +end + +ns.RegisterFeature({ + id = "keystoneList", + IsEnabled = function(p) return p.keystoneList.enabled ~= false end, + Start = BuildFrames, + Apply = function(on) + list.enabled = on + if not on then + preview.list = false + previewListRows = nil + end + SyncEvents() + ListRefresh() + end, +}) + +ns.RegisterFeature({ + id = "centerBanner", + IsEnabled = function(p) return p.centerBanner.enabled ~= false end, + Start = BuildFrames, + Apply = function(on) + banner.enabled = on + if not on then + preview.banner = false + previewBannerRows = nil + end + SyncEvents() + BannerRefresh() + end, +}) diff --git a/EllesmereUIMythicPlus/EUI_MythicPlus_Options.lua b/EllesmereUIMythicPlus/EUI_MythicPlus_Options.lua new file mode 100644 index 000000000..da8b4bfec --- /dev/null +++ b/EllesmereUIMythicPlus/EUI_MythicPlus_Options.lua @@ -0,0 +1,442 @@ +------------------------------------------------------------------------------- +-- EUI_MythicPlus_Options.lua -- settings pages for the Mythic+ module +-- +-- Positioning for the keystone list and banner deliberately lives in the +-- suite's global Unlock Mode, not here; this page only links to it. +------------------------------------------------------------------------------- +local ADDON_NAME, ns = ... + +local PAGE_ICONS = "Dungeon Icons" +local PAGE_WEEKLY = "Weekly Panel" +local PAGE_KEYS = "Keystones" + +local initFrame = CreateFrame("Frame") +initFrame:RegisterEvent("PLAYER_LOGIN") +initFrame:SetScript("OnEvent", function(self) + self:UnregisterEvent("PLAYER_LOGIN") + + if not EllesmereUI or not EllesmereUI.RegisterModule then return end + + local function P() return ns.P() end + + local function Refresh() + if _G._EMP_Apply then _G._EMP_Apply() end + end + + --- Settings whose state gates other rows need the page redrawn so the + --- dependent rows pick up their new disabled state. + local function RefreshAndRepaint() + Refresh() + if EllesmereUI.RefreshPage then EllesmereUI:RefreshPage() end + end + + local function ModuleOff() + local p = P() + return not p or p.enabled == false + end + + --------------------------------------------------------------------------- + -- Inline eye toggle + -- + -- Both keystone displays only appear under conditions the player cannot + -- produce on demand (a full party, or standing in a Mythic 0), so the eye + -- fills them with sample rows to size and position against. Preview state + -- is runtime-only -- see the note in EUI_MythicPlus_Keystones.lua. + --------------------------------------------------------------------------- + local function AttachEyeToggle(rgn, which, isDisabled) + local PP = EllesmereUI.PP + local btn = CreateFrame("Button", nil, rgn) + btn:SetSize(26, 26) + -- Chain off any inline widget already on this region so several inline + -- controls sit side by side instead of overlapping. + PP.Point(btn, "RIGHT", rgn._lastInline or rgn._control or rgn, "LEFT", -6, 0) + rgn._lastInline = btn + btn:SetFrameLevel(rgn:GetFrameLevel() + 5) + + local tex = btn:CreateTexture(nil, "OVERLAY") + tex:SetAllPoints() + + local function IsOn() return ns.IsKeystonePreview(which) end + local function UpdateVisual() + tex:SetTexture(IsOn() and EllesmereUI.EYE_VISIBLE_ICON or EllesmereUI.EYE_INVISIBLE_ICON) + btn:SetAlpha(isDisabled() and 0.15 or (IsOn() and 0.85 or 0.4)) + end + EllesmereUI.RegisterWidgetRefresh(UpdateVisual) + UpdateVisual() + + btn:SetScript("OnClick", function() + if isDisabled() then return end + ns.SetKeystonePreview(which, not IsOn()) + UpdateVisual() + end) + btn:SetScript("OnEnter", function(self) + if isDisabled() then return end + self:SetAlpha(1) + -- ShowWidgetTooltip translates its own text. + EllesmereUI.ShowWidgetTooltip(self, IsOn() and "Hide Preview" or "Preview with Sample Data") + end) + btn:SetScript("OnLeave", function() + UpdateVisual() + EllesmereUI.HideWidgetTooltip() + end) + end + + --- A live preview must survive the options window closing into Unlock Mode + --- -- placing the frames is exactly when it is wanted. Anything else that + --- closes the panel drops it, and so does leaving Unlock Mode. + local function InstallPreviewAutoOff() + local mf = _G.EllesmereUIFrame + if not mf or mf._empPreviewHook then return end + mf._empPreviewHook = true + mf:HookScript("OnHide", function() + if EllesmereUI.IsUnlockModeActive and EllesmereUI:IsUnlockModeActive() then return end + ns.ClearKeystonePreviews() + end) + if EllesmereUI.RegisterUnlockModeListener then + EllesmereUI:RegisterUnlockModeListener("EllesmereUIMythicPlus", function(active) + if not active then ns.ClearKeystonePreviews() end + end) + end + end + + --------------------------------------------------------------------------- + -- Score overlay: three identical line blocks (name / level / score) + --------------------------------------------------------------------------- + local SCORE_LINES = { + { key = "name", label = "Dungeon Name", shown = "nameShown", size = "nameSize", x = "nameX", y = "nameY" }, + { key = "level", label = "Best Level", shown = "levelShown", size = "levelSize", x = "levelX", y = "levelY" }, + { key = "score", label = "Dungeon Score", shown = "scoreShown", size = "scoreSize", x = "scoreX", y = "scoreY" }, + } + + local function ScoreOff() + local p = P() + return ModuleOff() or p.score.enabled == false + end + + local function BuildScoreLine(parent, y, line) + local W = EllesmereUI.Widgets + local row, h + + local function LineOff() + local p = P() + return ScoreOff() or p.score[line.shown] == false + end + + row, h = W:DualRow(parent, y, + { type = "toggle", text = "Show " .. line.label, + disabled = ScoreOff, disabledTooltip = "Show Score Overlay", + getValue = function() return P().score[line.shown] ~= false end, + setValue = function(v) P().score[line.shown] = v; RefreshAndRepaint() end }, + { type = "slider", text = line.label .. " Size", + disabled = LineOff, disabledTooltip = "Show " .. line.label, + min = 8, max = 32, step = 1, isPercent = false, + getValue = function() return P().score[line.size] end, + setValue = function(v) P().score[line.size] = v; Refresh() end }) + y = y - h + + row, h = W:DualRow(parent, y, + { type = "slider", pixel = true, text = line.label .. " X", + disabled = LineOff, disabledTooltip = "Show " .. line.label, + min = -80, max = 80, step = 1, isPercent = false, + getValue = function() return P().score[line.x] or 0 end, + setValue = function(v) P().score[line.x] = v; Refresh() end }, + { type = "slider", pixel = true, text = line.label .. " Y", + disabled = LineOff, disabledTooltip = "Show " .. line.label, + min = -80, max = 80, step = 1, isPercent = false, + getValue = function() return P().score[line.y] or 0 end, + setValue = function(v) P().score[line.y] = v; Refresh() end }) + y = y - h + + return y + end + + --------------------------------------------------------------------------- + -- Page: Dungeon Icons + --------------------------------------------------------------------------- + local function BuildIconsPage(parent, yOffset) + local W = EllesmereUI.Widgets + local y = yOffset + local _, row, h + + if EllesmereUI.ClearContentHeader then EllesmereUI:ClearContentHeader() end + parent._showRowDivider = true + + _, h = W:SectionHeader(parent, "GENERAL", y); y = y - h + + row, h = W:DualRow(parent, y, + { type = "toggle", text = "Enable Mythic+", + tooltip = "Master switch for every Mythic+ feature in this module.", + getValue = function() return P().enabled ~= false end, + setValue = function(v) P().enabled = v; RefreshAndRepaint() end }, + { type = "toggle", text = "Abbreviate Dungeon Names", + disabled = ModuleOff, disabledTooltip = "the module", + tooltip = "Show short dungeon names (WRS, MT, ...) wherever this module prints one.", + getValue = function() return P().useAbbreviation ~= false end, + setValue = function(v) P().useAbbreviation = v; Refresh() end }) + y = y - h + + _, h = W:SectionHeader(parent, "SCORE OVERLAY", y); y = y - h + + row, h = W:DualRow(parent, y, + { type = "toggle", text = "Show Score Overlay", + disabled = ModuleOff, disabledTooltip = "the module", + tooltip = "Draw dungeon name, best key level and season score onto each Group Finder dungeon icon.", + getValue = function() return P().score.enabled ~= false end, + setValue = function(v) P().score.enabled = v; RefreshAndRepaint() end }, + { type = "toggle", text = "Hide Blizzard Key Level", + disabled = ScoreOff, disabledTooltip = "Show Score Overlay", + getValue = function() return P().score.hideBlizzardLevel ~= false end, + setValue = function(v) P().score.hideBlizzardLevel = v; Refresh() end }) + y = y - h + + for _, line in ipairs(SCORE_LINES) do + y = BuildScoreLine(parent, y, line) + end + + _, h = W:SectionHeader(parent, "TELEPORTS", y); y = y - h + + local function TeleportOff() + local p = P() + return ModuleOff() or p.teleport.enabled == false + end + + row, h = W:DualRow(parent, y, + { type = "toggle", text = "Teleport Buttons", + disabled = ModuleOff, disabledTooltip = "the module", + tooltip = "Click a Group Finder dungeon icon to cast its teleport.", + getValue = function() return P().teleport.enabled ~= false end, + setValue = function(v) P().teleport.enabled = v; RefreshAndRepaint() end }, + { type = "toggle", text = "Announce Teleports", + disabled = TeleportOff, disabledTooltip = "Teleport Buttons", + tooltip = "Post a message to party or instance chat when you cast a dungeon teleport.", + getValue = function() return P().teleport.announce == true end, + setValue = function(v) P().teleport.announce = v; RefreshAndRepaint() end }) + y = y - h + + local function AnnounceOff() + local p = P() + return TeleportOff() or p.teleport.announce ~= true + end + + row, h = W:DualRow(parent, y, + { type = "labeledButton", text = "Announce Message", buttonText = "Edit Message", + disabled = AnnounceOff, disabledTooltip = "Announce Teleports", + tooltip = "{spell} becomes the teleport spell link, {dungeon} the dungeon name.", + onClick = function() + EllesmereUI:ShowInputPopup({ + title = "Announce Message", + message = "Use {spell} for the teleport spell link and {dungeon} for the dungeon name.", + -- Same resolver the runtime sends with, so the field can + -- never show a different default than what actually posts. + placeholder = ns.TeleportTemplate(), + confirmText = "Save", + cancelText = "Cancel", + onConfirm = function(text) + text = text and strtrim(text) or "" + -- Storing "" keeps the message following the client + -- language instead of freezing today's translation. + P().teleport.template = text + Refresh() + end, + }) + end }, + { type = "button", text = "Reset Message", width = 160, + disabled = AnnounceOff, disabledTooltip = "Announce Teleports", + onClick = function() P().teleport.template = ""; Refresh() end }) + y = y - h + + _, h = W:Spacer(parent, y, 20); y = y - h + parent:SetHeight(math.abs(y - yOffset)) + end + + --------------------------------------------------------------------------- + -- Page: Weekly Panel + --------------------------------------------------------------------------- + local function BuildWeeklyPage(parent, yOffset) + local W = EllesmereUI.Widgets + local y = yOffset + local _, row, h + + if EllesmereUI.ClearContentHeader then EllesmereUI:ClearContentHeader() end + parent._showRowDivider = true + + local function WeeklyOff() + local p = P() + return ModuleOff() or p.weekly.enabled == false + end + + _, h = W:SectionHeader(parent, "WEEKLY PANEL", y); y = y - h + + row, h = W:DualRow(parent, y, + { type = "toggle", text = "Show Weekly Panel", + disabled = ModuleOff, disabledTooltip = "the module", + tooltip = "List this week's Mythic+ runs beside the Group Finder window.", + getValue = function() return P().weekly.enabled ~= false end, + setValue = function(v) P().weekly.enabled = v; RefreshAndRepaint() end }, + { type = "toggle", text = "Hold Shift for Season Best", + disabled = WeeklyOff, disabledTooltip = "Show Weekly Panel", + getValue = function() return P().weekly.useModifierKey ~= false end, + setValue = function(v) P().weekly.useModifierKey = v; Refresh() end }) + y = y - h + + row, h = W:DualRow(parent, y, + { type = "slider", text = "Font Size", + disabled = WeeklyOff, disabledTooltip = "Show Weekly Panel", + min = 8, max = 32, step = 1, isPercent = false, + getValue = function() return P().weekly.fontSize end, + setValue = function(v) P().weekly.fontSize = v; Refresh() end }, + { type = "slider", pixel = true, text = "Row Spacing", + disabled = WeeklyOff, disabledTooltip = "Show Weekly Panel", + min = 0, max = 20, step = 1, isPercent = false, + getValue = function() return P().weekly.rowSpacing end, + setValue = function(v) P().weekly.rowSpacing = v; Refresh() end }) + y = y - h + + row, h = W:DualRow(parent, y, + { type = "slider", text = "Max Rows", + disabled = WeeklyOff, disabledTooltip = "Show Weekly Panel", + tooltip = "How many runs the Weekly Best and Season Best lists show before the per-dungeon summary.", + min = 3, max = 15, step = 1, isPercent = false, + getValue = function() return P().weekly.maxRows end, + setValue = function(v) P().weekly.maxRows = v; Refresh() end }, + { type = "label", text = "" }) + y = y - h + + _, h = W:SectionHeader(parent, "DOCK OFFSET", y); y = y - h + + row, h = W:DualRow(parent, y, + { type = "slider", pixel = true, text = "X Offset", + disabled = WeeklyOff, disabledTooltip = "Show Weekly Panel", + tooltip = "The panel docks to the Group Finder window's top-right corner and follows it.", + min = -400, max = 400, step = 1, isPercent = false, + getValue = function() return P().weekly.offsetX end, + setValue = function(v) P().weekly.offsetX = v; Refresh() end }, + { type = "slider", pixel = true, text = "Y Offset", + disabled = WeeklyOff, disabledTooltip = "Show Weekly Panel", + min = -400, max = 400, step = 1, isPercent = false, + getValue = function() return P().weekly.offsetY end, + setValue = function(v) P().weekly.offsetY = v; Refresh() end }) + y = y - h + + _, h = W:Spacer(parent, y, 20); y = y - h + parent:SetHeight(math.abs(y - yOffset)) + end + + --------------------------------------------------------------------------- + -- Page: Keystones + --------------------------------------------------------------------------- + local function BuildKeystonesPage(parent, yOffset) + local W = EllesmereUI.Widgets + local y = yOffset + local _, row, h + + if EllesmereUI.ClearContentHeader then EllesmereUI:ClearContentHeader() end + parent._showRowDivider = true + + local function ListOff() + local p = P() + return ModuleOff() or p.keystoneList.enabled == false + end + local function BannerOff() + local p = P() + return ModuleOff() or p.centerBanner.enabled == false + end + + InstallPreviewAutoOff() + + _, h = W:SectionHeader(parent, "PARTY KEYSTONES", y); y = y - h + + row, h = W:DualRow(parent, y, + { type = "toggle", text = "Show Party Keystones", + disabled = ModuleOff, disabledTooltip = "the module", + tooltip = "A floating list of every party member's keystone, hidden once a run starts.", + getValue = function() return P().keystoneList.enabled ~= false end, + setValue = function(v) P().keystoneList.enabled = v; RefreshAndRepaint() end }, + { type = "slider", pixel = true, text = "Icon Size", + disabled = ListOff, disabledTooltip = "Show Party Keystones", + min = 20, max = 64, step = 1, isPercent = false, + getValue = function() return P().keystoneList.iconSize end, + setValue = function(v) P().keystoneList.iconSize = v; Refresh() end }) + AttachEyeToggle(row._leftRegion, "list", ListOff) + y = y - h + + row, h = W:DualRow(parent, y, + { type = "slider", text = "List Font Size", + disabled = ListOff, disabledTooltip = "Show Party Keystones", + min = 8, max = 32, step = 1, isPercent = false, + getValue = function() return P().keystoneList.fontSize end, + setValue = function(v) P().keystoneList.fontSize = v; Refresh() end }, + { type = "slider", pixel = true, text = "List Row Spacing", + disabled = ListOff, disabledTooltip = "Show Party Keystones", + min = 0, max = 20, step = 1, isPercent = false, + getValue = function() return P().keystoneList.rowSpacing end, + setValue = function(v) P().keystoneList.rowSpacing = v; Refresh() end }) + y = y - h + + _, h = W:SectionHeader(parent, "KEYSTONE OWNERS", y); y = y - h + + row, h = W:DualRow(parent, y, + { type = "toggle", text = "Show Keystone Owners", + disabled = ModuleOff, disabledTooltip = "the module", + tooltip = "Names who is holding a key for the Mythic dungeon the group just entered.", + getValue = function() return P().centerBanner.enabled ~= false end, + setValue = function(v) P().centerBanner.enabled = v; RefreshAndRepaint() end }, + { type = "slider", text = "Banner Font Size", + disabled = BannerOff, disabledTooltip = "Show Keystone Owners", + min = 8, max = 32, step = 1, isPercent = false, + getValue = function() return P().centerBanner.fontSize end, + setValue = function(v) P().centerBanner.fontSize = v; Refresh() end }) + AttachEyeToggle(row._leftRegion, "banner", BannerOff) + y = y - h + + row, h = W:DualRow(parent, y, + { type = "slider", pixel = true, text = "Banner Row Spacing", + disabled = BannerOff, disabledTooltip = "Show Keystone Owners", + min = 0, max = 20, step = 1, isPercent = false, + getValue = function() return P().centerBanner.rowSpacing end, + setValue = function(v) P().centerBanner.rowSpacing = v; Refresh() end }, + { type = "label", text = "" }) + y = y - h + + _, h = W:SectionHeader(parent, "POSITIONING", y); y = y - h + + _, h = W:Toggle(parent, "Unlock Elements", y, + function() return EllesmereUI._unlockModeActive or false end, + function() + if EllesmereUI.ToggleUnlockMode then EllesmereUI:ToggleUnlockMode() end + end, + "Both keystone displays are placed in the shared Unlock Mode, alongside every other EllesmereUI element.") + y = y - h + + _, h = W:Spacer(parent, y, 20); y = y - h + parent:SetHeight(math.abs(y - yOffset)) + end + + --------------------------------------------------------------------------- + -- Registration + --------------------------------------------------------------------------- + EllesmereUI:RegisterModule("EllesmereUIMythicPlus", { + title = "Mythic+ Tools", + description = "Dungeon scores, weekly runs, teleports and party keystones.", + searchTerms = "mythic plus keystone key score teleport portal weekly vault dungeon", + pages = { PAGE_ICONS, PAGE_WEEKLY, PAGE_KEYS }, + buildPage = function(pageName, parent, yOffset) + if pageName == PAGE_ICONS then return BuildIconsPage(parent, yOffset) end + if pageName == PAGE_WEEKLY then return BuildWeeklyPage(parent, yOffset) end + if pageName == PAGE_KEYS then return BuildKeystonesPage(parent, yOffset) end + end, + onReset = function() + if _G._EMP_DB and _G._EMP_DB.ResetProfile then + _G._EMP_DB:ResetProfile() + end + EllesmereUI:InvalidatePageCache() + if _G._EMP_Apply then _G._EMP_Apply() end + end, + }) + + SLASH_EMP1 = "/emp" + SlashCmdList.EMP = function() + if InCombatLockdown and InCombatLockdown() then return end + EllesmereUI:ShowModule("EllesmereUIMythicPlus") + end +end) diff --git a/EllesmereUIMythicPlus/EUI_MythicPlus_Score.lua b/EllesmereUIMythicPlus/EUI_MythicPlus_Score.lua new file mode 100644 index 000000000..4a7c13282 --- /dev/null +++ b/EllesmereUIMythicPlus/EUI_MythicPlus_Score.lua @@ -0,0 +1,173 @@ +------------------------------------------------------------------------------- +-- EUI_MythicPlus_Score.lua -- dungeon score overlay +-- +-- Draws dungeon name, best keystone level and season score straight onto each +-- Blizzard dungeon icon in the Group Finder's Mythic+ tab. +-- +-- Taint discipline: the icons belong to Blizzard_ChallengesUI, so no field is +-- ever written onto one -- our FontStrings and hook flag live in the weak-keyed +-- side table below. Blizzard's own "highest level" text is claimed by ALPHA, +-- never Hide(), so releasing it is a pure restore. Hooks are HookScript / +-- hooksecurefunc only, and because a hook can never be removed, the handler +-- body early-outs while the feature is off instead. +------------------------------------------------------------------------------- +local ADDON_NAME, ns = ... + +local FFD = setmetatable({}, { __mode = "k" }) +local function GetFFD(frame) + local d = FFD[frame] + if not d then d = {}; FFD[frame] = d end + return d +end + +local enabled = false +local challengesHooked = false + +------------------------------------------------------------------------------- +-- Rendering +------------------------------------------------------------------------------- +local function BestRunForMap(mapID) + local inTime, overtime = C_MythicPlus.GetSeasonBestForMap(mapID) + local level, isOvertime = 0, false + if inTime or overtime then + -- Prefer the in-time run whenever it scored at least as well; an + -- overtime best is dimmed so a depleted key reads differently. + local useInTime = (inTime and not overtime) + or (inTime and overtime and inTime.dungeonScore >= overtime.dungeonScore) + local best = useInTime and inTime or overtime + level = best.level or 0 + isOvertime = not useInTime + end + local totalScore = select(2, C_MythicPlus.GetSeasonBestAffixScoreInfoForMap(mapID)) or 0 + return level, totalScore, isOvertime +end + +local function GetLine(icon, key, size) + local d = GetFFD(icon) + d.lines = d.lines or {} + local fs = d.lines[key] + if not fs then + fs = icon:CreateFontString(nil, "OVERLAY") + fs:SetJustifyH("CENTER") + fs:SetJustifyV("MIDDLE") + d.lines[key] = fs + end + ns.SetFont(fs, size) + return fs +end + +local function PlaceLine(fs, icon, x, y, shown) + fs:ClearAllPoints() + fs:SetPoint("CENTER", icon, "CENTER", x or 0, y or 0) + fs:SetShown(shown ~= false) +end + +--- Restore an icon to its untouched state: our text hidden, Blizzard's own +--- level text back to full alpha. +local function ReleaseIcon(icon) + local d = FFD[icon] + if d and d.lines then + for _, fs in pairs(d.lines) do fs:Hide() end + end + if icon.HighestLevel then icon.HighestLevel:SetAlpha(1) end +end + +local function RefreshIcon(icon) + if not enabled then return ReleaseIcon(icon) end + local p = ns.P() + if not p then return end + local mapID = icon.mapID + if not mapID or not ns.IsSeasonMap(mapID) then return ReleaseIcon(icon) end + + local cfg = p.score + if icon.HighestLevel then + icon.HighestLevel:SetAlpha(cfg.hideBlizzardLevel ~= false and 0 or 1) + end + + local level, totalScore, overtime = BestRunForMap(mapID) + local name = ns.DungeonName(mapID, p.useAbbreviation ~= false) + + local nameFS = GetLine(icon, "name", cfg.nameSize or 16) + local levelFS = GetLine(icon, "level", cfg.levelSize or 20) + local scoreFS = GetLine(icon, "score", cfg.scoreSize or 20) + + PlaceLine(nameFS, icon, cfg.nameX, cfg.nameY, cfg.nameShown) + PlaceLine(levelFS, icon, cfg.levelX, cfg.levelY, cfg.levelShown) + PlaceLine(scoreFS, icon, cfg.scoreX, cfg.scoreY, cfg.scoreShown) + + nameFS:SetText("|cffffd700" .. name .. "|r") + levelFS:SetText("|c" .. ns.LevelColor(level, overtime) .. level .. "|r") + scoreFS:SetText("|c" .. ns.ScoreColor(totalScore) .. totalScore .. "|r") +end + +------------------------------------------------------------------------------- +-- Hooks +------------------------------------------------------------------------------- +local function HookIcon(icon) + local d = GetFFD(icon) + if not d.hooked then + d.hooked = true + if type(icon.SetUp) == "function" then + hooksecurefunc(icon, "SetUp", RefreshIcon) + end + icon:HookScript("OnShow", RefreshIcon) + end + RefreshIcon(icon) +end + +local function RefreshAllIcons() + local cf = _G.ChallengesFrame + if not cf or not cf.DungeonIcons then return end + for _, icon in ipairs(cf.DungeonIcons) do + if enabled then HookIcon(icon) else ReleaseIcon(icon) end + end +end + +local function TryHookChallengesFrame() + local cf = _G.ChallengesFrame + if not cf then return end + if not challengesHooked then + challengesHooked = true + if type(cf.Update) == "function" then + hooksecurefunc(cf, "Update", RefreshAllIcons) + end + cf:HookScript("OnShow", RefreshAllIcons) + end + RefreshAllIcons() +end + +-- Blizzard_ChallengesUI is load-on-demand and we never force it: the player +-- cannot see a dungeon icon before the game itself has loaded that addon, so +-- waiting for its ADDON_LOADED costs nothing and saves the load at login. +local EVENTS = { "ADDON_LOADED", "CHALLENGE_MODE_COMPLETED" } + +local function OnEvent(event, arg1) + if event == "ADDON_LOADED" then + if arg1 ~= "Blizzard_ChallengesUI" then return end + TryHookChallengesFrame() + else + RefreshAllIcons() + end +end + +------------------------------------------------------------------------------- +-- Feature +------------------------------------------------------------------------------- +ns.RegisterFeature({ + id = "score", + + IsEnabled = function(p) + return p.score.enabled ~= false + end, + + Start = function() + TryHookChallengesFrame() + end, + + Apply = function(on) + enabled = on + ns.SetEvents(OnEvent, EVENTS, on) + if on then TryHookChallengesFrame() end + RefreshAllIcons() + end, +}) diff --git a/EllesmereUIMythicPlus/EUI_MythicPlus_Teleport.lua b/EllesmereUIMythicPlus/EUI_MythicPlus_Teleport.lua new file mode 100644 index 000000000..2b6dea48a --- /dev/null +++ b/EllesmereUIMythicPlus/EUI_MythicPlus_Teleport.lua @@ -0,0 +1,296 @@ +------------------------------------------------------------------------------- +-- EUI_MythicPlus_Teleport.lua -- dungeon teleport buttons + announce +-- +-- Turns every Group Finder dungeon icon into a one-click teleport, and can +-- announce the cast to the group from an editable template. +-- +-- Taint discipline: the button is OURS (InsecureActionButtonTemplate, so no +-- protected state is involved) and is tracked in the weak-keyed side table, not +-- as a field on Blizzard's icon. It propagates mouse motion so Blizzard's own +-- icon tooltip still builds normally, and our extra lines are appended from a +-- HookScript on the icon -- we never call a Blizzard script ourselves. +------------------------------------------------------------------------------- +local ADDON_NAME, ns = ... + +local FFD = setmetatable({}, { __mode = "k" }) +local function GetFFD(frame) + local d = FFD[frame] + if not d then d = {}; FFD[frame] = d end + return d +end + +local enabled = false +local challengesHooked = false + +------------------------------------------------------------------------------- +-- Tooltip +-- +-- Blizzard's icon OnEnter has already built the tooltip by the time our hook +-- runs, so we only append. The cooldown countdown then rewrites THAT ONE LINE +-- in place through its FontString instead of rebuilding the tooltip, and the +-- driver disarms itself the moment the tooltip stops belonging to the icon. +------------------------------------------------------------------------------- +local TICK_KEY = "emp_teleport_tooltip" + +local function CooldownText(spellID) + if not IsSpellKnown(spellID) then + return "|cffff4040" .. EllesmereUI.L("Teleport not learned") .. "|r" + end + local cd = C_Spell.GetSpellCooldown(spellID) + if not cd or not cd.startTime or not cd.duration then + return "|cffff4040" .. EllesmereUI.L("Cooldown unavailable") .. "|r" + end + if cd.duration == 0 then + return "|cff40ff40" .. EllesmereUI.L("Teleport ready") .. "|r" + end + return "|cffff4040" .. SecondsToTime(math.ceil(cd.startTime + cd.duration - GetTime())) .. "|r" +end + +local function StopTooltipTicker() + if ns.Drv then ns.Drv.Remove(TICK_KEY) end +end + +local function StartTooltipTicker(icon, spellID, lineIndex) + if not ns.Drv then return end + local lineFS = _G["GameTooltipTextLeft" .. lineIndex] + if not lineFS then return end + local accum = 0 + ns.Drv.Add(TICK_KEY, function(dt) + if not GameTooltip:IsShown() or not icon:IsMouseOver() then + StopTooltipTicker() + return + end + accum = accum + dt + if accum < 0.5 then return end + accum = 0 + lineFS:SetText(CooldownText(spellID)) + end) +end + +local function OnIconEnter(icon) + if not enabled then return end + local spellID = GetFFD(icon).spellID + if not spellID then return end + -- Blizzard's own OnEnter has already built and shown the tooltip by now; if + -- it did not, there is nothing to append to. + if not GameTooltip:IsShown() then return end + + GameTooltip:AddLine(" ") + GameTooltip:AddLine(C_Spell.GetSpellName(spellID) or EllesmereUI.L("Dungeon Teleport"), 1, 1, 1) + GameTooltip:AddLine(CooldownText(spellID)) + local lineIndex = GameTooltip:NumLines() + GameTooltip:Show() + StartTooltipTicker(icon, spellID, lineIndex) +end + +local function OnIconLeave() + StopTooltipTicker() +end + +------------------------------------------------------------------------------- +-- Buttons +------------------------------------------------------------------------------- +local function BestSpellID(spellIDs) + for _, spellID in ipairs(spellIDs) do + if IsSpellKnown(spellID) then return spellID end + end + return spellIDs[1] +end + +local function ReleaseIcon(icon) + local d = FFD[icon] + if d and d.button then + d.button:Hide() + d.button:SetAttribute("spell", nil) + end + if d then d.spellID = nil end +end + +local SetUpIcon -- forward declaration (the per-icon SetUp hook re-enters it) + +function SetUpIcon(icon) + if not enabled then return ReleaseIcon(icon) end + + local d = GetFFD(icon) + -- Hook the icon itself, not just ChallengesFrame:Update. Blizzard re-runs an + -- individual icon's SetUp when its map data lands or the list re-sorts, + -- without a whole-frame Update -- and that changes icon.mapID. A button left + -- carrying the previous mapID's spell would cast the WRONG dungeon's + -- teleport, so the binding has to be re-resolved from the same signal the + -- score overlay listens to. + if not d.hooked then + d.hooked = true + if type(icon.SetUp) == "function" then + hooksecurefunc(icon, "SetUp", function(self) SetUpIcon(self) end) + end + icon:HookScript("OnEnter", OnIconEnter) + icon:HookScript("OnLeave", OnIconLeave) + end + + local spellIDs = ns.TeleportSpells(icon.mapID) + if not spellIDs then return ReleaseIcon(icon) end + local spellID = BestSpellID(spellIDs) + if not spellID then return ReleaseIcon(icon) end + d.spellID = spellID + + local button = d.button + if not button then + button = CreateFrame("Button", nil, icon, "InsecureActionButtonTemplate") + button:SetAllPoints(icon) + button:SetAttribute("type", "spell") + -- Blizzard's icon keeps receiving enter/leave, so its own tooltip still + -- builds and our OnEnter hook above can simply append to it. + if button.SetPropagateMouseMotion then button:SetPropagateMouseMotion(true) end + d.button = button + end + -- Registering both AnyDown and AnyUp fires the action twice per click; the + -- second activation restarts the cast with a fresh castGUID, which defeats + -- the announce dedup and broadcasts twice. Match the player's cvar instead, + -- re-read on every refresh so a cvar change is picked up. + button:RegisterForClicks(GetCVarBool("ActionButtonUseKeyDown") and "AnyDown" or "AnyUp") + button:SetAttribute("spell", spellID) + button:Show() +end + +local function RefreshAllIcons() + local cf = _G.ChallengesFrame + if not cf or not cf.DungeonIcons then return end + for _, icon in ipairs(cf.DungeonIcons) do + SetUpIcon(icon) + end +end + +local function TryHookChallengesFrame() + local cf = _G.ChallengesFrame + if not cf then return end + if not challengesHooked then + challengesHooked = true + if type(cf.Update) == "function" then + hooksecurefunc(cf, "Update", RefreshAllIcons) + end + cf:HookScript("OnShow", RefreshAllIcons) + end + RefreshAllIcons() +end + +------------------------------------------------------------------------------- +-- Announce +------------------------------------------------------------------------------- +local lastCastGUID, lastSpellID, lastAnnounceAt + +local function SpellToMapID(spellID) + local mapIDs = ns.SeasonMapIDs() + if not mapIDs then return nil end + for _, mapID in ipairs(mapIDs) do + for _, id in ipairs(ns.TeleportSpells(mapID) or {}) do + if id == spellID then return mapID end + end + end + return nil +end + +-- gsub treats "%" in a REPLACEMENT string as a capture reference and errors on +-- anything it cannot resolve, so substituted text has to be escaped first. A +-- spell link or localized dungeon name carrying a literal percent would +-- otherwise blow up the announce inside the cast handler. +local function EscapeReplacement(s) + return (tostring(s):gsub("%%", "%%%%")) +end + +--- The template actually in force: the user's edit, or the active locale's +--- default when they never customised it. Single source of truth -- the options +--- page reads this rather than repeating the default string. +function ns.TeleportTemplate() + local stored = ns.P().teleport.template + if stored and stored ~= "" then return stored end + return EllesmereUI.L("Casting: {spell}, teleporting to: {dungeon}") +end + +local function BuildMessage(spellID, mapID) + local link = C_Spell.GetSpellLink(spellID) + or ("|cff71d5ff[" .. (C_Spell.GetSpellName(spellID) or "?") .. "]|r") + local msg = ns.TeleportTemplate() + :gsub("{spell}", EscapeReplacement(link)) + :gsub("{dungeon}", EscapeReplacement(ns.DungeonName(mapID, false))) + -- Chat rejects control characters and truncates past 255 bytes. + return (msg:gsub("\n", " "):gsub("[%z\1-\31\127]", ""):sub(1, 255)) +end + +local function OnSpellCast(_, _, unit, castGUID, spellID) + if unit ~= "player" then return end + local cfg = ns.P().teleport + if not cfg.announce then return end + if not IsInGroup() then return end + -- One cast can raise UNIT_SPELLCAST_START more than once (loading-screen + -- edge cases, self-cast frame refreshes), so dedup on castGUID. A restarted + -- cast carries a NEW guid, so also suppress the same spell inside a short + -- window -- teleport casts run ~10s, so 5s never swallows a real second cast. + if castGUID and castGUID == lastCastGUID then return end + local now = GetTime() + if lastSpellID == spellID and now - (lastAnnounceAt or 0) < 5 then return end + + local mapID = SpellToMapID(spellID) + if not mapID then return end + + lastCastGUID, lastSpellID, lastAnnounceAt = castGUID, spellID, now + local msg = BuildMessage(spellID, mapID) + if msg == "" then return end + -- A home-category RAID is not a party: sending to "PARTY" from a raid group + -- is silently dropped, so the raid channel has to be picked explicitly. + local channel + if IsInGroup(LE_PARTY_CATEGORY_INSTANCE) then + channel = "INSTANCE_CHAT" + elseif IsInRaid() then + channel = "RAID" + else + channel = "PARTY" + end + SendChatMessage(msg, channel) +end + +------------------------------------------------------------------------------- +-- Feature +------------------------------------------------------------------------------- +local EVENTS = { "ADDON_LOADED" } + +local function OnEvent(event, arg1) + if event == "ADDON_LOADED" and arg1 == "Blizzard_ChallengesUI" then + TryHookChallengesFrame() + end +end + +-- Unit-filtered registration needs its own frame (the shared fan-out in the core +-- file uses RegisterEvent), and it is created in this main chunk so the cast +-- handler bills this addon rather than the parent. Idle it holds no +-- registrations at all -- an unfiltered UNIT_SPELLCAST_START would otherwise +-- fire for every party, raid and nameplate unit in range. +local castFrame = CreateFrame("Frame") +castFrame:SetScript("OnEvent", OnSpellCast) + +local function SyncCastEvent(on) + if on then + castFrame:RegisterUnitEvent("UNIT_SPELLCAST_START", "player") + else + castFrame:UnregisterAllEvents() + end +end + +ns.RegisterFeature({ + id = "teleport", + + IsEnabled = function(p) + return p.teleport.enabled ~= false + end, + + Start = function() + TryHookChallengesFrame() + end, + + Apply = function(on) + enabled = on + ns.SetEvents(OnEvent, EVENTS, on) + SyncCastEvent(on and ns.P().teleport.announce == true) + if on then TryHookChallengesFrame() end + RefreshAllIcons() + end, +}) diff --git a/EllesmereUIMythicPlus/EUI_MythicPlus_Weekly.lua b/EllesmereUIMythicPlus/EUI_MythicPlus_Weekly.lua new file mode 100644 index 000000000..564f0678a --- /dev/null +++ b/EllesmereUIMythicPlus/EUI_MythicPlus_Weekly.lua @@ -0,0 +1,304 @@ +------------------------------------------------------------------------------- +-- EUI_MythicPlus_Weekly.lua -- weekly / season run panel +-- +-- A text panel docked beside the Group Finder listing this week's Mythic+ +-- runs; hold Shift to read the season's best instead. +-- +-- The panel is DOCKED, not free-floating: it parents to UIParent (never to +-- PVEFrame -- re-parenting into a secure frame's tree taints it) and anchors to +-- PVEFrame's top-right corner, so it tracks whatever the player does with the +-- Group Finder window. That is also why it is not an Unlock Mode element: its +-- position is derived from a Blizzard window rather than chosen on screen. Only +-- the dock offset is a setting. +------------------------------------------------------------------------------- +local ADDON_NAME, ns = ... + +local PANEL_W = 330 + +local panel, rows +local enabled = false +local frameHooked = false + +local function P() return ns.P() end + +------------------------------------------------------------------------------- +-- Rows +------------------------------------------------------------------------------- +local function GetRow(index) + local fs = rows[index] + if not fs then + fs = panel:CreateFontString(nil, "OVERLAY") + fs:SetJustifyH("LEFT") + rows[index] = fs + end + -- Set before any SetText: a FontString with no font raises + -- "SetText(): Font not set" the moment it is written to. + ns.SetFont(fs, P().weekly.fontSize or 14) + return fs +end + +local function ClearRows() + for _, fs in ipairs(rows) do + fs:SetText("") + fs:Hide() + fs:ClearAllPoints() + end +end + +--- Emit one row and return the next index. `extraGap` opens up a section break. +local function EmitRow(index, text, extraGap) + local fs = GetRow(index) + fs:ClearAllPoints() + if index == 1 then + fs:SetPoint("TOPLEFT", panel, "TOPLEFT", 0, 0) + else + local gap = (P().weekly.rowSpacing or 4) + (extraGap or 0) + fs:SetPoint("TOPLEFT", rows[index - 1], "BOTTOMLEFT", 0, -gap) + end + fs:Show() + fs:SetText(text) + return index + 1 +end + +------------------------------------------------------------------------------- +-- Content +------------------------------------------------------------------------------- +-- Vault reward item levels by keystone level (index 1..10). +-- +-- FALLBACK ONLY. The live number comes from Blizzard: +-- C_MythicPlus.GetRewardLevelForDifficultyLevel returns the current weekly +-- (Great Vault) reward level for any key level, so it follows a season rollover +-- with no edit here, and it answers levels past this table's tail correctly +-- instead of flattening everything above 10 onto the last entry. +-- +-- The table survives because that API cannot be trusted alone: it answers +-- nothing until C_MythicPlus.RequestMapInfo() data lands (Start below asks for +-- it), and it has a history of reporting the wrong reward level outright. +-- Refresh it each patch cycle alongside EllesmereUI.SEASON_PORTALS -- it is +-- only ever read when the API declines to answer. +local VAULT_ITEM_LEVELS = { 256, 259, 259, 263, 263, 266, 269, 269, 269, 272 } + +-- keystone level -> item level, memoized. Only Blizzard's answer is ever +-- stored: caching a fallback would pin it for the session and shut the API out +-- once its data finally lands. +local vaultIlvlCache = {} + +local function VaultItemLevel(keyLevel) + if type(keyLevel) ~= "number" or keyLevel < 1 then return nil end + local cached = vaultIlvlCache[keyLevel] + if cached then return cached end + + if C_MythicPlus and C_MythicPlus.GetRewardLevelForDifficultyLevel then + -- Reads 0 while the map info request is still in flight; that is + -- "unknown", not a reward level. + local weekly = C_MythicPlus.GetRewardLevelForDifficultyLevel(keyLevel) + if type(weekly) == "number" and weekly > 0 then + vaultIlvlCache[keyLevel] = weekly + return weekly + end + end + return VAULT_ITEM_LEVELS[math.min(keyLevel, #VAULT_ITEM_LEVELS)] +end + +-- Run-history slots that fill a Great Vault row. +local VAULT_SLOTS = { [1] = true, [4] = true, [8] = true } + +local function OwnClassText(text) + return ns.ClassColorText(select(2, UnitClass("player")), text) +end + +local function CompletionText(completed, level) + return (completed and "|cff00ff00" or "|cffff0000") .. (level or "") .. "|r" +end + +local function DungeonName(mapID) + return ns.DungeonName(mapID, P().useAbbreviation ~= false) +end + +--- Shared tail for both views: total run count, then one line per dungeon with +--- every level run in it. +local function BuildDungeonStats(runHistory, index) + local byDungeon, order = {}, {} + for _, run in ipairs(runHistory) do + local id = run.mapChallengeModeID + if not byDungeon[id] then + byDungeon[id] = {} + order[#order + 1] = id + end + local list = byDungeon[id] + list[#list + 1] = run + end + table.sort(order) + + index = EmitRow(index, + OwnClassText(EllesmereUI.L("Runs Completed") .. ": ") .. "|cffff8f00" .. #runHistory .. "|r", 10) + + for _, id in ipairs(order) do + local runs = byDungeon[id] + local text = DungeonName(id) .. " |cff9385ffx|r|cff0ffff2" .. #runs .. "|r - " + for i, run in ipairs(runs) do + text = text .. CompletionText(run.completed, run.level) + if i < #runs then text = text .. "|cff9d9d9d.|r" end + end + index = EmitRow(index, text) + end + return index +end + +local function BuildWeeklyView(runHistory) + ClearRows() + if #runHistory == 0 then + EmitRow(1, EllesmereUI.L("No Mythic+ runs this week")) + return + end + table.sort(runHistory, function(a, b) + if a.level == b.level then return a.mapChallengeModeID < b.mapChallengeModeID end + return a.level > b.level + end) + + local maxRows = P().weekly.maxRows or 8 + local index = EmitRow(1, OwnClassText(EllesmereUI.L("Weekly Best"))) + for i, run in ipairs(runHistory) do + if i > maxRows then break end + local text = CompletionText(run.completed, run.level) .. " " .. DungeonName(run.mapChallengeModeID) + if VAULT_SLOTS[i] then + local ilvl = VaultItemLevel(run.level) or "?" + text = text .. " |cffa335ee[" .. ilvl .. " " .. EllesmereUI.L("Item Level") .. "]|r" + end + index = EmitRow(index, text) + end + BuildDungeonStats(runHistory, index) +end + +local function BuildSeasonView(runHistory) + ClearRows() + if #runHistory == 0 then + EmitRow(1, EllesmereUI.L("No Mythic+ runs this season")) + return + end + + local bestByDungeon = {} + for _, run in ipairs(runHistory) do + if run.completed then + local id = run.mapChallengeModeID + if not bestByDungeon[id] or run.level > bestByDungeon[id].level then + bestByDungeon[id] = run + end + end + end + local best = {} + for _, run in pairs(bestByDungeon) do best[#best + 1] = run end + table.sort(best, function(a, b) + if a.level == b.level then return a.mapChallengeModeID < b.mapChallengeModeID end + return a.level > b.level + end) + + local maxRows = P().weekly.maxRows or 8 + local index = EmitRow(1, OwnClassText(EllesmereUI.L("Season Best"))) + for i, run in ipairs(best) do + if i > maxRows then break end + index = EmitRow(index, + "|c" .. ns.LevelColor(run.level) .. run.level .. "|r " .. DungeonName(run.mapChallengeModeID)) + end + BuildDungeonStats(runHistory, index) +end + +------------------------------------------------------------------------------- +-- Panel +------------------------------------------------------------------------------- +local function ApplyAnchor() + local cfg = P().weekly + panel:ClearAllPoints() + panel:SetPoint("TOPLEFT", PVEFrame, "TOPRIGHT", cfg.offsetX or 3, cfg.offsetY or -1) +end + +local function ShouldShow() + return enabled and PVEFrame and PVEFrame:IsShown() +end + +local Update -- forward declaration (the modifier subscription toggles with it) + +local function OnModifier() + Update() +end + +local function SyncModifierEvent() + local cfg = P() and P().weekly + local want = ShouldShow() and cfg and cfg.useModifierKey ~= false + ns.SetEvents(OnModifier, { "MODIFIER_STATE_CHANGED" }, want and true or false) +end + +function Update() + if not panel then return end + SyncModifierEvent() + if not ShouldShow() then + panel:Hide() + return + end + ApplyAnchor() + panel:Show() + + local seasonView = P().weekly.useModifierKey ~= false and IsShiftKeyDown() + if seasonView then + BuildSeasonView(C_MythicPlus.GetRunHistory(true, true) or {}) + else + BuildWeeklyView(C_MythicPlus.GetRunHistory(false, true) or {}) + end +end + +local function BuildPanel() + if panel then return end + rows = {} + panel = CreateFrame("Frame", "EllesmereUIMythicPlusWeekly", UIParent) + panel:SetSize(PANEL_W, 220) + panel:Hide() +end + +local function HookPVEFrame() + if frameHooked or not PVEFrame then return end + frameHooked = true + PVEFrame:HookScript("OnShow", function() Update() end) + PVEFrame:HookScript("OnHide", function() Update() end) +end + +------------------------------------------------------------------------------- +-- Feature +------------------------------------------------------------------------------- +local EVENTS = { "PLAYER_ENTERING_WORLD", "CHALLENGE_MODE_COMPLETED" } + +local function OnEvent(event) + -- A season rollover changes every vault reward level, so drop the memo on + -- the world transition that must follow one rather than trusting numbers + -- resolved under the old season. + if event == "PLAYER_ENTERING_WORLD" then + wipe(vaultIlvlCache) + end + -- Run history lands a moment after the event; one deferred pass settles it. + C_Timer.After(1, Update) +end + +ns.RegisterFeature({ + id = "weekly", + + IsEnabled = function(p) + return p.weekly.enabled ~= false + end, + + Start = function() + BuildPanel() + HookPVEFrame() + -- Unlocks the reward-level lookups above (and the rest of C_MythicPlus + -- alongside them). Asked for here, on first enable, so a disabled panel + -- still costs the game nothing. + if C_MythicPlus and C_MythicPlus.RequestMapInfo then + C_MythicPlus.RequestMapInfo() + end + end, + + Apply = function(on) + enabled = on + ns.SetEvents(OnEvent, EVENTS, on) + if on then HookPVEFrame() end + Update() + end, +}) diff --git a/EllesmereUIMythicPlus/EllesmereUIMythicPlus.lua b/EllesmereUIMythicPlus/EllesmereUIMythicPlus.lua new file mode 100644 index 000000000..d6f5f33c6 --- /dev/null +++ b/EllesmereUIMythicPlus/EllesmereUIMythicPlus.lua @@ -0,0 +1,384 @@ +------------------------------------------------------------------------------- +-- EllesmereUIMythicPlus.lua -- Mythic+ toolkit for EllesmereUI +-- +-- One addon, five independent features: the Group Finder dungeon-icon score +-- overlay, the weekly/season run panel docked to that window, one-click +-- teleport buttons with an optional party announce, a party keystone list and +-- an in-dungeon keystone banner. Every feature builds itself on first enable +-- and holds no events, frames or hooks while it is off. +-- +-- Season data is deliberately NOT duplicated here. The current season's +-- challenge maps come from C_ChallengeMode.GetMapTable(), and abbreviations +-- plus teleport spells resolve out of EllesmereUI.SEASON_PORTALS by matching +-- the localized dungeon name -- the same single season list _Keys.lua and +-- _TeleportPrompt.lua read. Refreshing that one list each patch cycle is all +-- the season maintenance this module needs. +------------------------------------------------------------------------------- +local ADDON_NAME, ns = ... + +local EMP = EllesmereUI.Lite.NewAddon(ADDON_NAME) +ns.EMP = EMP + +-- Key in EllesmereUI._addonKeyToFolder; drives the per-module font override. +ns.FONT_KEY = "mythicPlus" + +------------------------------------------------------------------------------- +-- Database defaults +------------------------------------------------------------------------------- +local DB_DEFAULTS = { + profile = { + enabled = true, + useAbbreviation = true, + + score = { + enabled = true, + hideBlizzardLevel = true, + nameShown = true, nameSize = 16, nameX = 0, nameY = 22, + levelShown = true, levelSize = 20, levelX = 0, levelY = 0, + scoreShown = true, scoreSize = 20, scoreX = 0, scoreY = -22, + }, + + weekly = { + enabled = true, + useModifierKey = true, + fontSize = 14, + rowSpacing = 4, + maxRows = 8, + offsetX = 3, + offsetY = -1, + }, + + teleport = { + enabled = true, + -- OFF by default: this is the only feature in the suite that speaks + -- to other players, and nobody should start broadcasting to their + -- group because they installed an addon. + announce = false, + -- Empty means "follow the active locale's default", so the announce + -- text keeps tracking the client language. Only a real user edit is + -- ever stored. + template = "", + }, + + keystoneList = { + enabled = true, + iconSize = 42, + fontSize = 12, + rowSpacing = 4, + }, + + centerBanner = { + enabled = true, + fontSize = 18, + rowSpacing = 6, + }, + }, +} + +local db +function ns.P() + return db and db.profile +end + +------------------------------------------------------------------------------- +-- Shared event fan-out +-- +-- ONE event frame, created in this file's main chunk so every handler's call +-- tree bills this addon rather than the parent (see the CPU-attribution note +-- in EllesmereUI_Lite.lua). Features subscribe when they turn on and +-- unsubscribe when they turn off; an event with no subscribers left is +-- unregistered outright, so a disabled feature really does receive nothing. +------------------------------------------------------------------------------- +local eventFrame = CreateFrame("Frame") +local eventSubs = {} -- event -> dense array of callbacks + +eventFrame:SetScript("OnEvent", function(_, event, ...) + local subs = eventSubs[event] + if not subs then return end + -- A handler routinely unsubscribes from inside its own dispatch (the weekly + -- panel drops MODIFIER_STATE_CHANGED the moment the Group Finder closes), so + -- the walk must not cache the length. table.remove shifts the tail down, so + -- a slot whose callback removed itself now holds the NEXT callback and has + -- to be re-tested rather than stepped over. + local i = 1 + while true do + local fn = subs[i] + if not fn then break end + fn(event, ...) + if subs[i] == fn then i = i + 1 end + end +end) + +--- Subscribe or unsubscribe one callback across a list of events. +-- Idempotent in both directions, so a feature can call it on every Apply +-- without tracking its own registration state. +function ns.SetEvents(fn, events, enabled) + for _, event in ipairs(events) do + local subs = eventSubs[event] + local at + if subs then + for i = 1, #subs do + if subs[i] == fn then at = i; break end + end + end + if enabled then + if not subs then subs = {}; eventSubs[event] = subs end + if not at then + subs[#subs + 1] = fn + if #subs == 1 then eventFrame:RegisterEvent(event) end + end + elseif at then + table.remove(subs, at) + if #subs == 0 then + eventSubs[event] = nil + eventFrame:UnregisterEvent(event) + end + end + end +end + +-- Self-disarming OnUpdate driver for this addon. The frame is born in this +-- main chunk, so every tick bills EllesmereUIMythicPlus (EllesmereUI_Ticker.lua). +ns.Drv = EllesmereUI.Tick and EllesmereUI.Tick.NewDriver(CreateFrame("Frame")) + +------------------------------------------------------------------------------- +-- Fonts +------------------------------------------------------------------------------- +function ns.GetFont(size) + local path = (EllesmereUI.GetFontPath and EllesmereUI.GetFontPath(ns.FONT_KEY)) + or "Fonts\\FRIZQT__.TTF" + local flag = (EllesmereUI.GetFontOutlineFlag and EllesmereUI.GetFontOutlineFlag(ns.FONT_KEY)) + or "" + return path, size or 12, flag +end + +--- Apply the module font at `size` to a FontString, priming the shadow so +--- unoutlined text stays readable over the dungeon art. +function ns.SetFont(fs, size) + local path, sz, flag = ns.GetFont(size) + if EllesmereUI.PrimeFontShadow then + EllesmereUI.PrimeFontShadow(fs, flag == "") + end + fs:SetFont(path, sz, flag) +end + +------------------------------------------------------------------------------- +-- Colors +-- +-- Keystone levels and dungeon scores are tinted with the item-quality ramp: +-- every two key levels moves one quality step, and +15 and above jumps a +-- further step so the top brackets stay distinguishable. +------------------------------------------------------------------------------- +local LEVEL_COLORS = { + [0] = "ffffffff", + [1] = "ff1eff00", + [2] = "ff0070dd", + [3] = "ffa335ee", + [4] = "ffff8000", + [5] = "ffe6cc80", + [6] = "fff46fc8", +} +local OVERTIME_COLOR = "ffa3a3a3" + +local SCORE_RANGES = { + { minScore = 455, index = 6 }, + { minScore = 410, index = 5 }, + { minScore = 380, index = 4 }, + { minScore = 320, index = 3 }, + { minScore = 230, index = 2 }, + { minScore = 155, index = 1 }, + { minScore = 0, index = 0 }, +} + +function ns.LevelColor(level, overtime) + if overtime then return OVERTIME_COLOR end + if not level or level <= 0 then return LEVEL_COLORS[0] end + local bump = level >= 15 and 1 or 0 + local idx = math.min(math.floor((math.max(level, 2) - 2) / 2), 5) + bump + return LEVEL_COLORS[idx] or LEVEL_COLORS[0] +end + +function ns.ScoreColor(score) + if not score or score == 0 then return LEVEL_COLORS[0] end + for _, range in ipairs(SCORE_RANGES) do + if score >= range.minScore then + return LEVEL_COLORS[range.index] or LEVEL_COLORS[0] + end + end + return LEVEL_COLORS[0] +end + +--- "|cffRRGGBBtext|r" using a unit's or classID's class color. +function ns.ClassColorText(classFile, text) + local c = classFile and RAID_CLASS_COLORS and RAID_CLASS_COLORS[classFile] + if not c then return text end + return string.format("|cff%02x%02x%02x%s|r", c.r * 255, c.g * 255, c.b * 255, text) +end + +function ns.ClassFileFromID(classID) + if not classID or not GetClassInfo then return nil end + return (select(2, GetClassInfo(classID))) +end + +------------------------------------------------------------------------------- +-- Season data +-- +-- Built lazily and only cached once every current-season map resolves a name: +-- an early call with partially-loaded map data retries on the next use instead +-- of freezing an incomplete table. +------------------------------------------------------------------------------- +local seasonMapIDs -- ordered array of challenge mapIDs +local seasonInfo -- mapID -> { abbr = "WRS", spellIDs = { id, ... } } +local seasonByName -- localized instance name -> mapID + +local function BuildSeasonData() + if seasonInfo then return true end + if not (C_ChallengeMode and C_ChallengeMode.GetMapTable and C_ChallengeMode.GetMapUIInfo) then + return false + end + local maps = C_ChallengeMode.GetMapTable() + if not maps or #maps == 0 then return false end + + -- Lowercase dungeon name -> SEASON_PORTALS entry (short name + teleports). + local portalByName = {} + for _, entry in ipairs(EllesmereUI.SEASON_PORTALS or {}) do + for _, name in ipairs(entry.names or {}) do + portalByName[name] = entry + end + end + + local ids, info, byName = {}, {}, {} + for _, mapID in ipairs(maps) do + local name = C_ChallengeMode.GetMapUIInfo(mapID) + if not name then return false end + ids[#ids + 1] = mapID + byName[name] = mapID + + local portal = portalByName[name:lower()] + local spellIDs + if portal and portal.spellID then + spellIDs = { portal.spellID } + for _, alt in ipairs(portal.altSpellIDs or {}) do + spellIDs[#spellIDs + 1] = alt + end + end + info[mapID] = { abbr = portal and portal.short, spellIDs = spellIDs } + end + + seasonMapIDs, seasonInfo, seasonByName = ids, info, byName + return true +end +ns.BuildSeasonData = BuildSeasonData + +function ns.SeasonMapIDs() + if not BuildSeasonData() then return nil end + return seasonMapIDs +end + +function ns.IsSeasonMap(mapID) + if not mapID or not BuildSeasonData() then return false end + return seasonInfo[mapID] ~= nil +end + +function ns.MapIDFromName(instanceName) + if not instanceName or not BuildSeasonData() then return nil end + return seasonByName[instanceName] +end + +--- Teleport spell ids for a dungeon, best-known first. nil when the current +--- SEASON_PORTALS list has no entry for that dungeon. +function ns.TeleportSpells(mapID) + if not mapID or not BuildSeasonData() then return nil end + local info = seasonInfo[mapID] + return info and info.spellIDs +end + +--- Short abbreviation when the user asked for it and the season list has one, +--- otherwise the localized full dungeon name. +function ns.DungeonName(mapID, useAbbreviation) + if useAbbreviation and BuildSeasonData() then + local info = seasonInfo[mapID] + if info and info.abbr then return info.abbr end + end + if C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then + local name = C_ChallengeMode.GetMapUIInfo(mapID) + if name then return name end + end + return EllesmereUI.L("Unknown") +end + +--- Dungeon icon texture for a keystone row, with a generic key as fallback. +local FALLBACK_KEY_ICON = [[Interface\Icons\INV_Misc_Key_14]] +function ns.DungeonIcon(mapID) + if mapID and mapID ~= 0 and C_ChallengeMode and C_ChallengeMode.GetMapUIInfo then + local _, _, _, texture = C_ChallengeMode.GetMapUIInfo(mapID) + if texture and texture ~= 0 then return texture end + end + return FALLBACK_KEY_ICON +end + +------------------------------------------------------------------------------- +-- Feature registry +-- +-- Every feature file registers one table here. Start() runs once, the first +-- time the feature is enabled (that is what keeps a disabled feature at zero +-- cost); Apply(on) then runs on every settings change with the resolved +-- on/off state so a started feature can hide and unhook itself again. +------------------------------------------------------------------------------- +ns.features = {} + +function ns.RegisterFeature(feature) + ns.features[#ns.features + 1] = feature +end + +local function ApplyAll() + local p = ns.P() + if not p then return end + local moduleOn = p.enabled ~= false + for _, feature in ipairs(ns.features) do + local on = moduleOn and feature.IsEnabled(p) + if on and not feature._started then + feature._started = true + feature.Start() + end + if feature._started then + feature.Apply(on) + end + end +end +ns.Apply = ApplyAll +_G._EMP_Apply = ApplyAll + +-- Unlock-element registration is MODULE level, not per feature: the keystone +-- list and banner share one registration call, and hanging it off either +-- feature's table would silently drop the other element if that feature were +-- ever reordered or removed. +local unlockHooks = {} + +function ns.RegisterUnlockHook(fn) + unlockHooks[#unlockHooks + 1] = fn +end + +--- Re-register every unlock element. Called by the profile system after a +--- profile apply, where the outgoing profile's registrations are stale. +local function RegisterUnlock() + for _, fn in ipairs(unlockHooks) do + fn() + end +end +ns.RegisterUnlock = RegisterUnlock +_G._EMP_RegisterUnlock = RegisterUnlock + +------------------------------------------------------------------------------- +-- Lifecycle +------------------------------------------------------------------------------- +function EMP:OnInitialize() + db = EllesmereUI.Lite.NewDB("EllesmereUIMythicPlusDB", DB_DEFAULTS) + ns.db = db + _G._EMP_DB = db +end + +function EMP:OnEnable() + ApplyAll() + RegisterUnlock() +end diff --git a/EllesmereUIMythicPlus/EllesmereUIMythicPlus.toc b/EllesmereUIMythicPlus/EllesmereUIMythicPlus.toc new file mode 100644 index 000000000..e15a76f8c --- /dev/null +++ b/EllesmereUIMythicPlus/EllesmereUIMythicPlus.toc @@ -0,0 +1,22 @@ +## Interface: 120000, 120001, 120005, 120007, 120100 +## Title: |cff0cd29fEllesmereUI|r Mythic+ +## Category: |cff0cd29fEllesmere|rUI +## Group: EllesmereUI +## Notes: Dungeon scores, weekly runs, teleports and party keystones. +## Author: Ellesmere +## Version: 8.7.6 +## Dependencies: EllesmereUI +## SavedVariables: EllesmereUIMythicPlusDB +## IconTexture: Interface\AddOns\EllesmereUI\media\eg-logo.tga + +# Core: database, season data, shared colors/fonts, feature registry +EllesmereUIMythicPlus.lua + +# Features (each self-gates and costs nothing while disabled) +EUI_MythicPlus_Score.lua +EUI_MythicPlus_Weekly.lua +EUI_MythicPlus_Teleport.lua +EUI_MythicPlus_Keystones.lua + +# Options +EUI_MythicPlus_Options.lua diff --git a/EllesmereUI_FirstInstall.lua b/EllesmereUI_FirstInstall.lua index 0bc08669a..455eef053 100644 --- a/EllesmereUI_FirstInstall.lua +++ b/EllesmereUI_FirstInstall.lua @@ -74,6 +74,7 @@ local GROUPS = { { label = "Chat", addon = "EllesmereUIChat" }, { label = "Bags", addon = "EllesmereUIBags" }, { label = "Mythic+ Timer", addon = "EllesmereUIMythicTimer" }, + { label = "Mythic+ Tools", addon = "EllesmereUIMythicPlus" }, { label = "Quest Tracker", addon = "EllesmereUIQuestTracker" }, }, }, diff --git a/EllesmereUI_GlobalSearch.lua b/EllesmereUI_GlobalSearch.lua index d538afb4c..a3a922a04 100644 --- a/EllesmereUI_GlobalSearch.lua +++ b/EllesmereUI_GlobalSearch.lua @@ -184,6 +184,7 @@ local function BuildModuleAliases() -- registered a module simply don't get their shorthands. local EXTRA_ALIASES = { EllesmereUIMythicTimer = { "m+ timer", "m+" }, + EllesmereUIMythicPlus = { "mythic plus", "keystone", "key", "m+ score" }, EllesmereUICooldownManager = { "cdm" }, } for folder, list in pairs(EXTRA_ALIASES) do diff --git a/EllesmereUI_Profiles.lua b/EllesmereUI_Profiles.lua index 3a772ebba..23111ad1e 100644 --- a/EllesmereUI_Profiles.lua +++ b/EllesmereUI_Profiles.lua @@ -75,6 +75,7 @@ local ADDON_DB_MAP = { { folder = "EllesmereUIBags", display = "Bags", svName = "EllesmereUIBagsDB", suffix = "Bags" }, { folder = "EllesmereUIFriends", display = "Friends List", svName = "EllesmereUIFriendsDB", suffix = "Friends" }, { folder = "EllesmereUIMythicTimer", display = "Mythic+ Timer", svName = "EllesmereUIMythicTimerDB", suffix = "MythicTimer" }, + { folder = "EllesmereUIMythicPlus", display = "Mythic+ Tools", svName = "EllesmereUIMythicPlusDB", suffix = "MythicPlus" }, { folder = "EllesmereUIQuestTracker", display = "Quest Tracker", svName = "EllesmereUIQuestTrackerDB", suffix = "QuestTracker" }, { folder = "EllesmereUIMinimap", display = "Minimap", svName = "EllesmereUIMinimapDB", suffix = "Minimap" }, { folder = "EllesmereUIDamageMeters", display = "Damage Meters", svName = "EllesmereUIDamageMetersDB", suffix = "DamageMeters" }, @@ -193,6 +194,7 @@ local KEY_PREFIX_FOLDER = { ["EBS_"] = "EllesmereUIMinimap", ["EDR_"] = "EllesmereUIBlizzardSkin", ["EMT_"] = "EllesmereUIMythicTimer", + ["EMP_"] = "EllesmereUIMythicPlus", ["EABR_"] = "EllesmereUIAuraBuffReminders", ["RF_"] = "EllesmereUIRaidFrames", ["ECL_"] = "EllesmereUIQoL", @@ -1424,6 +1426,8 @@ function EllesmereUI.RefreshAllAddons() if _G._EFR_ApplyFriends then _G._EFR_ApplyFriends() end -- Mythic Timer if _G._EMT_Apply then _G._EMT_Apply() end + -- Mythic+ (score overlay, weekly panel, teleports, keystone displays) + if _G._EMP_Apply then _G._EMP_Apply() end -- Damage Meters if _G._EDM_Apply then _G._EDM_Apply() end -- DataBars (bar set + blocks + layout + positions are all per-profile) @@ -1446,6 +1450,7 @@ function EllesmereUI.RefreshAllAddons() if _G._ECL_RegisterUnlock then _G._ECL_RegisterUnlock() end if _G._EUI_BattleRes_RegisterUnlock then _G._EUI_BattleRes_RegisterUnlock() end if _G._EDB_RegisterUnlock then _G._EDB_RegisterUnlock() end + if _G._EMP_RegisterUnlock then _G._EMP_RegisterUnlock() end -- After all addons have rebuilt and positioned their frames from -- db.profile.positions, re-apply centralized grow-direction positioning -- (handles lazy migration of imported TOPLEFT positions to CENTER format) diff --git a/EllesmereUI_SpecOverrides.lua b/EllesmereUI_SpecOverrides.lua index d6dc5fb40..224904f38 100644 --- a/EllesmereUI_SpecOverrides.lua +++ b/EllesmereUI_SpecOverrides.lua @@ -82,6 +82,7 @@ local FOLDER_BLACKLIST = { EllesmereUIDragonRiding = true, EllesmereUIDamageMeters = true, EllesmereUIMythicTimer = true, + EllesmereUIMythicPlus = true, EllesmereUIQuestTracker = true, EllesmereUIFriends = true, EllesmereUIBags = true, @@ -113,6 +114,10 @@ local REFRESH_FNS = { EllesmereUIChat = { "_ECHAT_RefreshAll" }, EllesmereUIFriends = { "_EFR_ApplyFriends" }, EllesmereUIMythicTimer = { "_EMT_Apply" }, + -- Folder is capture/apply-blacklisted (see FOLDER_BLACKLIST); this entry is + -- insurance so a key that slips through can never hit the unmapped-folder + -- fallback and escalate into a full RefreshAllAddons. + EllesmereUIMythicPlus = { "_EMP_Apply" }, EllesmereUIDamageMeters = { "_EDM_Apply" }, EllesmereUIDataBars = { "_EDB_Apply" }, EllesmereUIAuraBuffReminders = { "_EABR_RequestRefresh", "_EABR_ApplyUnlockPos" }, @@ -2852,6 +2857,7 @@ local EXCLUDED_CONTEXTS = { ["EllesmereUIBlizzardSkin"] = true, ["EllesmereUIDamageMeters"] = true, ["EllesmereUIMythicTimer"] = true, + ["EllesmereUIMythicPlus"] = true, ["EllesmereUIQuestTracker"] = true, ["EllesmereUIFriends"] = true, ["EllesmereUIBags"] = true, diff --git a/Locales/_keys.txt b/Locales/_keys.txt index 421a2ea29..f0daea8bf 100644 --- a/Locales/_keys.txt +++ b/Locales/_keys.txt @@ -1,6 +1,6 @@ # Auto-generated by .tools/extract-locale-keys.sh -- do not edit by hand. # Canonical list of translatable English keys passed as string literals -# (661 unique). Regenerate after wrapping new strings. Keys passed as +# (673 unique). Regenerate after wrapping new strings. Keys passed as # variables are not listed here -- use the in-game /euiloc harvester for # the complete runtime set. (Raid) @@ -135,6 +135,7 @@ CDM Buttons can have all their glow and active\nstates changed on a per icon (or Calendar Cancel Cast Timer Settings +Casting: {spell}, teleporting to: {dungeon} Categories Change buff Changes the crosshair color when your current target is out of range. @@ -162,6 +163,7 @@ Context Menu Continue Convert to Party Convert to Raid +Cooldown unavailable Cooldowns Coordinates\nDisabled Coordinates\nEnabled @@ -228,6 +230,7 @@ Drag to Reorder Drag to Reorder Bars Drag to Reorder Roles Draw tick lines across the bar at positions you choose. +Dungeon Teleport Duplicate Keybind Duration (seconds) Dynamic Rez @@ -341,6 +344,7 @@ Items Keep Mine Keybind Keybind: %1$s +Keystone Owners Later Layout Leech @@ -385,10 +389,13 @@ New Patch Reminder Dot New Profile No No Bars - Click to Add +No Mythic+ runs this season +No Mythic+ runs this week No Saved Presets No buffs assigned. Right click a button in the preview to assign buffs. No death recap available No excluded debuffs. +No keystone No patch notes yet. No spec macros for No spells yet. Add spell IDs above. @@ -491,6 +498,7 @@ Right-click: favorite Right-click: remove favorite Role Rune +Runs Completed Save Save & Exit Save Style Preset @@ -499,6 +507,7 @@ Search Features... Search Module Settings... Search Spells Search... +Season Best Seconds left when threshold text starts (0 = off) Seconds the active state shows after use Section @@ -569,6 +578,8 @@ Target Target Unit Targeting: Targets +Teleport not learned +Teleport ready The pasted string could not be read completely. Please paste it again. The single stack threshold is off while Multiple Thresholds is on. Thickness @@ -622,6 +633,7 @@ W Match W Matched WARNING: Frame positions may be off. Warbank +Weekly Best Welcome to EllesmereUI What's new in EllesmereUI. Whistle diff --git a/Locales/zhCN.lua b/Locales/zhCN.lua index 30ec89ee3..b6e353651 100644 --- a/Locales/zhCN.lua +++ b/Locales/zhCN.lua @@ -6749,3 +6749,57 @@ L["Sidebar Width"] = "侧边栏宽度" L["Edit Box Height"] = "输入框高度" L["Edit Box Font"] = "输入框字体" L["Edit Box Font Size"] = "输入框字体大小" + +-- == Mythic+ (score overlay, weekly panel, teleports, keystones) ========== +L["Teleport not learned"] = "未学会传送" +L["Cooldown unavailable"] = "冷却未知" +L["Teleport ready"] = "传送就绪" +L["Dungeon Teleport"] = "副本传送" +L["Casting: {spell}, teleporting to: {dungeon}"] = "正在施放:{spell},传送到:{dungeon}" +L["Runs Completed"] = "完成次数" +L["No Mythic+ runs this week"] = "本周没有大秘境记录" +L["No Mythic+ runs this season"] = "本赛季没有大秘境记录" +L["Weekly Best"] = "本周最佳" +L["Season Best"] = "赛季最佳" +L["Dungeon scores, weekly runs, teleports and party keystones."] = "副本得分、每周记录、副本传送与队伍钥石。" +L["Dungeon Icons"] = "副本图标" +L["Weekly Panel"] = "每周面板" +L["Keystones"] = "钥石" +L["SCORE OVERLAY"] = "得分覆盖" +L["TELEPORTS"] = "副本传送" +L["WEEKLY PANEL"] = "每周面板" +L["DOCK OFFSET"] = "停靠偏移" +L["PARTY KEYSTONES"] = "队伍钥石" +L["KEYSTONE OWNERS"] = "钥石持有者" +L["Enable Mythic+"] = "启用大秘境" +L["Abbreviate Dungeon Names"] = "使用副本缩写" +L["Show Score Overlay"] = "显示得分覆盖" +L["Hide Blizzard Key Level"] = "隐藏暴雪层数文本" +L["Dungeon Name Size"] = "副本名称字号" +L["Dungeon Name X"] = "副本名称 X" +L["Dungeon Name Y"] = "副本名称 Y" +L["Show Best Level"] = "显示最高层数" +L["Best Level Size"] = "最高层数字号" +L["Best Level X"] = "最高层数 X" +L["Best Level Y"] = "最高层数 Y" +L["Show Dungeon Score"] = "显示副本得分" +L["Dungeon Score Size"] = "副本得分字号" +L["Dungeon Score X"] = "副本得分 X" +L["Dungeon Score Y"] = "副本得分 Y" +L["Teleport Buttons"] = "传送按钮" +L["Announce Teleports"] = "广播传送" +L["Announce Message"] = "广播消息" +L["Edit Message"] = "编辑消息" +L["Reset Message"] = "重置消息" +L["Show Weekly Panel"] = "显示每周面板" +L["Hold Shift for Season Best"] = "按住 Shift 查看赛季最佳" +L["Show Party Keystones"] = "显示队伍钥石" +L["List Font Size"] = "列表字号" +L["List Row Spacing"] = "列表行间距" +L["Show Keystone Owners"] = "显示钥石持有者" +L["Banner Font Size"] = "横幅字号" +L["Banner Row Spacing"] = "横幅行间距" +L["Keystone Owners"] = "钥石持有者" +L["Mythic+ Tools"] = "大秘境工具" +L["Preview with Sample Data"] = "用示例数据预览" +L["Hide Preview"] = "关闭预览" diff --git a/Locales/zhTW.lua b/Locales/zhTW.lua index 2652a1bda..faf2ff4df 100644 --- a/Locales/zhTW.lua +++ b/Locales/zhTW.lua @@ -5660,3 +5660,59 @@ L["Sidebar Width"] = "側邊欄寬度" L["Edit Box Height"] = "輸入框高度" L["Edit Box Font"] = "輸入框字型" L["Edit Box Font Size"] = "輸入框字型大小" + +-- == Mythic+ (score overlay, weekly panel, teleports, keystones) ========== +L["Teleport not learned"] = "未學會傳送" +L["Cooldown unavailable"] = "冷卻未知" +L["Teleport ready"] = "傳送就緒" +L["Dungeon Teleport"] = "副本傳送" +L["Casting: {spell}, teleporting to: {dungeon}"] = "正在施放:{spell},傳送到:{dungeon}" +L["Runs Completed"] = "完成次數" +L["No Mythic+ runs this week"] = "本週沒有大秘境紀錄" +L["No Mythic+ runs this season"] = "本賽季沒有大秘境紀錄" +L["Weekly Best"] = "本週最佳" +L["Season Best"] = "賽季最佳" +L["Dungeon scores, weekly runs, teleports and party keystones."] = "副本分數、每週紀錄、副本傳送與隊伍鑰石。" +L["Dungeon Icons"] = "副本圖示" +L["Weekly Panel"] = "每週面板" +L["Keystones"] = "鑰石" +L["SCORE OVERLAY"] = "分數覆蓋" +L["TELEPORTS"] = "副本傳送" +L["WEEKLY PANEL"] = "每週面板" +L["DOCK OFFSET"] = "停靠偏移" +L["PARTY KEYSTONES"] = "隊伍鑰石" +L["KEYSTONE OWNERS"] = "鑰石持有者" +L["Enable Mythic+"] = "啟用大秘境" +L["Abbreviate Dungeon Names"] = "使用副本縮寫" +L["Show Score Overlay"] = "顯示分數覆蓋" +L["Hide Blizzard Key Level"] = "隱藏暴雪層數文字" +L["Dungeon Name Size"] = "副本名稱字級" +L["Dungeon Name X"] = "副本名稱 X" +L["Dungeon Name Y"] = "副本名稱 Y" +L["Show Best Level"] = "顯示最高層數" +L["Best Level Size"] = "最高層數字級" +L["Best Level X"] = "最高層數 X" +L["Best Level Y"] = "最高層數 Y" +L["Show Dungeon Score"] = "顯示副本分數" +L["Dungeon Score Size"] = "副本分數字級" +L["Dungeon Score X"] = "副本分數 X" +L["Dungeon Score Y"] = "副本分數 Y" +L["Teleport Buttons"] = "傳送按鈕" +L["Announce Teleports"] = "廣播傳送" +L["Announce Message"] = "廣播訊息" +L["Edit Message"] = "編輯訊息" +L["Reset Message"] = "重置訊息" +L["Show Weekly Panel"] = "顯示每週面板" +L["Hold Shift for Season Best"] = "按住 Shift 查看賽季最佳" +L["Max Rows"] = "最大行數" +L["Row Spacing"] = "行間距" +L["Show Party Keystones"] = "顯示隊伍鑰石" +L["List Font Size"] = "列表字級" +L["List Row Spacing"] = "列表行間距" +L["Show Keystone Owners"] = "顯示鑰石持有者" +L["Banner Font Size"] = "橫幅字級" +L["Banner Row Spacing"] = "橫幅行間距" +L["Keystone Owners"] = "鑰石持有者" +L["Mythic+ Tools"] = "大祕境工具" +L["Preview with Sample Data"] = "用範例資料預覽" +L["Hide Preview"] = "關閉預覽"