From ebb4b056c3000c315352c1c3be42b3dcf95a4a9e Mon Sep 17 00:00:00 2001 From: Brues <5278969+brues-code@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:40:50 -0600 Subject: [PATCH] Nameplates: drive every plate from unit events, drop the per-tick loop The module polled: a central OnUpdate at 100 Hz over every visible plate, a full OnDataChanged refresh of each plate every 0.5 s, and per-tick reads of the engine's name/level regions, frame size, mouse state and SpellIsTargeting. ClassicAPI feeds every unit event to the nameplateN tokens, so none of that is needed. Each bound plate now registers its unit events on its own frame against its token (UNIT_HEALTH/MAXHEALTH, UNIT_AURA_DELTA, UNIT_FLAGS, UNIT_DYNAMIC_FLAGS, UNIT_FACTION, UNIT_LEVEL, UNIT_CLASSIFICATION_CHANGED, UNIT_NAME_UPDATE, UNIT_TARGET, the casts) and each handler updates only what that event can change; the module-level events (target, combat, level-up, roster, guild, spell targeting) fan out to the plates they concern. Health comes from UnitHealth on the token instead of the engine bar's OnValueChanged hook; name, level and difficulty colour from UnitName/UnitLevel/GetDifficultyColor instead of the blanked engine regions (UnitLevel's -1 is exactly the engine's skull case, so "??B" survives). The engine resets the plate's size at every bind, so the engine frame's size and click routing are re-applied from its OnShow instead of being checked every tick; the overlay steps aside for ground-target spells on CURRENT_SPELL_CAST_CHANGED. The only OnUpdate left is a driver frame, shown while a castbar sweeps or the target zoom animates and hidden when both sets are empty; the zoom is time-based now rather than per-tick multiplication. The nameplates/nameplates_target/nameplates_mass throttle categories no longer mean anything and are gone from libthrottle and the GUI; nameplates_castbar stays as the sweep rate of non-target castbars. Mouseover tracking is gone (the token already names the unit), and libhealth's "known" return now decides whether absolute health is shown, so a unit whose health is only known as a percentage no longer prints "57 | 57%". --- env/translations_deDE.lua | 2 - env/translations_enUS.lua | 2 - libs/libthrottle.lua | 6 - modules/gui.lua | 180 +-- modules/nameplates.lua | 2661 +++++++++++++++++-------------------- 5 files changed, 1237 insertions(+), 1614 deletions(-) diff --git a/env/translations_deDE.lua b/env/translations_deDE.lua index 0faac4bd..e95df008 100644 --- a/env/translations_deDE.lua +++ b/env/translations_deDE.lua @@ -909,8 +909,6 @@ pfUI_translation["deDE"] = { ["Panel Alignment"] = "Panel Ausrichtung", ["Nameplate Update Rate"] = "Namensplaketten Aktualisierungsrate", ["Target/Casting Plates"] = "Ziel/Cast Plaketten", - ["Normal Plates"] = "Normale Plaketten", - ["Mass Pulls (20+ Plates)"] = "Große Pulls (20+ Plaketten)", ["Tooltip Update Rate"] = "Tooltip Aktualisierungsrate", ["Cursor Follow"] = "Cursor Folgen", ["Health/Status Bar"] = "Leben/Status Leiste", diff --git a/env/translations_enUS.lua b/env/translations_enUS.lua index e5115a0d..acc44c85 100644 --- a/env/translations_enUS.lua +++ b/env/translations_enUS.lua @@ -941,8 +941,6 @@ pfUI_translation["enUS"] = { ["Panel Alignment"] = nil, ["Nameplate Update Rate"] = nil, ["Target/Casting Plates"] = nil, - ["Normal Plates"] = nil, - ["Mass Pulls (20+ Plates)"] = nil, ["Tooltip Update Rate"] = nil, ["Cursor Follow"] = nil, ["Health/Status Bar"] = nil, diff --git a/libs/libthrottle.lua b/libs/libthrottle.lua index 1acd13d9..a985e9e1 100644 --- a/libs/libthrottle.lua +++ b/libs/libthrottle.lua @@ -31,10 +31,7 @@ libthrottle.presetNames = { -- Default throttle categories libthrottle.defaults = { - nameplates = "custom", - nameplates_target = "custom", nameplates_castbar = "custom", - nameplates_mass = "custom", tooltip_cursor = "fastest", chat_tab = "custom", swingtimer = "custom", @@ -158,10 +155,7 @@ libthrottle:SetScript("OnEvent", function() end -- Set defaults for custom fields if missing - if not _G.pfUI_throttle.nameplates_target_custom then _G.pfUI_throttle.nameplates_target_custom = "50" end - if not _G.pfUI_throttle.nameplates_custom then _G.pfUI_throttle.nameplates_custom = "10" end if not _G.pfUI_throttle.nameplates_castbar_custom then _G.pfUI_throttle.nameplates_castbar_custom = "100" end - if not _G.pfUI_throttle.nameplates_mass_custom then _G.pfUI_throttle.nameplates_mass_custom = "7" end if not _G.pfUI_throttle.tooltip_cursor_custom then _G.pfUI_throttle.tooltip_cursor_custom = "10" end if not _G.pfUI_throttle.chat_tab_custom then _G.pfUI_throttle.chat_tab_custom = "10" end if not _G.pfUI_throttle.swingtimer_custom then _G.pfUI_throttle.swingtimer_custom = "50" end diff --git a/modules/gui.lua b/modules/gui.lua index c0657b3f..c4f32b78 100644 --- a/modules/gui.lua +++ b/modules/gui.lua @@ -1669,115 +1669,6 @@ pfUI:RegisterModule("gui", function () local header = CreateConfig(nil, T["Nameplate Update Rate"], nil, nil, "header") header:GetParent().objectCount = header:GetParent().objectCount - 1 header:SetHeight(20) - - local targetCustom -- declare first so callback can use it - - CreateConfig(function() - -- Callback when dropdown changes - update custom field immediately - if targetCustom and targetCustom.input then - local isCustom = pfUI.throttle:IsCustom("nameplates_target") - if not isCustom then - -- Preset selected - show FPS from preset, make readonly - targetCustom.input:EnableMouse(false) - targetCustom.input:EnableKeyboard(false) - targetCustom.input:ClearFocus() - targetCustom.input:SetTextColor(.5,.5,.5,1) - targetCustom.input:SetText(tostring(pfUI.throttle:GetFps("nameplates_target"))) - else - -- Custom selected - make editable - targetCustom.input:EnableMouse(true) - targetCustom.input:EnableKeyboard(true) - targetCustom.input:SetTextColor(.2,1,.8,1) - if _G.pfUI_throttle.nameplates_target_custom then - targetCustom.input:SetText(_G.pfUI_throttle.nameplates_target_custom) - end - end - end - end, T["Target Plates"], _G.pfUI_throttle, "nameplates_target", "dropdown", { - "very_slow:" .. T["Very Slow"] .. " (2 FPS)", - "slow:" .. T["Slow"] .. " (5 FPS)", - "normal:" .. T["Normal"] .. " (10 FPS)", - "fast:" .. T["Fast"] .. " (20 FPS)", - "very_fast:" .. T["Very Fast"] .. " (30 FPS)", - "fastest:" .. T["Fastest"] .. " (50 FPS)", - "custom:" .. T["Custom"], - }) - - -- Now create custom field AFTER dropdown - targetCustom = CreateConfig(nil, T["Custom FPS"], _G.pfUI_throttle, "nameplates_target_custom") - - -- Set initial state - local isCustom = pfUI.throttle:IsCustom("nameplates_target") - if not isCustom then - targetCustom.input:EnableMouse(false) - targetCustom.input:EnableKeyboard(false) - targetCustom.input:SetTextColor(.5,.5,.5,1) - targetCustom.input:SetText(tostring(pfUI.throttle:GetFps("nameplates_target"))) - else - targetCustom.input:EnableMouse(true) - targetCustom.input:EnableKeyboard(true) - targetCustom.input:SetTextColor(.2,1,.8,1) - if _G.pfUI_throttle.nameplates_target_custom then - targetCustom.input:SetText(_G.pfUI_throttle.nameplates_target_custom) - end - end - - -- Spacer after custom field - local spacer1 = CreateConfig(nil, " ", nil, nil, "header") - spacer1:GetParent().objectCount = spacer1:GetParent().objectCount - 1 - spacer1:SetHeight(5) - - local normalCustom - - CreateConfig(function() - if normalCustom and normalCustom.input then - local isCustom = pfUI.throttle:IsCustom("nameplates") - if not isCustom then - normalCustom.input:EnableMouse(false) - normalCustom.input:EnableKeyboard(false) - normalCustom.input:ClearFocus() - normalCustom.input:SetTextColor(.5,.5,.5,1) - normalCustom.input:SetText(tostring(pfUI.throttle:GetFps("nameplates"))) - else - normalCustom.input:EnableMouse(true) - normalCustom.input:EnableKeyboard(true) - normalCustom.input:SetTextColor(.2,1,.8,1) - if _G.pfUI_throttle.nameplates_custom then - normalCustom.input:SetText(_G.pfUI_throttle.nameplates_custom) - end - end - end - end, T["Normal Plates"], _G.pfUI_throttle, "nameplates", "dropdown", { - "very_slow:" .. T["Very Slow"] .. " (2 FPS)", - "slow:" .. T["Slow"] .. " (5 FPS)", - "normal:" .. T["Normal"] .. " (10 FPS)", - "fast:" .. T["Fast"] .. " (20 FPS)", - "very_fast:" .. T["Very Fast"] .. " (30 FPS)", - "fastest:" .. T["Fastest"] .. " (50 FPS)", - "custom:" .. T["Custom"], - }) - - normalCustom = CreateConfig(nil, T["Custom FPS"], _G.pfUI_throttle, "nameplates_custom") - - local isCustom2 = pfUI.throttle:IsCustom("nameplates") - if not isCustom2 then - normalCustom.input:EnableMouse(false) - normalCustom.input:EnableKeyboard(false) - normalCustom.input:SetTextColor(.5,.5,.5,1) - normalCustom.input:SetText(tostring(pfUI.throttle:GetFps("nameplates"))) - else - normalCustom.input:EnableMouse(true) - normalCustom.input:EnableKeyboard(true) - normalCustom.input:SetTextColor(.2,1,.8,1) - if _G.pfUI_throttle.nameplates_custom then - normalCustom.input:SetText(_G.pfUI_throttle.nameplates_custom) - end - end - - -- Spacer after custom field - local spacer2 = CreateConfig(nil, " ", nil, nil, "header") - spacer2:GetParent().objectCount = spacer2:GetParent().objectCount - 1 - spacer2:SetHeight(5) local castbarCustom @@ -1811,71 +1702,30 @@ pfUI:RegisterModule("gui", function () castbarCustom = CreateConfig(nil, T["Custom FPS"], _G.pfUI_throttle, "nameplates_castbar_custom") - local spacer3 = CreateConfig(nil, " ", nil, nil, "header") - spacer3:GetParent().objectCount = spacer3:GetParent().objectCount - 1 - spacer3:SetHeight(5) - - local massCustom - - CreateConfig(function() - if massCustom and massCustom.input then - local isCustom = pfUI.throttle:IsCustom("nameplates_mass") - if not isCustom then - massCustom.input:EnableMouse(false) - massCustom.input:EnableKeyboard(false) - massCustom.input:ClearFocus() - massCustom.input:SetTextColor(.5,.5,.5,1) - massCustom.input:SetText(tostring(pfUI.throttle:GetFps("nameplates_mass"))) - else - massCustom.input:EnableMouse(true) - massCustom.input:EnableKeyboard(true) - massCustom.input:SetTextColor(.2,1,.8,1) - if _G.pfUI_throttle.nameplates_mass_custom then - massCustom.input:SetText(_G.pfUI_throttle.nameplates_mass_custom) - end - end - end - end, T["Mass Pulls (20+ Plates)"], _G.pfUI_throttle, "nameplates_mass", "dropdown", { - "very_slow:" .. T["Very Slow"] .. " (2 FPS)", - "slow:" .. T["Slow"] .. " (5 FPS)", - "normal:" .. T["Normal"] .. " (10 FPS)", - "fast:" .. T["Fast"] .. " (20 FPS)", - "very_fast:" .. T["Very Fast"] .. " (30 FPS)", - "fastest:" .. T["Fastest"] .. " (50 FPS)", - "custom:" .. T["Custom"], - }) - - massCustom = CreateConfig(nil, T["Custom FPS"], _G.pfUI_throttle, "nameplates_mass_custom") - - local isCustom3 = pfUI.throttle:IsCustom("nameplates_mass") - if not isCustom3 then - massCustom.input:EnableMouse(false) - massCustom.input:EnableKeyboard(false) - massCustom.input:SetTextColor(.5,.5,.5,1) - massCustom.input:SetText(tostring(pfUI.throttle:GetFps("nameplates_mass"))) + local isCustom = pfUI.throttle:IsCustom("nameplates_castbar") + if not isCustom then + castbarCustom.input:EnableMouse(false) + castbarCustom.input:EnableKeyboard(false) + castbarCustom.input:SetTextColor(.5,.5,.5,1) + castbarCustom.input:SetText(tostring(pfUI.throttle:GetFps("nameplates_castbar"))) else - massCustom.input:EnableMouse(true) - massCustom.input:EnableKeyboard(true) - massCustom.input:SetTextColor(.2,1,.8,1) - if _G.pfUI_throttle.nameplates_mass_custom then - massCustom.input:SetText(_G.pfUI_throttle.nameplates_mass_custom) + castbarCustom.input:EnableMouse(true) + castbarCustom.input:EnableKeyboard(true) + castbarCustom.input:SetTextColor(.2,1,.8,1) + if _G.pfUI_throttle.nameplates_castbar_custom then + castbarCustom.input:SetText(_G.pfUI_throttle.nameplates_castbar_custom) end end - + -- Spacer before reset button local spacer = CreateConfig(nil, " ", nil, nil, "header") spacer:GetParent().objectCount = spacer:GetParent().objectCount - 1 spacer:SetHeight(10) - + -- Reset to defaults button CreateConfig(nil, T["Reset to Defaults"], nil, nil, "button", function() - pfUI.throttle:ResetToDefault("nameplates_target") - pfUI.throttle:ResetToDefault("nameplates") - pfUI.throttle:ResetToDefault("nameplates_mass") - -- Also reset custom fields to their default FPS values - _G.pfUI_throttle.nameplates_target_custom = "50" - _G.pfUI_throttle.nameplates_custom = "10" - _G.pfUI_throttle.nameplates_mass_custom = "7" + pfUI.throttle:ResetToDefault("nameplates_castbar") + _G.pfUI_throttle.nameplates_castbar_custom = "100" Reload() end, true) end) diff --git a/modules/nameplates.lua b/modules/nameplates.lua index 3c95b9eb..b90d88f9 100644 --- a/modules/nameplates.lua +++ b/modules/nameplates.lua @@ -5,21 +5,36 @@ pfUI:RegisterModule("nameplates", function () -- Local function references for performance local GetTime = GetTime local UnitName = UnitName + local UnitGUID = UnitGUID local UnitIsPlayer = UnitIsPlayer local UnitAffectingCombat = UnitAffectingCombat local UnitIsUnit = UnitIsUnit - local UnitCanAssist = UnitCanAssist local UnitHealth = UnitHealth local UnitHealthMax = UnitHealthMax + local UnitLevel = UnitLevel + local UnitClassification = UnitClassification + local UnitIsTapped = UnitIsTapped + local UnitIsTappedByPlayer = UnitIsTappedByPlayer + local UnitSelectionType = UnitSelectionType + local UnitCreatureTypeID = UnitCreatureTypeID + local UnitCreatedBySpell = UnitCreatedBySpell local pairs = pairs + local next = next local tonumber = tonumber local strlower = strlower - local strfind = strfind local strlen = strlen local floor = floor local ceil = ceil local abs = abs - local mathmod = math.mod + + -- Everything a plate shows is driven by events. The engine's own nameplate + -- regions are blanked at creation and never read again: a bound plate reads + -- its unit through its "nameplateN" token, which ClassicAPI feeds every unit + -- event (UNIT_HEALTH, UNIT_FLAGS, UNIT_TARGET, UNIT_AURA_DELTA, the casts, + -- ...), each registered on the plate's own frame against that token. There + -- is no per-tick loop over the plates and no periodic refresh: the only + -- OnUpdate is `driver`, shown while a castbar sweeps or the target zoom + -- animates and hidden the moment both sets run dry. local unitcolors = { ["ENEMY_NPC"] = { .9, .2, .3, .8 }, @@ -29,8 +44,6 @@ pfUI:RegisterModule("nameplates", function () ["FRIENDLY_PLAYER"] = { .2, .6, 1, .8 } } - local offtanks = {} - local combatstate = { -- gets overwritten by user config ["OFFTANK"] = { r = .7, g = .4, b = .2, a = 1 }, @@ -48,35 +61,174 @@ pfUI:RegisterModule("nameplates", function () ["boss"] = "B" } + -- UnitSelectionType: 0 hostile, 1 unfriendly, 2 neutral, 3 friendly (or a + -- PvP-flagged player), 4 unflagged player. Type 3 is ambiguous, so the + -- player check promotes *_NPC to *_PLAYER. + local selectionTypes = { + [0] = "ENEMY_NPC", + [1] = "ENEMY_NPC", + [2] = "NEUTRAL_NPC", + [3] = "FRIENDLY_NPC", + [4] = "FRIENDLY_PLAYER", + } + + local NULL_GUID = "0x0000000000000000" + local ZOOM_TIME = 0.2 + + local registry = {} -- engine plate -> true, every pool slot carrying a pfUI overlay + local plateByGuid = {} -- guid -> overlay, the plates currently bound to a unit + local plateByUnit = {} -- "nameplateN" -> overlay, the same set by token + local castState = {} -- guid -> cast struct (see PollCastInfo) + local threatMemory = {} -- guid -> true once the mob targeted the player + local raidGuidCache = {} -- guid -> name of the player's group + local offtanks = {} + local activeCasts = {} -- overlay -> true while its castbar sweeps + local zooming = {} -- overlay -> true while its target zoom animates + local auraSlots = {} -- reusable GetAuraSlots buffer for the per-plate aura scan + local targetGuid + local playerInCombat + local mouseBlocked -- a spell is waiting for a ground target, so the overlays let clicks through + local myGuild + local platecount = 0 + -- Friendly zone nameplate disable state local savedHostileState = nil local savedFriendlyState = nil local inFriendlyZone = false - local myGuild = nil - local platecount = 0 - local registry = {} - -- Subset of registry that currently has a unit assigned (between - -- NAME_PLATE_UNIT_ADDED and _REMOVED). The central loop iterates this instead - -- of the full pool so hidden pool slots aren't touched every tick. - local visiblePlates = {} - local raidGuidCache = {} -- guid -> name (rebuilt on RAID_ROSTER_UPDATE/PARTY_MEMBERS_CHANGED) - - -- Per-GUID cast state, populated from ClassicAPI's UNIT_SPELLCAST_* events - -- (which now fire for nameplate tokens) and cleared on STOP / plate removal / - -- expiry. This replaces the old per-tick C_Spell poll on every visible plate: - -- cast detection is event driven, and GetCastInfo just reads this cache. - local castState = {} - -- guid -> nameplate, maintained on NAME_PLATE_UNIT_ADDED/_REMOVED so a cast - -- event can find its plate in O(1) and only cache casts we actually show. - local plateByGuid = {} + -- ============================================================================ + -- Config caching + -- ============================================================================ + local cfg = {} + local debufffilter, debufflist + + local function DebuffFilterPopulate() + debufffilter = C.nameplates.debuffs.filter + debufflist = {} + if debufffilter ~= "none" then + for _, val in pairs({strsplit("#", C.nameplates.debuffs[debufffilter])}) do + debufflist[strlower(val)] = true + end + end + end + + local function DebuffFilter(effect) + if debufffilter == "none" then return true end + local listed = debufflist[strlower(effect)] + if debufffilter == "blacklist" then return not listed end + return listed + end + + local function CacheConfig() + local N = C.nameplates + cfg.showcastbar = N.showcastbar == "1" + cfg.targetcastbar = N.targetcastbar == "1" + cfg.notargalpha = tonumber(N.notargalpha) or 0.5 + if cfg.notargalpha > 1 then cfg.notargalpha = cfg.notargalpha / 100 end + if cfg.notargalpha > 1 then cfg.notargalpha = 1 end + cfg.namefightcolor = N.namefightcolor == "1" + cfg.spellname = N.spellname == "1" + cfg.showhp = N.showhp == "1" + cfg.hptextformat = N.hptextformat + cfg.showdebuffs = N.showdebuffs == "1" + cfg.showdebuffs_hostile = N.showdebuffs_hostile == "1" + cfg.showdebuffs_friendly = N.showdebuffs_friendly == "1" + -- owndebuffs adds the PLAYER filter token so only auras whose caster GUID + -- matches the local player come through + cfg.debufffilter = N.owndebuffs == "1" and "HARMFUL|PLAYER" or "HARMFUL" + cfg.showstacks = N.debuffs.showstacks == "1" + cfg.debuffbottom = N.debuffs.position == "BOTTOM" + cfg.targetzoom = N.targetzoom == "1" + cfg.zoomval = (tonumber(N.targetzoomval) or 0.4) + 1 + cfg.width = tonumber(N.width) or 120 + cfg.heighthealth = tonumber(N.heighthealth) or 8 + cfg.targetglow = N.targetglow == "1" + cfg.targethighlight = N.targethighlight == "1" + cfg.outcombatstate = N.outcombatstate == "1" + cfg.barcombatstate = N.barcombatstate == "1" + cfg.combatstate = cfg.outcombatstate or cfg.barcombatstate + cfg.ccombatcasting = N.ccombatcasting == "1" + cfg.ccombatthreat = N.ccombatthreat == "1" + cfg.ccombatnothreat = N.ccombatnothreat == "1" + cfg.ccombatstun = N.ccombatstun == "1" + cfg.ccombatofftank = N.ccombatofftank == "1" + cfg.font_size = N.use_unitfonts == "1" and C.global.font_unit_size or C.global.font_size + cfg.totemicons = N.totemicons == "1" + cfg.cpdisplay = N.cpdisplay == "1" + cfg.showguildname = N.showguildname == "1" + cfg.abbrevname = C.unitframes.abbrevname == "1" + cfg.friendclassnamec = N.friendclassnamec == "1" + cfg.castbardecimals = C.unitframes.castbardecimals == "1" + cfg.fullhealth = N.fullhealth == "1" + cfg.keeptarget = N.target == "1" + cfg.critters = N.critters == "1" + cfg.totems = N.totems == "1" + cfg.hide = { + ENEMY_NPC = N.enemynpc == "1", + ENEMY_PLAYER = N.enemyplayer == "1", + NEUTRAL_NPC = N.neutralnpc == "1", + FRIENDLY_NPC = N.friendlynpc == "1", + FRIENDLY_PLAYER = N.friendlyplayer == "1", + } + cfg.outline = { + FRIENDLY_NPC = N.outfriendlynpc == "1", + FRIENDLY_PLAYER = N.outfriendly == "1", + NEUTRAL_NPC = N.outneutral == "1", + ENEMY_NPC = N.outenemy == "1", + ENEMY_PLAYER = N.outenemy == "1", + } + cfg.classcolor = { + ENEMY_PLAYER = N.enemyclassc == "1", + FRIENDLY_PLAYER = N.friendclassc == "1", + } + cfg.overlap = N.overlap == "1" + cfg.useoverlay = cfg.overlap or N.vertical_offset ~= "0" -- the overlay takes the clicks + cfg.clickable = N.clickthrough ~= "1" + cfg.rightclick = N.rightclick == "1" + cfg.hlr, cfg.hlg, cfg.hlb, cfg.hla = GetStringColor(N.highlightcolor) + cfg.br, cfg.bg, cfg.bb, cfg.ba = GetStringColor(C.appearance.border.color) + -- sweep rate of the castbars on plates other than the target's + cfg.throttle_castbar = pfUI.throttle:Get("nameplates_castbar") + + local c = combatstate + c.CASTING.r, c.CASTING.g, c.CASTING.b, c.CASTING.a = GetStringColor(N.combatcasting) + c.THREAT.r, c.THREAT.g, c.THREAT.b, c.THREAT.a = GetStringColor(N.combatthreat) + c.NOTHREAT.r, c.NOTHREAT.g, c.NOTHREAT.b, c.NOTHREAT.a = GetStringColor(N.combatnothreat) + c.OFFTANK.r, c.OFFTANK.g, c.OFFTANK.b, c.OFFTANK.a = GetStringColor(N.combatofftank) + c.STUN.r, c.STUN.g, c.STUN.b, c.STUN.a = GetStringColor(N.combatstun) + + offtanks = {} + for _, v in pairs({strsplit("#", N.combatofftanks)}) do + if v ~= "" then offtanks[strlower(v)] = true end + end + + DebuffFilterPopulate() + end + CacheConfig() + + local function RebuildRaidGuidCache() + table.wipe(raidGuidCache) + for i = 1, GetNumRaidMembers() do + local g = UnitGUID("raid"..i) + if g then raidGuidCache[g] = UnitName("raid"..i) end + end + for i = 1, GetNumPartyMembers() do + local g = UnitGUID("party"..i) + if g then raidGuidCache[g] = UnitName("party"..i) end + end + local pg = UnitGUID("player") + if pg then raidGuidCache[pg] = UnitName("player") end + end + + -- ============================================================================ + -- Casts + -- ============================================================================ -- One-shot C_Spell poll. Builds the cast struct for a UNIT_SPELLCAST_* event - -- (the payload carries no timing) and seeds a plate that spawns while its unit + -- (the payload carries no timing) and seeds a plate that binds while its unit -- is already mid-cast (its START fired before the plate existed). Picks cast - -- vs channel itself. Never called per frame. + -- vs channel itself. local function PollCastInfo(unit) - if not unit then return nil end local name, _, texture, startMs, endMs, _, _, noInterrupt, spellID = C_Spell.UnitCastingInfo(unit) local isChannel if not name then @@ -96,154 +248,38 @@ pfUI:RegisterModule("nameplates", function () } end - -- Read a unit's current cast from the event-driven cache (keyed by GUID). - -- Returns the cached struct while the cast is still active, else nil (and - -- prunes the expired entry). Same struct shape and callers as before, minus - -- the per-tick poll. - local function GetCastInfo(unit) - if not unit then return nil end - local guid = UnitGUID(unit) - if not guid then return nil end - local info = castState[guid] - if info and info.endTime > GetTime() then return info end - if info then castState[guid] = nil end - return nil - end - - local debuffCache = {} -- guid -> { [spellID] = { start, duration } } - -- Reusable per-plate debuff display buffer (avoid GC churn from per-call table creation) - local debuffDisplayBuf = {} -- [i] = { effect, texture, stacks, dtype, duration, timeleft } - for i = 1, 16 do debuffDisplayBuf[i] = {} end - local auraSlots = {} -- reusable GetAuraSlots buffer for the per-plate aura scan - local threatMemory = {} -- guid -> true if mob had player targeted - -- local debuffSeen = {} -- reusable table for debuff tracking (avoid GC churn) - - -- PERF: visiblePlateCount maintained event-driven (NAME_PLATE_UNIT_ADDED/_REMOVED) - local visiblePlateCount = 0 - -- ============================================================================ - -- OPTIMIZATION: Config caching + -- Plate state -- ============================================================================ - local cfg = {} - local function CacheConfig() - cfg.showcastbar = C.nameplates["showcastbar"] == "1" - cfg.targetcastbar = C.nameplates["targetcastbar"] == "1" - cfg.notargalpha = tonumber(C.nameplates.notargalpha) or 0.5 - if cfg.notargalpha > 1 then cfg.notargalpha = cfg.notargalpha / 100 end - -- Clamp to 1.0 so a 100% setting yields full opacity (target detection is GUID-based) - if cfg.notargalpha > 1 then cfg.notargalpha = 1 end - cfg.namefightcolor = C.nameplates.namefightcolor == "1" - cfg.spellname = C.nameplates.spellname == "1" - cfg.showhp = C.nameplates.showhp == "1" - cfg.showdebuffs = C.nameplates["showdebuffs"] == "1" - cfg.showdebuffs_hostile = C.nameplates["showdebuffs_hostile"] == "1" - cfg.showdebuffs_friendly = C.nameplates["showdebuffs_friendly"] == "1" - cfg.owndebuffs = C.nameplates["owndebuffs"] == "1" - cfg.targetzoom = C.nameplates.targetzoom == "1" - cfg.zoomval = (tonumber(C.nameplates.targetzoomval) or 0.4) + 1 - cfg.width = tonumber(C.nameplates.width) or 120 - cfg.heighthealth = tonumber(C.nameplates.heighthealth) or 8 - cfg.targetglow = C.nameplates.targetglow == "1" - cfg.targethighlight = C.nameplates.targethighlight == "1" - cfg.outcombatstate = C.nameplates.outcombatstate == "1" - cfg.barcombatstate = C.nameplates.barcombatstate == "1" - cfg.ccombatcasting = C.nameplates.ccombatcasting == "1" - cfg.ccombatthreat = C.nameplates.ccombatthreat == "1" - cfg.ccombatnothreat = C.nameplates.ccombatnothreat == "1" - cfg.ccombatstun = C.nameplates.ccombatstun == "1" - cfg.ccombatofftank = C.nameplates.ccombatofftank == "1" - cfg.use_unitfonts = C.nameplates.use_unitfonts == "1" - cfg.font_size = cfg.use_unitfonts and C.global.font_unit_size or C.global.font_size - cfg.hptextformat = C.nameplates.hptextformat - -- Throttle delays, resolved once instead of per plate per tick. - -- libthrottle:Get walks the saved-variable table, a defaults fallback and a - -- preset table, and can build a "_custom" key -- the per-plate - -- OnUpdate was calling it one or two times for every visible plate, a - -- hundred times a second, just to decide it had nothing to do. - -- - -- cfg.throttle_min is the floor across all four. No plate can ever be due - -- sooner than that, so the update can bail on it before working out which - -- category it actually belongs to. - cfg.throttle_target = pfUI.throttle:Get("nameplates_target") - cfg.throttle_mass = pfUI.throttle:Get("nameplates_mass") - cfg.throttle_normal = pfUI.throttle:Get("nameplates") - cfg.throttle_castbar = pfUI.throttle:Get("nameplates_castbar") - cfg.throttle_min = cfg.throttle_target - if cfg.throttle_mass < cfg.throttle_min then cfg.throttle_min = cfg.throttle_mass end - if cfg.throttle_normal < cfg.throttle_min then cfg.throttle_min = cfg.throttle_normal end - if cfg.throttle_castbar < cfg.throttle_min then cfg.throttle_min = cfg.throttle_castbar end + -- Updaters reference each other, so they are declared ahead and defined below. + local UpdateBarColor, UpdateBorder, UpdateNameColor, UpdateLayout, UpdateDebuffs + local UpdateLevel, UpdateCombatColor, UpdateCast, HideCast, SetZoom, Unbind - -- Rebuild offtanks lookup table - offtanks = {} - for k, v in pairs({strsplit("#", C.nameplates.combatofftanks)}) do - if v ~= "" then offtanks[string.lower(v)] = true end + -- Numeric CreatureType.dbc id for the plate's unit (8 = Critter, 11 = Totem). + -- It's fixed per unit, so cache it and reset on bind. nil while the creature + -- cache hasn't answered yet, in which case the next call asks again. + local function CreatureType(plate) + if not plate.creatureType then + plate.creatureType = UnitCreatureTypeID(plate.unit) end + return plate.creatureType end - local function RebuildOfftanks() - offtanks = {} - for k, v in pairs({strsplit("#", C.nameplates.combatofftanks)}) do - if v ~= "" then offtanks[string.lower(v)] = true end - end - end - RebuildOfftanks() + -- The combat-state colour for a bound plate, or nil when the unit shows no + -- combat state. The mob's target comes through the plate's token chain + -- ("nameplateNtarget"). Runs on the events that can change the answer: + -- UNIT_TARGET, UNIT_FLAGS, the casts, the player's combat state and the + -- group roster -- never on a timer. + local function CombatStateColor(plate) + if not playerInCombat or plate.friendly or not plate.incombat then return nil end - -- ============================================================================ - -- OPTIMIZATION: Frame state cache - -- ============================================================================ - local frameState = { - now = 0, - targetGuid = nil, - mouseoverGuid = nil, - } - - -- cache default border color - local er, eg, eb, ea = GetStringColor(pfUI_config.appearance.border.color) - - local NULL_GUID = "0x0000000000000000" - - local function RebuildRaidGuidCache() - for k in pairs(raidGuidCache) do raidGuidCache[k] = nil end - for i = 1, GetNumRaidMembers() do - local g = UnitGUID("raid"..i) - if g then raidGuidCache[g] = UnitName("raid"..i) end - end - for i = 1, GetNumPartyMembers() do - local g = UnitGUID("party"..i) - if g then raidGuidCache[g] = UnitName("party"..i) end - end - local pg = UnitGUID("player") - if pg then raidGuidCache[pg] = UnitName("player") end - end - - local combatColorCache = {} -- guid -> { color, expires } - - local function GetCombatStateColor(guid, token) - -- PERF: Quick exit if player not in combat - if not UnitAffectingCombat("player") then return false end - if not token then return false end - if UnitCanAssist("player", token) then return false end - - -- PERF: 0.2s throttle per guid - color changes are not time-critical - local now = frameState.now - local cached = combatColorCache[guid] - if cached and cached.expires > now then - return cached.color - end - - if not UnitAffectingCombat(token) then return false end - - -- The mob's current target via the nameplate token chain (ClassicAPI): - -- "nameplateNtarget" resolves to whatever this plate's unit is targeting, - -- so no GetUnitField("target") or SuperWoW "target" token needed. - local target = token .. "target" + local guid = plate.guid + local target = plate.targetToken local mobTargetGuid = UnitGUID(target) local hasTarget = mobTargetGuid and mobTargetGuid ~= NULL_GUID - local color = false - - local castInfo = GetCastInfo(token) - local isCasting = castInfo and castInfo.endTime and now < castInfo.endTime + local info = castState[guid] + local isCasting = info and info.endTime > GetTime() local targetingPlayer = hasTarget and UnitIsUnit(target, "player") if targetingPlayer then @@ -252,192 +288,405 @@ pfUI:RegisterModule("nameplates", function () threatMemory[guid] = nil end - -- PERF: O(1) GUID lookup via raidGuidCache instead of O(40) loop local targetName = hasTarget and (UnitName(target) or raidGuidCache[mobTargetGuid]) if cfg.ccombatcasting and isCasting then - color = combatstate.CASTING + return combatstate.CASTING elseif cfg.ccombatthreat and (targetingPlayer or threatMemory[guid]) then - color = combatstate.THREAT + return combatstate.THREAT elseif cfg.ccombatofftank and targetName and offtanks[strlower(targetName)] then - color = combatstate.OFFTANK + return combatstate.OFFTANK elseif cfg.ccombatofftank and pfUI.uf and pfUI.uf.raid and targetName and pfUI.uf.raid.tankrole[targetName] then - color = combatstate.OFFTANK + return combatstate.OFFTANK elseif cfg.ccombatnothreat and hasTarget then - color = combatstate.NOTHREAT + return combatstate.NOTHREAT elseif cfg.ccombatstun and not hasTarget then - color = combatstate.STUN + return combatstate.STUN end - - combatColorCache[guid] = combatColorCache[guid] or {} - combatColorCache[guid].color = color - combatColorCache[guid].expires = now + 0.2 - - return color - end - - local function DisableObject(object) - if not object then return end - if not object.GetObjectType then return end - - local otype = object:GetObjectType() - - if otype == "Texture" then - object:SetTexture("") - object:SetTexCoord(0, 0, 0, 0) - elseif otype == "FontString" then - object:SetWidth(0.001) - elseif otype == "StatusBar" then - object:SetStatusBarTexture("") - end - end - - -- Numeric CreatureType.dbc id for the plate's unit (8 = Critter, 11 = Totem). - -- It's fixed per unit, so cache it and reset on plate reuse. nil while the GUID - -- can't resolve yet (incl. the no-SuperWoW case, where cachedGuid is empty). - -- Locale-proof and covers custom units for free -- no name tables needed. - local function CreatureType(plate) - local guid = plate.cachedGuid - if not guid or guid == "" then return nil end - if not plate.creatureType then - plate.creatureType = UnitCreatureTypeID(guid) -- nil retries next call - end - return plate.creatureType - end - - -- Totem icon: UnitCreatedBySpell returns the totem-drop spell, whose icon IS - -- the totem's icon. It's a broadcast descriptor field the client has for every - -- summoned unit in range, so it resolves immediately for passive and active - -- totems alike -- no self-aura read or attack-cast capture needed. - -- - -- Re-read the spell every call rather than caching the icon outright: a shaman - -- can swap the totem in place (same unit, new drop spell) with no plate re-add - -- to invalidate a cache, so key the cached texture on the spell id and refresh - -- it only when the spell changes. - local function TotemPlate(plate) - if C.nameplates.totemicons ~= "1" then return nil end - if CreatureType(plate) ~= 11 then return nil end - local spellId = UnitCreatedBySpell(plate.cachedGuid) - if spellId ~= plate.totemSpell then - plate.totemSpell = spellId - plate.totemIcon = spellId and C_Spell.GetSpellTexture(spellId) or nil - end - return plate.totemIcon - end - - local function HidePlate(unittype, fullhp, target, plate) - -- keep some plates always visible according to config - if C.nameplates.fullhealth == "1" and not fullhp then return nil end - if C.nameplates.target == "1" and target then return nil end - - -- return true when something needs to be hidden - if C.nameplates.enemynpc == "1" and unittype == "ENEMY_NPC" then - return true - elseif C.nameplates.enemyplayer == "1" and unittype == "ENEMY_PLAYER" then - return true - elseif C.nameplates.neutralnpc == "1" and unittype == "NEUTRAL_NPC" then - return true - elseif C.nameplates.friendlynpc == "1" and unittype == "FRIENDLY_NPC" then - return true - elseif C.nameplates.friendlyplayer == "1" and unittype == "FRIENDLY_PLAYER" then - return true - elseif C.nameplates.critters == "1" and CreatureType(plate) == 8 then - return true - elseif C.nameplates.totems == "1" and CreatureType(plate) == 11 then - return true - end - - -- nothing to hide return nil end + -- Returns true when the colour changed. + local function ReadCombatColor(plate) + local color = cfg.combatstate and CombatStateColor(plate) or nil + if color == plate.combatColor then return nil end + plate.combatColor = color + return true + end + + function UpdateCombatColor(plate) + if ReadCombatColor(plate) then + UpdateBarColor(plate) + UpdateBorder(plate) + end + end + + -- Returns true when the type (or the class behind its colour) changed. + local function ReadUnitType(plate) + local unit = plate.unit + local player = UnitIsPlayer(unit) and true or nil + local unittype = selectionTypes[UnitSelectionType(unit)] or "ENEMY_NPC" + if player then + if unittype == "ENEMY_NPC" then unittype = "ENEMY_PLAYER" + elseif unittype == "FRIENDLY_NPC" then unittype = "FRIENDLY_PLAYER" end + end + local class = player and UnitClassBase(unit) or nil + if unittype == plate.unittype and class == plate.class then return nil end + plate.player, plate.unittype, plate.class = player, unittype, class + plate.friendly = unittype == "FRIENDLY_PLAYER" or unittype == "FRIENDLY_NPC" + return true + end + + local function UpdateUnitType(plate) + if not ReadUnitType(plate) then return end + ReadCombatColor(plate) + UpdateBarColor(plate) + UpdateBorder(plate) + UpdateNameColor(plate) + UpdateLayout(plate) + UpdateDebuffs(plate) + end + + -- UNIT_FLAGS: the combat bit, and the reaction can move with it + local function UpdateFlags(plate) + local incombat = UnitAffectingCombat(plate.unit) and true or nil + local changed = incombat ~= plate.incombat + plate.incombat = incombat + UpdateUnitType(plate) + if changed then + UpdateCombatColor(plate) + UpdateNameColor(plate) + end + end + + -- UNIT_DYNAMIC_FLAGS: tapped by someone else greys the bar + local function UpdateTapped(plate) + local unit = plate.unit + local tapped = UnitIsTapped(unit) and not UnitIsTappedByPlayer(unit) and true or nil + if tapped == plate.tapped then return end + plate.tapped = tapped + UpdateBarColor(plate) + end + + function UpdateBarColor(plate) + local unittype = plate.unittype + local r, g, b, a = unpack(unitcolors[unittype]) + if plate.class and cfg.classcolor[unittype] then + r, g, b, a = PFUI_CLASS_COLORS[plate.class]:GetRGBA() + end + if plate.tapped then + r, g, b, a = .5, .5, .5, .8 + end + if cfg.barcombatstate and plate.combatColor then + local c = plate.combatColor + r, g, b, a = c.r, c.g, c.b, c.a + end + local cache = plate.cache + if r == cache.r and g == cache.g and b == cache.b then return end + cache.r, cache.g, cache.b = r, g, b + plate.health:SetStatusBarColor(r, g, b, a) + end + + function UpdateBorder(plate) + local r, g, b, a + if cfg.outcombatstate then + local c = plate.combatColor or combatstate.NONE + r, g, b, a = c.r, c.g, c.b, c.a + elseif plate.istarget and cfg.targethighlight then + r, g, b, a = cfg.hlr, cfg.hlg, cfg.hlb, cfg.hla + elseif cfg.outline[plate.unittype] then + r, g, b, a = unpack(unitcolors[plate.unittype]) + else + r, g, b, a = cfg.br, cfg.bg, cfg.bb, cfg.ba + end + local cache = plate.cache + if r == cache.br and g == cache.bg and b == cache.bb then return end + cache.br, cache.bg, cache.bb = r, g, b + plate.health.backdrop:SetBackdropBorderColor(r, g, b, a) + end + + -- Class colour for friendly players when friendclassnamec is on, the fight + -- colour while the unit and the player are both in combat, white otherwise. + function UpdateNameColor(plate) + local r, g, b, a = 1, 1, 1, 1 + if cfg.friendclassnamec and plate.unittype == "FRIENDLY_PLAYER" and plate.class then + r, g, b, a = PFUI_CLASS_COLORS[plate.class]:GetRGBA() + elseif cfg.namefightcolor and playerInCombat and plate.incombat then + r, g, b, a = 1, .4, .2, 1 + end + local cache = plate.cache + if r == cache.nr and g == cache.ng and b == cache.nb then return end + cache.nr, cache.ng, cache.nb = r, g, b + plate.name:SetTextColor(r, g, b, a) + end + local function abbrevname(t) return string.sub(t,1,1)..". " end local function GetNameString(name) - local abbrev = pfUI_config.unitframes.abbrevname == "1" or nil - local size = 20 - - -- first try to only abbreviate the first word - if abbrev and name and strlen(name) > size then + if cfg.abbrevname and name and strlen(name) > 20 then + -- first try to only abbreviate the first word name = string.gsub(name, "^(%S+) ", abbrevname) + -- abbreviate all if it still doesn't fit + if strlen(name) > 20 then + name = string.gsub(name, "(%S+) ", abbrevname) + end end - - -- abbreviate all if it still doesn't fit - if abbrev and name and strlen(name) > size then - name = string.gsub(name, "(%S+) ", abbrevname) - end - return name end + -- Name, and the guild or subname line under it. Returns true when the + -- layout has to follow: the guild line moves the name-only anchors, and a + -- totem swapped in place keeps its unit while its name and drop spell change. + local function ReadName(plate) + local unit = plate.unit + local relayout + local name = UnitName(unit) + if name ~= plate.rawname then + plate.rawname = name + plate.name:SetText(GetNameString(name)) + -- a player too high to show a level is looked up by name + UpdateLevel(plate) + if plate.creatureType == 11 then relayout = true end + end - -- UnitSelectionType: 0 hostile, 1 unfriendly, 2 neutral, 3 friendly (or a - -- PvP-flagged player), 4 unflagged player. Type 3 is ambiguous, so callers - -- still promote *_NPC to *_PLAYER from their own player check. - local selectionTypes = { - [0] = "ENEMY_NPC", - [1] = "ENEMY_NPC", - [2] = "NEUTRAL_NPC", - [3] = "FRIENDLY_NPC", - [4] = "FRIENDLY_PLAYER", - } - - local function GetUnitType(plate) - -- an unresolved token reads as the player's own type (4), so never ask - -- without a GUID - local guid = plate.cachedGuid - if not guid or guid == "" then return "ENEMY_NPC" end - return selectionTypes[UnitSelectionType(guid)] + local guild = cfg.showguildname and (plate.player and GetGuildInfo(unit) or UnitSubName(unit)) or nil + if guild then + if guild ~= plate.guildtext then + plate.guildtext = guild + plate.guild:SetText(guild) + end + local mine = guild == myGuild + if mine ~= plate.guildmine then + plate.guildmine = mine + if mine then + plate.guild:SetTextColor(0, 0.9, 0, 1) + else + plate.guild:SetTextColor(0.8, 0.8, 0.8, 1) + end + end + end + local shown = guild and true or nil + if shown ~= plate.guildshown then + plate.guildshown = shown + relayout = true + end + return relayout end - local filter, list, cache - local function DebuffFilterPopulate() - -- initialize variables - filter = C.nameplates["debuffs"]["filter"] - if filter == "none" then return end - list = C.nameplates["debuffs"][filter] - cache = {} + -- UNIT_NAME_UPDATE + local function UpdateName(plate) + if ReadName(plate) then UpdateLayout(plate) end + end - -- populate list - for _, val in pairs({strsplit("#", list)}) do - cache[strlower(val)] = true + -- UNIT_LEVEL / UNIT_CLASSIFICATION_CHANGED / PLAYER_LEVEL_UP + function UpdateLevel(plate) + local unit = plate.unit + local level = UnitLevel(unit) + local elite = UnitClassification(unit) + if level <= 0 then + if plate.player then + local _, dblevel = GetUnitInfo(plate.rawname) + if dblevel and dblevel > 0 then level = dblevel end + else + -- UnitLevel answers -1 exactly where the engine shows the skull: a + -- world boss, or a hostile ten or more levels above the player + elite = "boss" + end + end + plate.levelnum = level > 0 and level or nil + if level <= 0 then level = "??" end + + local cache = plate.cache + if cache.level == level and cache.elite == elite then return end + cache.level, cache.elite = level, elite + plate.level:SetFormattedText("%s%s", level, elitestrings[elite] or "") + + -- Blizzard's own tiers: adding a flat offset to the channels desaturates + -- every tier toward white and collapses the boundaries + if plate.levelnum then + local color = GetDifficultyColor(plate.levelnum) + plate.level:SetTextColor(color.r, color.g, color.b, 1) + elseif plate.player then + plate.level:SetTextColor(1, 1, 1, 1) + else + plate.level:SetTextColor(1, .1, .1, 1) end end - local function DebuffFilter(effect) - if filter == "none" then return true end - if not cache then DebuffFilterPopulate() end + local function ApplyHealth(plate) + local hp, hpmax = plate.hp, plate.hpmax + plate.health:SetMinMaxValues(0, hpmax) + plate.health:SetValue(hp) + if not cfg.showhp then return end - if filter == "blacklist" and cache[strlower(effect)] then - return nil - elseif filter == "blacklist" then - return true - elseif filter == "whitelist" and cache[strlower(effect)] then - return true - elseif filter == "whitelist" then - return nil + local text = plate.health.text + if hpmax <= 0 then + text:SetText("") + return end + + -- A unit outside the group reports its health as a percentage (max 100); + -- libhealth may know the real numbers for a mob by name and level. + local rhp, rhpmax, known + if hpmax ~= 100 then + rhp, rhpmax, known = hp, hpmax, true + elseif pfUI.libhealth and pfUI.libhealth.enabled then + rhp, rhpmax, known = pfUI.libhealth:GetUnitHealthByName(plate.rawname, plate.levelnum, hp, hpmax) + end + + local perc = ceil(hp / hpmax * 100) + local setting = cfg.hptextformat + if not known then + if setting == "deficit" then + text:SetFormattedText("-%d%%", 100 - perc) + else + text:SetFormattedText("%d%%", perc) + end + elseif setting == "curperc" then + text:SetFormattedText("%s | %d%%", Abbreviate(rhp), perc) + elseif setting == "cur" then + text:SetText(Abbreviate(rhp)) + elseif setting == "curmax" then + text:SetFormattedText("%s - %s", Abbreviate(rhp), Abbreviate(rhpmax)) + elseif setting == "curmaxs" then + text:SetFormattedText("%s / %s", Abbreviate(rhp), Abbreviate(rhpmax)) + elseif setting == "curmaxperc" then + text:SetFormattedText("%s - %s | %d%%", Abbreviate(rhp), Abbreviate(rhpmax), perc) + elseif setting == "curmaxpercs" then + text:SetFormattedText("%s / %s | %d%%", Abbreviate(rhp), Abbreviate(rhpmax), perc) + elseif setting == "deficit" then + text:SetFormattedText("-%s", Abbreviate(rhpmax - rhp)) + else -- "percent" as fallback + text:SetFormattedText("%d%%", perc) + end + end + + -- Bar and text. Returns true when full health flipped, which can hide or + -- show the bar. + local function ReadHealth(plate) + local unit = plate.unit + local hp, hpmax = UnitHealth(unit), UnitHealthMax(unit) + if hp == plate.hp and hpmax == plate.hpmax then return nil end + plate.hp, plate.hpmax = hp, hpmax + ApplyHealth(plate) + local fullhp = hp == hpmax + if fullhp == plate.fullhp then return nil end + plate.fullhp = fullhp + return true + end + + -- UNIT_HEALTH / UNIT_MAXHEALTH + local function UpdateHealth(plate) + if ReadHealth(plate) then UpdateLayout(plate) end + end + + -- true when the bar should be hidden, leaving name and level + local function HidePlate(plate) + -- keep some plates always visible according to config + if cfg.fullhealth and not plate.fullhp then return nil end + if cfg.keeptarget and plate.istarget then return nil end + + if cfg.hide[plate.unittype] then return true end + if cfg.critters and CreatureType(plate) == 8 then return true end + if cfg.totems and CreatureType(plate) == 11 then return true end + return nil + end + + local function UpdateGlow(plate) + plate.glow:SetShown(plate.istarget and cfg.targetglow and plate.mode ~= "totem") + end + + -- Which of the three looks the plate wears: the totem icon alone, name and + -- level without a bar, or the full plate. Re-anchors only when the look + -- changes. + function UpdateLayout(plate) + local mode, icon + if cfg.totemicons and CreatureType(plate) == 11 then + -- UnitCreatedBySpell returns the totem-drop spell, whose icon IS the + -- totem's icon. It's a broadcast descriptor field the client has for + -- every summoned unit in range, so it resolves immediately for passive + -- and active totems alike. + local spellId = UnitCreatedBySpell(plate.unit) + if spellId ~= plate.totemSpell then + plate.totemSpell = spellId + plate.totemIcon = spellId and C_Spell.GetSpellTexture(spellId) or nil + end + icon = plate.totemIcon + end + + if icon then + mode = "totem" + if plate.totemShown ~= icon then + plate.totemShown = icon + plate.totem.icon:SetTexture(icon) + end + elseif HidePlate(plate) then + mode = "name" + else + mode = "bar" + end + plate.mode = mode + + local key = plate.guildshown and mode .. "+guild" or mode + if plate.layout ~= key then + plate.layout = key + local font_size = cfg.font_size + + if mode == "totem" then + plate.level:Hide() + plate.name:Hide() + plate.health:Hide() + plate.guild:Hide() + plate.totem:Show() + elseif mode == "name" then + plate.level:SetPoint("RIGHT", plate.name, "LEFT", -3, 0) + plate.name:SetParent(plate) + plate.guild:SetPoint("BOTTOM", plate.name, "BOTTOM", -2, -(font_size + 2)) + plate.glow:SetPoint("CENTER", plate.name, "CENTER", 0, plate.guildshown and -(font_size / 2) - 2 or 0) + + plate.level:Show() + plate.name:Show() + plate.health:Hide() + plate.guild:SetShown(plate.guildshown) + plate.totem:Hide() + else + plate.level:SetPoint("RIGHT", plate.health, "LEFT", -5, 0) + plate.name:SetParent(plate.health) + plate.guild:SetPoint("BOTTOM", plate.health, "BOTTOM", 0, -(font_size + 4)) + plate.glow:SetPoint("CENTER", plate.health, "CENTER", 0, 0) + + plate.level:Show() + plate.name:Show() + plate.health:Show() + plate.guild:SetShown(plate.guildshown) + plate.totem:Hide() + end + end + + UpdateGlow(plate) end local function CreateDebuffIcon(plate, index) - plate.debuffs[index] = CreateFrame("Frame", plate.platename.."Debuff"..index, plate) - plate.debuffs[index]:Hide() - plate.debuffs[index]:SetFrameLevel(4) + local icon = CreateFrame("Frame", plate.platename.."Debuff"..index, plate) + icon:Hide() + icon:SetFrameLevel(4) - plate.debuffs[index].icon = plate.debuffs[index]:CreateTexture(nil, "BACKGROUND") - plate.debuffs[index].icon:SetColorTexture(.3,1,.8,1) - plate.debuffs[index].icon:SetAllPoints(plate.debuffs[index]) + icon.icon = icon:CreateTexture(nil, "BACKGROUND") + icon.icon:SetColorTexture(.3,1,.8,1) + icon.icon:SetAllPoints(icon) - plate.debuffs[index].stacks = plate.debuffs[index]:CreateFontString(nil, "OVERLAY") - plate.debuffs[index].stacks:SetAllPoints(plate.debuffs[index]) - plate.debuffs[index].stacks:SetJustifyH("RIGHT") - plate.debuffs[index].stacks:SetJustifyV("BOTTOM") - plate.debuffs[index].stacks:SetTextColor(1,1,0) + icon.stacks = icon:CreateFontString(nil, "OVERLAY") + icon.stacks:SetAllPoints(icon) + icon.stacks:SetJustifyH("RIGHT") + icon.stacks:SetJustifyV("BOTTOM") + icon.stacks:SetTextColor(1,1,0) - plate.debuffs[index].cd = CreateCooldown(plate.debuffs[index], "nameplate", plate.platename.."Debuff"..index.."Cooldown") - plate.debuffs[index].cd:SetFrameLevel(6) + icon.cd = CreateCooldown(icon, "nameplate", plate.platename.."Debuff"..index.."Cooldown") + icon.cd:SetFrameLevel(6) + + plate.debuffs[index] = icon end local function UpdateDebuffConfig(nameplate, i) @@ -472,333 +721,464 @@ pfUI:RegisterModule("nameplates", function () nameplate.debuffs[i]:SetSize(debuffsize, debuffsize) end - -- create nameplate core -local nameplates = CreateFrame("Frame", "pfNameplates", UIParent) -nameplates:RegisterEvent("PLAYER_ENTERING_WORLD") -nameplates:RegisterEvent("PLAYER_TARGET_CHANGED") -nameplates:RegisterEvent("PLAYER_LOGOUT") -nameplates:RegisterUnitEvent("UNIT_COMBO_POINTS", "player") -nameplates:RegisterEvent("PLAYER_COMBO_POINTS") -nameplates:RegisterEvent("ZONE_CHANGED_NEW_AREA") -nameplates:RegisterEvent("RAID_ROSTER_UPDATE") -nameplates:RegisterEvent("PARTY_MEMBERS_CHANGED") -nameplates:RegisterEvent("NAME_PLATE_CREATED") -nameplates:RegisterEvent("NAME_PLATE_UNIT_ADDED") -nameplates:RegisterEvent("NAME_PLATE_UNIT_REMOVED") -nameplates:RegisterEvent("UPDATE_MOUSEOVER_UNIT") --- UNIT_AURA_DELTA / UNIT_FLAGS / UNIT_SPELLCAST_* are registered per plate, on the --- plate's own frame, against its own token -- see OnCreate and --- NAME_PLATE_UNIT_ADDED. -nameplates:RegisterEvent("PLAYER_GUILD_UPDATE") --- The interruptible flips only fire for target and focus, not nameplate --- tokens. castState is keyed by GUID, so they still reach those units' plates. -nameplates:RegisterUnitEvent("UNIT_SPELLCAST_INTERRUPTIBLE", "target", "focus") -nameplates:RegisterUnitEvent("UNIT_SPELLCAST_NOT_INTERRUPTIBLE", "target", "focus") - - nameplates:SetScript("OnEvent", function() - -- Stop event handling during logout to prevent crash 132 - if event == "PLAYER_LOGOUT" then - this:UnregisterAllEvents() - this:SetScript("OnEvent", nil) - this:SetScript("OnUpdate", nil) - -- The plates hold their own unit subscriptions now, so silencing this - -- frame alone would leave them dispatching through logout -- exactly what - -- this branch exists to prevent. Leaving the world destroys the units - -- before PLAYER_LOGOUT fires, and a destroyed plate already dropped its - -- events and lost its methods, so only the live ones need silencing. - for plate in pairs(registry) do - if plate.nameplate and plate.nameplate.UnregisterAllEvents then - plate.nameplate:UnregisterAllEvents() - plate.nameplate:SetScript("OnEvent", nil) + -- UNIT_AURA_DELTA: one GetAuraSlots enumeration, then positional by-slot + -- reads straight onto the icons. The scan allocates nothing. + function UpdateDebuffs(plate) + local index = 1 + local friendly = plate.friendly + if cfg.showdebuffs and (friendly and cfg.showdebuffs_friendly or (not friendly and cfg.showdebuffs_hostile)) then + local unit = plate.unit + local now = GetTime() + local n = ScanAuraSlots(unit, cfg.debufffilter, auraSlots, 16) + for i = 1, n do + local effect, texture, stacks, _, duration, expirationTime = C_UnitAuras.UnitAuraBySlot(unit, auraSlots[i]) + if not effect then break end + if texture and DebuffFilter(effect) then + local icon = plate.debuffs[index] + if not icon then + CreateDebuffIcon(plate, index) + UpdateDebuffConfig(plate, index) + icon = plate.debuffs[index] + end + + icon:Show() + icon.icon:SetTexture(texture) + icon.icon:SetTexCoord(.078, .92, .079, .937) + + if stacks and stacks > 1 and cfg.showstacks then + icon.stacks:SetText(stacks) + icon.stacks:Show() + else + icon.stacks:Hide() + end + + local timeleft = expirationTime and expirationTime > 0 and (expirationTime - now) + if duration and timeleft then + -- C_UnitAuras returns the Spell.dbc base duration, which talents + -- can extend past -- Shadow Affinity bumps SW:P from 18s to 24s, + -- so a fresh cast has timeleft > duration and `now + timeleft - + -- duration` lands in the future, which CooldownFrame_SetTimer + -- treats as "not yet started" (no swirl). Widen to whichever is + -- larger so start <= now. + local effDuration = duration > timeleft and duration or timeleft + local start = now + timeleft - effDuration + icon.cd:Show() + if icon.cdEffect ~= effect or abs(icon.cdStart - start) > 0.5 then + CooldownFrame_SetTimer(icon.cd, start, effDuration, 1) + icon.cdEffect = effect + icon.cdStart = start + end + else + icon.cd:Hide() + icon.cdEffect = nil + end + + index = index + 1 + if index > 16 then break end end end - return + end - elseif event == "PLAYER_GUILD_UPDATE" and arg1 == 'player' then - myGuild = GetGuildInfo("player") + -- hide remaining debuffs + for i = index, table.getn(plate.debuffs) do + plate.debuffs[i]:Hide() + end + end - elseif event == "PLAYER_ENTERING_WORLD" or event == "ZONE_CHANGED_NEW_AREA" then - if event == "PLAYER_ENTERING_WORLD" then - CacheConfig() - this:SetGameVariables() - RebuildRaidGuidCache() - frameState.targetGuid = UnitGUID("target") - myGuild = GetGuildInfo("player") - end - - -- Handle friendly zone nameplate disable feature - local disableHostile = C.nameplates["disable_hostile_in_friendly"] == "1" - local disableFriendly = C.nameplates["disable_friendly_in_friendly"] == "1" - - if disableHostile or disableFriendly then - local pvpType = GetZonePVPInfo() - local nowFriendly = (pvpType == "friendly") - - if nowFriendly and not inFriendlyZone then - -- Entering friendly zone - save current state and hide based on options - inFriendlyZone = true - savedHostileState = C.nameplates["showhostile"] - savedFriendlyState = C.nameplates["showfriendly"] - - if disableHostile then - _G.NAMEPLATES_ON = nil - HideNameplates() - end - - if disableFriendly then - _G.FRIENDNAMEPLATES_ON = nil - HideFriendNameplates() - end - elseif not nowFriendly and inFriendlyZone then - -- Leaving friendly zone - restore previous state - inFriendlyZone = false - - if savedHostileState == "1" then - _G.NAMEPLATES_ON = true - ShowNameplates() - end - - if savedFriendlyState == "1" then - _G.FRIENDNAMEPLATES_ON = true - ShowFriendNameplates() - end - - savedHostileState = nil - savedFriendlyState = nil - end - end + local function UpdateCombo(plate) + local cp = plate.istarget and cfg.cpdisplay and GetComboPoints("target") or 0 + if plate.cpShown == cp then return end + plate.cpShown = cp + for i = 1, 5 do plate.combopoints[i]:SetShown(i <= cp) end + end - elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then - RebuildRaidGuidCache() + local function UpdateAlpha(plate) + local alpha = (plate.istarget or not targetGuid) and 1 or cfg.notargalpha + if plate.alpha == alpha then return end + plate.alpha = alpha + plate:SetAlpha(alpha) + end - elseif event == "NAME_PLATE_CREATED" then - -- arg1 = the nameplate Frame; build the pfUI overlay once per pool slot - if arg1 and not registry[arg1] then - nameplates.OnCreate(arg1) - registry[arg1] = arg1 - end + local function UpdateStrata(plate) + local strata = plate.istarget and "LOW" or "BACKGROUND" + if plate.strata == strata then return end + plate.strata = strata + plate:SetFrameStrata(strata) + end - elseif event == "NAME_PLATE_UNIT_ADDED" then - -- arg1 = "nameplateN" unit token. Cache the GUID for cache keys and the - -- token itself for token-based UnitX reads (stable per plate lifetime). - local plate = C_NamePlate.GetNamePlateForUnit(arg1) - if plate and plate.nameplate then - visiblePlates[plate] = plate - local guid = UnitGUID(arg1) - plate.nameplate.cachedGuid = guid - plate.nameplate.unit = arg1 + -- ============================================================================ + -- Animation driver: castbar sweeps and the target zoom. Shown only while + -- something moves. + -- ============================================================================ + local driver = CreateFrame("Frame", nil, UIParent) + driver:Hide() - -- Point this plate's own subscriptions at the token it just took. On a - -- recycled frame these replace the previous unit rather than stacking: - -- RegisterUnitEvent on an already-filtered registration swaps the units. - plate.nameplate:RegisterUnitEvent("UNIT_AURA_DELTA", arg1) - plate.nameplate:RegisterUnitEvent("UNIT_FLAGS", arg1) - plate.nameplate:RegisterUnitEvent("UNIT_TARGET", arg1) - plate.nameplate:RegisterUnitEvent("UNIT_SPELLCAST_START", arg1) - plate.nameplate:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_START", arg1) - plate.nameplate:RegisterUnitEvent("UNIT_SPELLCAST_STOP", arg1) - plate.nameplate:RegisterUnitEvent("UNIT_SPELLCAST_CHANNEL_STOP", arg1) - plate.nameplate.creatureType = nil -- recompute for the new unit - plate.nameplate.totemIcon = nil - plate.nameplate.totemSpell = nil - if guid then - plateByGuid[guid] = plate.nameplate - -- Seed: the unit may already be mid-cast (its UNIT_SPELLCAST_START - -- fired before this plate existed). One poll here catches that; - -- ongoing casts arrive via the event. - castState[guid] = PollCastInfo(arg1) - end - nameplates.OnShow(plate) - end - visiblePlateCount = visiblePlateCount + 1 + -- f = 0 is the configured bar, f = 1 the zoomed one + function SetZoom(plate, f) + plate.zoomf = f + plate.health:SetSize(cfg.width * (1 + (cfg.zoomval - 1) * f), cfg.heighthealth * (1 + (cfg.zoomval * .9 - 1) * f)) + end - elseif event == "NAME_PLATE_UNIT_REMOVED" then - visiblePlateCount = visiblePlateCount > 0 and visiblePlateCount - 1 or 0 - -- arg1 = "nameplateN" unit token; UnitGUID still resolves inside the - -- handler (the slot is freed after dispatch returns) - local plate = C_NamePlate.GetNamePlateForUnit(arg1) - if plate then visiblePlates[plate] = nil end - local guid = UnitGUID(arg1) - if guid then - if debuffCache[guid] then debuffCache[guid] = nil end - if threatMemory[guid] then threatMemory[guid] = nil end - if combatColorCache[guid] then combatColorCache[guid] = nil end - if castState[guid] then castState[guid] = nil end - if plateByGuid[guid] then plateByGuid[guid] = nil end - if plate and plate.nameplate and plate.nameplate.cachedGuid == guid then - plate.nameplate.cachedGuid = nil - plate.nameplate.unit = nil - -- Drop the subscriptions with the token: this slot is now free and the - -- next plate to take it would otherwise feed this frame its events. - plate.nameplate:UnregisterAllEvents() - end - end + -- Continues from wherever the bar is, so a target swap mid-zoom doesn't jump. + local function StartZoom(plate, zoomIn) + local f = plate.zoomf or 0 + if (zoomIn and f >= 1) or (not zoomIn and f <= 0) then return end + plate.zoomIn = zoomIn + plate.zoomStart = GetTime() - (zoomIn and f or 1 - f) * ZOOM_TIME + zooming[plate] = true + driver:Show() + end - elseif event == "UPDATE_MOUSEOVER_UNIT" then - local new = UnitGUID("mouseover") - local old = frameState.mouseoverGuid - if new ~= old then - frameState.mouseoverGuid = new - local po = old and plateByGuid[old] - if po then po.eventcache = true end - local pn = new and plateByGuid[new] - if pn then pn.eventcache = true end - end - - elseif event == "PLAYER_TARGET_CHANGED" then - frameState.targetGuid = UnitGUID('target') - -- Flag the target's plate for update - local plate = C_NamePlate.GetNamePlateForUnit("target") - if plate and plate.nameplate then - plate.nameplate.targetUpdate = true - end - -- Also propagate to all plates for alpha/strata updates - this.eventcache = true - - elseif event == "UNIT_SPELLCAST_INTERRUPTIBLE" or event == "UNIT_SPELLCAST_NOT_INTERRUPTIBLE" then - local guid = UnitGUID(arg1) - local info = guid and castState[guid] - local plate = info and plateByGuid[guid] - if plate and plate.castbar then - info.noInterrupt = event == "UNIT_SPELLCAST_NOT_INTERRUPTIBLE" - -- UpdateCastbar only restyles on a new cast; force it - plate.castbar.lastEndTime = nil - plate.castUpdate = true - end - - elseif event == "PLAYER_COMBO_POINTS" or event == "UNIT_COMBO_POINTS" then - -- Only flag the target's plate for combo point update - local plate = C_NamePlate.GetNamePlateForUnit("target") - if plate and plate.nameplate then - plate.nameplate.comboUpdate = true + -- Skip SetText when the rounded value hasn't changed since last frame. + -- `string.format("%.2f", ...)` allocates a fresh string every call; this + -- gate compares cheap integers and only formats when the displayed value + -- would actually differ. Reset via lastEndTime-changed branch below. + local function SetCastbarText(castbar, remaining) + local rounded + if cfg.castbardecimals then + rounded = floor(remaining * 10) + if castbar.lastTextTick ~= rounded then + castbar.lastTextTick = rounded + castbar.text:SetText(rounded / 10) end else - this.eventcache = true + rounded = floor(remaining * 100) + if castbar.lastTextTick ~= rounded then + castbar.lastTextTick = rounded + castbar.text:SetFormattedText("%.2f", remaining) + end end - end) + end - nameplates:SetScript("OnUpdate", function() - -- PERF: Throttle central OnUpdate to ~80 FPS (0.0125s) - -- Saves ~44% calls at 144 FPS while staying above 50 FPS target-plate rate + local function UpdateCastbar(plate, info, now) + local castbar = plate.castbar + local duration = info.duration + if castbar.lastEndTime ~= info.endTime then + castbar.lastEndTime = info.endTime + castbar.lastTextTick = nil + -- Relative 0..duration range to avoid float precision loss with large + -- absolute timestamps. + castbar:SetMinMaxValues(0, duration) + SetCastbarShield(castbar, castbar.icon, info.noInterrupt, info.isChannel) + if info.icon then + castbar.icon.tex:SetTexture(info.icon) + castbar.icon.tex:SetTexCoord(.1,.9,.1,.9) + end + castbar.spell:SetText(cfg.spellname and info.spellName or "") + end + + local value = info.isChannel and (info.endTime - now) or (now - info.startTime) + if value < 0 then value = 0 elseif value > duration then value = duration end + castbar:SetValue(value) + SetCastbarText(castbar, info.endTime - now) + if not castbar.isShown then + castbar.isShown = true + castbar:Show() + end + end + + function HideCast(plate) + activeCasts[plate] = nil + local castbar = plate.castbar + if castbar.isShown then + castbar.isShown = nil + castbar.lastEndTime = nil + castbar:Hide() + end + end + + -- Start or stop the plate's castbar from the cast cache and the config. + function UpdateCast(plate) + local info = castState[plate.guid] + if info and cfg.showcastbar and (plate.istarget or not cfg.targetcastbar) then + if not activeCasts[plate] then + activeCasts[plate] = true + plate.casttick = 0 + driver:Show() + end + UpdateCastbar(plate, info, GetTime()) + else + HideCast(plate) + end + end + + driver:SetScript("OnUpdate", function() local now = GetTime() - if (this.frameTick or 0) + 0.01 > now then return end - this.frameTick = now - -- PERF: Cache GetTime() once per frame - frameState.now = now - - if this.eventcache then - this.eventcache = nil - for plate in pairs(visiblePlates) do - plate.nameplate.eventcache = true + -- The target's bar sweeps every frame; the others at the castbar rate. + for plate in pairs(activeCasts) do + if plate.istarget or plate.casttick + cfg.throttle_castbar <= now then + plate.casttick = now + local info = castState[plate.guid] + if info and info.endTime > now then + UpdateCastbar(plate, info, now) + else + -- ran out without a STOP: treat it as one + castState[plate.guid] = nil + HideCast(plate) + UpdateCombatColor(plate) + end end end - for plate in pairs(visiblePlates) do - if plate:IsVisible() then - nameplates.OnUpdate(plate, frameState) + for plate in pairs(zooming) do + local f = (now - plate.zoomStart) / ZOOM_TIME + if f >= 1 then + f = 1 + zooming[plate] = nil end + SetZoom(plate, plate.zoomIn and f or 1 - f) end + + if not next(activeCasts) and not next(zooming) then this:Hide() end end) - -- combat tracker - nameplates.combat = CreateFrame("Frame") - nameplates.combat:RegisterEvent("PLAYER_ENTER_COMBAT") - nameplates.combat:RegisterEvent("PLAYER_LEAVE_COMBAT") - nameplates.combat:RegisterEvent("PLAYER_LOGOUT") - nameplates.combat:SetScript("OnEvent", function() - if event == "PLAYER_LOGOUT" then - this:UnregisterAllEvents() - this:SetScript("OnEvent", nil) - return - elseif event == "PLAYER_ENTER_COMBAT" then - this.inCombat = 1 - if PlayerFrame then PlayerFrame.inCombat = 1 end - elseif event == "PLAYER_LEAVE_COMBAT" then - this.inCombat = nil - if PlayerFrame then PlayerFrame.inCombat = nil end - -- Clear threat memory when leaving combat - for k in pairs(threatMemory) do - threatMemory[k] = nil - end - end - end) + -- ============================================================================ + -- Cast events (per plate) + -- ============================================================================ + local function OnCastStart(plate) + -- the payload carries no timing, so poll it (PollCastInfo picks cast vs + -- channel) + castState[plate.guid] = PollCastInfo(plate.unit) + UpdateCast(plate) + UpdateCombatColor(plate) + end - nameplates.OnCreate = function(frame) - local parent = frame or this + local function OnCastStop(plate) + -- ended: natural, interrupted or cancelled -- the poll fires STOP for all + if not castState[plate.guid] then return end + castState[plate.guid] = nil + HideCast(plate) + UpdateCombatColor(plate) + end + + -- ============================================================================ + -- Target + -- ============================================================================ + local function SetTarget(plate, istarget) + plate.istarget = istarget + UpdateStrata(plate) + UpdateBorder(plate) + UpdateLayout(plate) + UpdateCombo(plate) + UpdateCast(plate) + if cfg.targetzoom then StartZoom(plate, istarget) end + end + + -- ============================================================================ + -- Binding a plate to a unit + -- ============================================================================ + + -- Every unit event the plate listens to, on its own frame against its token. + local PLATE_EVENTS = { + "UNIT_HEALTH", "UNIT_MAXHEALTH", + "UNIT_AURA_DELTA", + "UNIT_FLAGS", "UNIT_DYNAMIC_FLAGS", "UNIT_FACTION", + "UNIT_LEVEL", "UNIT_CLASSIFICATION_CHANGED", "UNIT_NAME_UPDATE", + "UNIT_TARGET", + "UNIT_SPELLCAST_START", "UNIT_SPELLCAST_CHANNEL_START", + "UNIT_SPELLCAST_STOP", "UNIT_SPELLCAST_CHANNEL_STOP", + } + local NUM_PLATE_EVENTS = table.getn(PLATE_EVENTS) + + local plateEvents = { + UNIT_HEALTH = UpdateHealth, + UNIT_MAXHEALTH = UpdateHealth, + UNIT_AURA_DELTA = UpdateDebuffs, + UNIT_FLAGS = UpdateFlags, + UNIT_DYNAMIC_FLAGS = UpdateTapped, + UNIT_FACTION = UpdateUnitType, + UNIT_LEVEL = UpdateLevel, + UNIT_CLASSIFICATION_CHANGED = UpdateLevel, + UNIT_NAME_UPDATE = UpdateName, + UNIT_TARGET = UpdateCombatColor, + UNIT_SPELLCAST_START = OnCastStart, + UNIT_SPELLCAST_CHANNEL_START = OnCastStart, + UNIT_SPELLCAST_STOP = OnCastStop, + UNIT_SPELLCAST_CHANNEL_STOP = OnCastStop, + } + + local function OnPlateEvent() + local handler = plateEvents[event] + if handler then handler(this) end + end + + -- State the previous occupant left behind + local PER_UNIT = { + "layout", "mode", "creatureType", "totemSpell", "totemIcon", "totemShown", + "hp", "hpmax", "fullhp", "rawname", "levelnum", "guildtext", "guildshown", + "guildmine", "unittype", "class", "player", "friendly", "combatColor", + "incombat", "tapped", + } + local NUM_PER_UNIT = table.getn(PER_UNIT) + + -- Everything, from the unit. Bind and config changes. Reads first, then one + -- pass over the visuals. + local function Refresh(plate) + local unit = plate.unit + plate.incombat = UnitAffectingCombat(unit) and true or nil + plate.tapped = UnitIsTapped(unit) and not UnitIsTappedByPlayer(unit) and true or nil + ReadUnitType(plate) + ReadCombatColor(plate) + ReadName(plate) + ReadHealth(plate) + UpdateBarColor(plate) + UpdateBorder(plate) + UpdateNameColor(plate) + UpdateLayout(plate) + UpdateDebuffs(plate) + UpdateCombo(plate) + UpdateStrata(plate) + UpdateAlpha(plate) + UpdateCast(plate) + if cfg.targetzoom and plate.istarget then StartZoom(plate, true) end + end + + function Unbind(unit) + local plate = plateByUnit[unit] + if not plate then return end + local guid = plate.guid + plateByUnit[unit] = nil + if plateByGuid[guid] == plate then plateByGuid[guid] = nil end + castState[guid] = nil + threatMemory[guid] = nil + -- Drop the subscriptions with the token: this slot is now free and the + -- next plate to take it would otherwise feed this frame its events. + plate:UnregisterAllEvents() + HideCast(plate) + zooming[plate] = nil + if plate.zoomf and plate.zoomf > 0 then SetZoom(plate, 0) end + plate.unit = nil + plate.guid = nil + plate.targetToken = nil + plate.istarget = nil + end + + local function Bind(unit) + local parent = C_NamePlate.GetNamePlateForUnit(unit) + local plate = parent and parent.nameplate + if not plate then return end + local guid = UnitGUID(unit) + if not guid then return end + + -- ADDED can precede the REMOVED of this frame's or this unit's previous + -- binding within one tick + if plate.unit then Unbind(plate.unit) end + local old = plateByGuid[guid] + if old then Unbind(old.unit) end + + plate.unit = unit + plate.guid = guid + plate.targetToken = unit .. "target" + plate.istarget = guid == targetGuid + plateByGuid[guid] = plate + plateByUnit[unit] = plate + + table.wipe(plate.cache) + for i = 1, NUM_PER_UNIT do plate[PER_UNIT[i]] = nil end + for i = 1, table.getn(plate.debuffs) do plate.debuffs[i].cdEffect = nil end + + -- Point this plate's own subscriptions at the token it just took. On a + -- recycled frame these replace the previous unit rather than stacking: + -- RegisterUnitEvent on an already-filtered registration swaps the units. + for i = 1, NUM_PLATE_EVENTS do + plate:RegisterUnitEvent(PLATE_EVENTS[i], unit) + end + + -- the unit may already be mid-cast (its START fired before this plate + -- existed); ongoing casts arrive via the event + castState[guid] = PollCastInfo(unit) + + Refresh(plate) + end + + -- ============================================================================ + -- Engine frame: size and click routing + -- ============================================================================ + + -- The engine stores a default size into the plate at every bind and shows it + -- afterwards, so OnShow is the moment to put ours back. Overlap mode shrinks + -- the engine frame to a point so the engine's own spacing stops pushing the + -- plates apart; otherwise it matches the overlay so it stays the click target. + local function ApplyFrameLayout(parent) + local plate = parent.nameplate + if cfg.overlap then + parent:SetSize(1, 1) + else + local scale = UIParent:GetScale() + parent:SetSize(plate:GetWidth() * scale, plate:GetHeight() * scale) + end + parent:EnableMouse(cfg.clickable and not cfg.useoverlay) + plate:EnableMouse(cfg.clickable and cfg.useoverlay and not mouseBlocked) + end + + local function OnEngineShow() + if this.nameplate then ApplyFrameLayout(this) end + end + + -- CURRENT_SPELL_CAST_CHANGED: while a spell waits for a ground target the + -- engine's own plate lets the click through; the overlay has to step aside. + local function UpdateSpellTargeting() + if not (cfg.useoverlay and cfg.clickable) then return end + local blocked = SpellIsTargeting() and true or nil + if blocked == mouseBlocked then return end + mouseBlocked = blocked + for _, plate in pairs(plateByGuid) do + plate:EnableMouse(not blocked) + end + end + + local function DisableObject(object) + if not object then return end + if not object.GetObjectType then return end + + local otype = object:GetObjectType() + + if otype == "Texture" then + object:SetTexture("") + object:SetTexCoord(0, 0, 0, 0) + elseif otype == "FontString" then + object:SetWidth(0.001) + elseif otype == "StatusBar" then + object:SetStatusBarTexture("") + end + end + + local function OverlayClick() + this.parent:Click() + end + + -- create nameplate core + local nameplates = CreateFrame("Frame", "pfNameplates", UIParent) + + nameplates.OnCreate = function(parent) platecount = platecount + 1 local platename = "pfNamePlate" .. platecount -- create pfUI nameplate overlay local nameplate = CreateFrame("Button", platename, parent) nameplate.platename = platename - nameplate:EnableMouse(0) + nameplate:EnableMouse(false) nameplate.parent = parent nameplate.cache = {} - nameplate.original = {} -- Each plate watches its own unit. With RegisterUnitEvent the token IS the - -- subscription, so there is no central listener sifting every unit event in - -- the world for a "^nameplate" prefix and then resolving the plate back out - -- of arg1 -- the event arrives only at the plate it concerns, and `this` is - -- already that plate. NAME_PLATE_UNIT_ADDED points the registration at the - -- new token; _REMOVED drops it, which matters because freed slots are - -- reused and a stale token would feed this frame another unit's events. - nameplate:SetScript("OnEvent", function() - if event == "UNIT_AURA_DELTA" then - -- a fresh C_UnitAuras read next tick rather than waiting out the 0.5s - -- throttle -- covers expiry, dispels, refreshes and stack changes - this.auraUpdate = true - elseif event == "UNIT_FLAGS" then - this.eventcache = true - elseif event == "UNIT_TARGET" then - -- the unit turned to someone else: recolour its combat state now - -- rather than once the colour cache and the data tick run out - if cfg.outcombatstate or cfg.barcombatstate then - local cached = this.cachedGuid and combatColorCache[this.cachedGuid] - if cached then cached.expires = 0 end - this.eventcache = true - end - elseif event == "UNIT_SPELLCAST_START" or event == "UNIT_SPELLCAST_CHANNEL_START" then - -- the payload carries no timing, so poll it (PollCastInfo picks cast - -- vs channel) - local guid = this.cachedGuid - if guid then - castState[guid] = PollCastInfo(this.unit) - if castState[guid] then - this.castUpdate = true -- bypass the throttle so the bar shows now - end - end - elseif event == "UNIT_SPELLCAST_STOP" or event == "UNIT_SPELLCAST_CHANNEL_STOP" then - -- ended: natural, interrupted or cancelled -- the poll fires STOP for all - local guid = this.cachedGuid - if guid and castState[guid] then - castState[guid] = nil - this.castUpdate = true - end - end - end) - - -- create shortcuts for all known elements and disable them - nameplate.original.healthbar, nameplate.original.castbar = parent:GetChildren() - DisableObject(nameplate.original.healthbar) - DisableObject(nameplate.original.castbar) - - local NAMEPLATE_OBJECTORDER = { "border", "glow", "name", "level", "levelicon", "raidicon" } + -- subscription: the event arrives only at the plate it concerns, and + -- `this` is already that plate. Bind points the registration at the new + -- token; Unbind drops it. + nameplate:SetScript("OnEvent", OnPlateEvent) + -- Blank the engine's drawing. Its regions come in creation order: border, + -- glow, name, level, skull, raid icon -- the raid icon is the one kept. + DisableObject(parent:GetChildren()) for i, object in pairs({parent:GetRegions()}) do - if NAMEPLATE_OBJECTORDER[i] and NAMEPLATE_OBJECTORDER[i] == "raidicon" then - nameplate[NAMEPLATE_OBJECTORDER[i]] = object - elseif NAMEPLATE_OBJECTORDER[i] then - nameplate.original[NAMEPLATE_OBJECTORDER[i]] = object - DisableObject(object) + if i == 6 then + nameplate.raidicon = object else DisableObject(object) end end - nameplate.original.healthbar:HookScript("OnValueChanged", nameplates.OnValueChanged) - -- adjust sizes and scaling of the nameplate nameplate:SetScale(UIParent:GetScale()) @@ -872,13 +1252,13 @@ nameplates:RegisterUnitEvent("UNIT_SPELLCAST_NOT_INTERRUPTIBLE", "target", "focu castbar:Hide() castbar:SetScript("OnShow", function() - if C.nameplates.debuffs["position"] == "BOTTOM" then + if cfg.debuffbottom then nameplate.debuffs[1]:SetPoint("TOPLEFT", this, "BOTTOMLEFT", 0, -4) end end) castbar:SetScript("OnHide", function() - if C.nameplates.debuffs["position"] == "BOTTOM" then + if cfg.debuffbottom then nameplate.debuffs[1]:SetPoint("TOPLEFT", this:GetParent(), "BOTTOMLEFT", 0, -4) end end) @@ -900,49 +1280,31 @@ nameplates:RegisterUnitEvent("UNIT_SPELLCAST_NOT_INTERRUPTIBLE", "target", "focu nameplate.castbar = castbar end - -- Stagger tick to spread updates across frames (0.05s apart per plate) - nameplate.tick = GetTime() + mathmod(platecount, 10) * 0.05 - parent.nameplate = nameplate - -- NOTE: OnUpdate is now handled centrally, not per-plate/ - parent:SetScript("OnUpdate", nil) -- Disable Blizzard's OnUpdate + parent:HookScript("OnShow", OnEngineShow) nameplates.OnConfigChange(parent) end - nameplates.OnConfigChange = function(frame) - local parent = frame - local nameplate = frame.nameplate + nameplates.OnConfigChange = function(parent) + local nameplate = parent.nameplate local font = C.nameplates.use_unitfonts == "1" and pfUI.font_unit or pfUI.font_default - local font_size = C.nameplates.use_unitfonts == "1" and C.global.font_unit_size or C.global.font_size + local font_size = cfg.font_size local font_style = C.nameplates.name.fontstyle local glowr, glowg, glowb, glowa = GetStringColor(C.nameplates.glowcolor) - local hlr, hlg, hlb, hla = GetStringColor(C.nameplates.highlightcolor) local hptexture = pfUI.media[C.nameplates.healthtexture] - local rawborder, default_border = GetBorderSize("nameplates") + local _, default_border = GetBorderSize("nameplates") local plate_width = C.nameplates.width + 50 local plate_height = C.nameplates.heighthealth + font_size + 5 - -- local plate_height_cast = C.nameplates.heighthealth + font_size + 5 + C.nameplates.heightcast + 5 local combo_size = 5 - -- local width = tonumber(C.nameplates.width) - -- local debuffsize = tonumber(C.nameplates.debuffsize) local healthoffset = tonumber(C.nameplates.health.offset) local orientation = C.nameplates.verticalhealth == "1" and "VERTICAL" or "HORIZONTAL" - local c = combatstate -- load combat state colors - c.CASTING.r, c.CASTING.g, c.CASTING.b, c.CASTING.a = GetStringColor(C.nameplates.combatcasting) - c.THREAT.r, c.THREAT.g, c.THREAT.b, c.THREAT.a = GetStringColor(C.nameplates.combatthreat) - c.NOTHREAT.r, c.NOTHREAT.g, c.NOTHREAT.b, c.NOTHREAT.a = GetStringColor(C.nameplates.combatnothreat) - c.OFFTANK.r, c.OFFTANK.g, c.OFFTANK.b, c.OFFTANK.a = GetStringColor(C.nameplates.combatofftank) - c.STUN.r, c.STUN.g, c.STUN.b, c.STUN.a = GetStringColor(C.nameplates.combatstun) - - RebuildOfftanks() - nameplate:SetSize(plate_width, plate_height) - nameplate:SetPoint("TOP", parent, "TOP", 0, 0) + nameplate:SetPoint("TOP", parent, "TOP", 0, tonumber(C.nameplates.vertical_offset) or 0) nameplate.name:SetFont(font, font_size, font_style) local nameTextPos = C.nameplates.nametextpos or "CENTER" @@ -960,14 +1322,14 @@ nameplates:RegisterUnitEvent("UNIT_SPELLCAST_NOT_INTERRUPTIBLE", "target", "focu nameplate.health:ClearAllPoints() nameplate.health:SetPoint("BOTTOM", nameplate, "BOTTOM", 0, 0) nameplate.health:SetStatusBarTexture(hptexture) - nameplate.health:SetWidth(C.nameplates.width) - nameplate.health:SetHeight(C.nameplates.heighthealth) - nameplate.health.hlr, nameplate.health.hlg, nameplate.health.hlb, nameplate.health.hla = hlr, hlg, hlb, hla + zooming[nameplate] = nil + SetZoom(nameplate, 0) CreateBackdrop(nameplate.health, default_border) nameplate.health.text:SetFont(font, font_size - 2, "OUTLINE") nameplate.health.text:SetJustifyH(C.nameplates.hptextpos) + if not cfg.showhp then nameplate.health.text:SetText("") end nameplate.guild:SetFont(font, font_size, font_style) @@ -1007,751 +1369,26 @@ nameplates:RegisterUnitEvent("UNIT_SPELLCAST_NOT_INTERRUPTIBLE", "target", "focu nameplate.castbar.icon:SetPoint("TOPLEFT", nameplate.health, "TOPRIGHT", default_border*3, 0) nameplate.castbar.icon:SetWidth(C.nameplates.heightcast + default_border*3 + C.nameplates.heighthealth) CreateBackdrop(nameplate.castbar.icon, default_border) - end - nameplates.OnValueChanged = function() - local plate = this:GetParent().nameplate - if plate and plate.health then - plate.health:SetMinMaxValues(plate.original.healthbar:GetMinMaxValues()) - plate.health:SetValue(plate.original.healthbar:GetValue()) - end - end + -- the anchors above are re-applied by the next layout pass, and the + -- colour caches no longer describe what is on screen + nameplate.layout = nil + table.wipe(nameplate.cache) - nameplates.OnDataChanged = function(self, plate) - local visible = plate:IsVisible() - local hp = plate.original.healthbar:GetValue() - local hpmin, hpmax = plate.original.healthbar:GetMinMaxValues() - local name = plate.original.name:GetText() - local level = plate.original.level:IsShown() and plate.original.level:GetObjectType() == "FontString" and tonumber(plate.original.level:GetText()) or "??" + -- click routing: the overlay forwards to the engine plate when it is the + -- one under the mouse; right mouse-look on whichever takes the clicks + local clicker = cfg.useoverlay and nameplate or parent + local other = cfg.useoverlay and parent or nameplate + nameplate:SetScript("OnClick", cfg.useoverlay and OverlayClick or nil) + clicker:SetScript("OnMouseDown", cfg.rightclick and nameplates.mouselook.OnMouseDown or nil) + other:SetScript("OnMouseDown", nil) - -- reset per-unit cache when the plate is reassigned. Gate on GUID *and* - -- name — name alone misses pool reuse between same-named units. - -- Wipe the whole cache table: the PERF gates below ("only update X when - -- X changed") would otherwise skip bar/color/text updates when the new - -- unit happens to share a cached value with the previous occupant - -- (e.g., both at 60% HP percentage on plate pool reuse → bar stays at - -- the old fill until the new mob actually changes HP). - if plate.cache.name ~= name or plate.cache.guid ~= plate.cachedGuid then - table.wipe(plate.cache) - plate.cache.name = name - plate.cache.guid = plate.cachedGuid - plate.cdCache = nil -- new unit, reset spell-keyed timer cache - plate.name:SetText(GetNameString(name)) - end - - local target = plate.istarget - local mouseover = plate.cachedGuid and plate.cachedGuid == frameState.mouseoverGuid or nil - local unitstr = target and "target" or mouseover and "mouseover" or plate.cachedGuid or nil - - -- target event sometimes fires too quickly, where nameplate identifiers are not - -- yet updated. So while being inside this event, we cannot trust the unitstr. - if event == "PLAYER_TARGET_CHANGED" then unitstr = nil end - - -- remove unitstr when it doesn't resolve to this plate's unit (stale istarget, - -- stale mouseover guid or a despawned unit) - if unitstr and UnitName(unitstr) ~= name then unitstr = nil end - - -- the plate's own unit token says who it is; a player too high to show a - -- level keeps the one the player database last saw - local unit = plate.unit - local player, class, elite, guild, ulevel - if unit then - player = UnitIsPlayer(unit) - class = player and UnitClassBase(unit) - elite = UnitClassification(unit) - guild = player and GetGuildInfo(unit) or UnitSubName(unit) - ulevel = UnitLevel(unit) - if player and ulevel <= 0 then - local _ - _, ulevel = GetUnitInfo(name) - end - end - - -- Use the unit's level ONLY if current level is ?? (fixes ?? after reload, but doesn't override visible levels) - local levelFromDB = false - if level == "??" and ulevel and ulevel > 0 then - level = ulevel - levelFromDB = true - end - - local unittype = GetUnitType(plate) - local font_size = C.nameplates.use_unitfonts == "1" and C.global.font_unit_size or C.global.font_size - - if player and unittype == "ENEMY_NPC" then unittype = "ENEMY_PLAYER" end - if player and unittype == "FRIENDLY_NPC" then unittype = "FRIENDLY_PLAYER" end - elite = plate.original.levelicon:IsShown() and not player and "boss" or elite - - -- skip data updates on invisible frames - if not visible then return end - - -- always make sure to keep plate visible - plate:Show() - - plate.glow:SetShown(target and cfg.targetglow) - - -- target indicator - if cfg.outcombatstate then - local guid = plate.cachedGuid or "" - - -- determine color based on combat state - local color = GetCombatStateColor(guid, plate.unit) - if not color then color = combatstate.NONE end - - -- set border color - plate.health.backdrop:SetBackdropBorderColor(color.r, color.g, color.b, color.a) - elseif target and cfg.targethighlight then - plate.health.backdrop:SetBackdropBorderColor(plate.health.hlr, plate.health.hlg, plate.health.hlb, plate.health.hla) - elseif C.nameplates.outfriendlynpc == "1" and unittype == "FRIENDLY_NPC" then - plate.health.backdrop:SetBackdropBorderColor(unpack(unitcolors[unittype])) - elseif C.nameplates.outfriendly == "1" and unittype == "FRIENDLY_PLAYER" then - plate.health.backdrop:SetBackdropBorderColor(unpack(unitcolors[unittype])) - elseif C.nameplates.outneutral == "1" and strfind(unittype, "NEUTRAL") then - plate.health.backdrop:SetBackdropBorderColor(unpack(unitcolors[unittype])) - elseif C.nameplates.outenemy == "1" and strfind(unittype, "ENEMY") then - plate.health.backdrop:SetBackdropBorderColor(unpack(unitcolors[unittype])) - else - plate.health.backdrop:SetBackdropBorderColor(er,eg,eb,ea) - end - - -- hide frames according to the configuration - local TotemIcon = TotemPlate(plate) - - if TotemIcon then - -- icon resolved from the totem-drop spell (already a full path) - plate.totem.icon:SetTexture(TotemIcon) - - plate.glow:Hide() - plate.level:Hide() - plate.name:Hide() - plate.health:Hide() - plate.guild:Hide() - plate.totem:Show() - elseif HidePlate(unittype, (hpmax-hp == hpmin), target, plate) then - plate.level:SetPoint("RIGHT", plate.name, "LEFT", -3, 0) - plate.name:SetParent(plate) - plate.guild:SetPoint("BOTTOM", plate.name, "BOTTOM", -2, -(font_size + 2)) - - plate.level:Show() - plate.name:Show() - plate.health:Hide() - if guild and C.nameplates.showguildname == "1" then - plate.glow:SetPoint("CENTER", plate.name, "CENTER", 0, -(font_size / 2) - 2) - else - plate.glow:SetPoint("CENTER", plate.name, "CENTER", 0, 0) - end - plate.totem:Hide() - else - plate.level:SetPoint("RIGHT", plate.health, "LEFT", -5, 0) - plate.name:SetParent(plate.health) - plate.guild:SetPoint("BOTTOM", plate.health, "BOTTOM", 0, -(font_size + 4)) - - plate.level:Show() - plate.name:Show() - plate.health:Show() - plate.glow:SetPoint("CENTER", plate.health, "CENTER", 0, 0) - plate.totem:Hide() - end - - -- Gate level SetText behind a (level, elite) cache. string.format - -- here was firing every OnDataChanged tick per plate; with the cache - -- it only re-formats on real changes (level up / elite-state flip). - if plate.cache.level ~= level or plate.cache.elite ~= elite then - plate.cache.level = level - plate.cache.elite = elite - plate.level:SetFormattedText("%s%s", level, (elitestrings[elite] or "")) - end - - -- Set level color from GetDifficultyColor when using DB level. - -- No brightening here: adding a flat offset to all three channels - -- desaturates every tier toward white and collapses the boundaries - -- (orange reads as yellow, red reads as orange). Use Blizzard's values. - -- Clearing cache.levelcolor forces the sync block in OnUpdate to - -- re-colour once the ?? resolves and it takes ownership again. - if levelFromDB and type(level) == "number" then - local color = GetDifficultyColor(level) - plate.level:SetTextColor(color.r, color.g, color.b, 1) - plate.cache.levelcolor = nil - end - - -- remember who owns the level colour so the two writers cannot fight - plate.cache.levelfromdb = levelFromDB or nil - - if guild and C.nameplates.showguildname == "1" then - plate.guild:SetText(guild) - if guild == myGuild then - plate.guild:SetTextColor(0, 0.9, 0, 1) - else - plate.guild:SetTextColor(0.8, 0.8, 0.8, 1) - end - plate.guild:Show() - else - plate.guild:Hide() - end - - -- PERF: Only update bar + HP text when values actually changed - if plate.cache.hp ~= hp or plate.cache.hpmax ~= hpmax then - plate.cache.hp = hp - plate.cache.hpmax = hpmax - plate.health:SetMinMaxValues(hpmin, hpmax) - plate.health:SetValue(hp) - - if cfg.showhp then - local rhp, rhpmax, estimated - if unit then - local npHp = UnitHealth(unit) - local npMaxHp = UnitHealthMax(unit) - if npHp and npHp > 0 and npMaxHp and npMaxHp > 0 and npMaxHp ~= 100 then - rhp, rhpmax = npHp, npMaxHp - end - end - - -- Fallback to existing methods - if not rhp then - if hpmax > 100 or (round(hpmax/100*hp) ~= hp) then - rhp, rhpmax = hp, hpmax - elseif pfUI.libhealth and pfUI.libhealth.enabled then - rhp, rhpmax, estimated = pfUI.libhealth:GetUnitHealthByName(name,level,tonumber(hp),tonumber(hpmax)) - end - end - - local setting = cfg.hptextformat - local hasdata = ( rhp and rhpmax ) or estimated or hpmax > 100 or (round(hpmax/100*hp) ~= hp) - - if setting == "curperc" and hasdata and rhp then - plate.health.text:SetFormattedText("%s | %s%%", Abbreviate(rhp), ceil(hp/hpmax*100)) - elseif setting == "cur" and hasdata and rhp then - plate.health.text:SetFormattedText("%s", Abbreviate(rhp)) - elseif setting == "curmax" and hasdata and rhp then - plate.health.text:SetFormattedText("%s - %s", Abbreviate(rhp), Abbreviate(rhpmax)) - elseif setting == "curmaxs" and hasdata and rhp then - plate.health.text:SetFormattedText("%s / %s", Abbreviate(rhp), Abbreviate(rhpmax)) - elseif setting == "curmaxperc" and hasdata and rhp then - plate.health.text:SetFormattedText("%s - %s | %s%%", Abbreviate(rhp), Abbreviate(rhpmax), ceil(hp/hpmax*100)) - elseif setting == "curmaxpercs" and hasdata and rhp then - plate.health.text:SetFormattedText("%s / %s | %s%%", Abbreviate(rhp), Abbreviate(rhpmax), ceil(hp/hpmax*100)) - elseif setting == "deficit" and rhp then - plate.health.text:SetFormattedText("-%s" .. (hasdata and "" or "%%"), Abbreviate(rhpmax - rhp)) - else -- "percent" as fallback - plate.health.text:SetFormattedText("%s%%", ceil(hp/hpmax*100)) - end - else - plate.health.text:SetText() - end - end -- hp cache gate - - local r, g, b, a = unpack(unitcolors[unittype]) - - if class and ( - (unittype == "ENEMY_PLAYER" and C.nameplates["enemyclassc"] == "1") or - (unittype == "FRIENDLY_PLAYER" and C.nameplates["friendclassc"] == "1") - ) then - r, g, b, a = PFUI_CLASS_COLORS[class]:GetRGBA() - end - - if unitstr and UnitIsTapped(unitstr) and not UnitIsTappedByPlayer(unitstr) then - r, g, b, a = .5, .5, .5, .8 - end - - if cfg.barcombatstate then - local guid = plate.cachedGuid or "" - local color = GetCombatStateColor(guid, plate.unit) - - if color then - r, g, b, a = color.r, color.g, color.b, color.a - end - end - - if r ~= plate.cache.r or g ~= plate.cache.g or b ~= plate.cache.b then - plate.health:SetStatusBarColor(r, g, b, a) - plate.cache.r, plate.cache.g, plate.cache.b = r, g, b - end - - -- Friendly player names take this colour when friendclassnamec is on. - -- Ownership is recorded so the OnUpdate sync below stands down rather than - -- racing us: both writers used to share cache.namecolor despite storing - -- unrelated quantities (this colour vs Blizzard's name FontString), so - -- either could suppress the other -- and since nameplate.cache survives - -- pool reuse, a recycled plate could keep the previous unit's name colour. - local ownname = unittype == "FRIENDLY_PLAYER" and C.nameplates["friendclassnamec"] == "1" - and class and true or nil - - if plate.cache.ownname ~= ownname then - plate.cache.ownname = ownname - -- ownership flipped: whichever writer is now in charge must re-assert - plate.cache.namecolor = nil - plate.cache.ownnamecolor = nil - end - - -- read the class colour directly rather than reusing the bar's r,g,b: the - -- bar only carries a class colour when friendclassc happens to be on, and - -- it also picks up the tapped-grey and barcombatstate overrides, neither of - -- which belongs on the name. - if ownname then - local cr, cg, cb, ca = PFUI_CLASS_COLORS[class]:GetRGBA() - if cr + cg + cb ~= plate.cache.ownnamecolor then - plate.cache.ownnamecolor = cr + cg + cb - plate.name:SetTextColor(cr, cg, cb, ca) - end - end - - if target and C.nameplates.cpdisplay == "1" then - local cp = GetComboPoints("target") - if plate.cpShown ~= cp then - for i=1, 5 do plate.combopoints[i]:SetShown(i <= cp) end - plate.cpShown = cp - end - elseif plate.cpShown then - for i=1, 5 do plate.combopoints[i]:Hide() end - plate.cpShown = nil - end - - -- update debuffs - local index = 1 - - local isFriendly = unittype == "FRIENDLY_PLAYER" or unittype == "FRIENDLY_NPC" - local showDebuffsForType = cfg.showdebuffs and (isFriendly and cfg.showdebuffs_friendly or (not isFriendly and cfg.showdebuffs_hostile)) - if showDebuffsForType then - -- Pull debuffs from C_UnitAuras (HARMFUL range). owndebuffs adds the - -- PLAYER filter token so only auras whose caster GUID matches the local - -- player come through. debuffDisplayBuf is a module-level reusable - -- buffer (no GC churn). - local debuffCount = 0 - for i = 1, 16 do debuffDisplayBuf[i].effect = nil end - if unitstr then - local filter = cfg.owndebuffs and "HARMFUL|PLAYER" or "HARMFUL" - local now = GetTime() - -- one GetAuraSlots enumeration, then positional by-slot reads straight - -- into the reusable buffer: the per-plate scan allocates nothing (no - -- per-aura table, no result array) and walks the aura array once - local n = ScanAuraSlots(unitstr, filter, auraSlots, 16) - for i = 1, n do - local aname, icon, count, dispelType, duration, expirationTime = C_UnitAuras.UnitAuraBySlot(unitstr, auraSlots[i]) - if not aname then break end - debuffCount = debuffCount + 1 - local b = debuffDisplayBuf[debuffCount] - b.effect = aname - b.texture = icon - b.stacks = count - b.dtype = dispelType - b.duration = duration - b.timeleft = (expirationTime and expirationTime > 0) and (expirationTime - now) or nil - end - end - for i = 1, 16 do - local effect, texture, stacks, dtype, duration, timeleft - if debuffDisplayBuf[i].effect then - local b = debuffDisplayBuf[i] - effect, texture, stacks, dtype, duration, timeleft = b.effect, b.texture, b.stacks, b.dtype, b.duration, b.timeleft - end - - if effect and texture and DebuffFilter(effect) then - if not plate.debuffs[index] then - CreateDebuffIcon(plate, index) - UpdateDebuffConfig(plate, index) - end - - plate.debuffs[index]:Show() - plate.debuffs[index].icon:SetTexture(texture) - plate.debuffs[index].icon:SetTexCoord(.078, .92, .079, .937) - - if stacks and stacks > 1 and C.nameplates.debuffs["showstacks"] == "1" then - plate.debuffs[index].stacks:SetText(stacks) - plate.debuffs[index].stacks:Show() - else - plate.debuffs[index].stacks:Hide() - end - - if duration and timeleft then - -- C_UnitAuras returns the Spell.dbc base duration, which talents - -- can extend past — Shadow Affinity bumps SW:P from 18s to 24s, - -- so a fresh cast has timeleft > duration and `now + timeleft - - -- duration` lands in the future, which CooldownFrame_SetTimer - -- treats as "not yet started" (no swirl). Widen to whichever is - -- larger so start <= now. - local effDuration = duration > timeleft and duration or timeleft - plate.cdCache = plate.cdCache or {} - local newStart = GetTime() + timeleft - effDuration - local slotCache = plate.cdCache[index] - local cachedStart = slotCache and slotCache.effect == effect and slotCache.start - local cd = plate.debuffs[index].cd - cd:Show() - if not cachedStart or abs(cachedStart - newStart) > 0.5 then - CooldownFrame_SetTimer(cd, newStart, effDuration, 1) - plate.cdCache[index] = plate.cdCache[index] or {} - plate.cdCache[index].effect = effect - plate.cdCache[index].start = newStart - end - end - - index = index + 1 - end - end - end - - -- hide remaining debuffs - for i = index, 16 do - if plate.debuffs[i] then - plate.debuffs[i]:Hide() - end - end - end - - nameplates.OnShow = function(frame) - nameplates:OnDataChanged((frame or this).nameplate) - end - - nameplates.OnUpdate = function(frame, state) - local nameplate = frame.nameplate - local now = state and state.now or GetTime() - - -- cachedGuid is maintained by NAME_PLATE_UNIT_ADDED / _REMOVED events. - - -- Cheap gate first. The central loop calls this for every visible plate ~100 - -- times a second, and classifying the plate below costs two C calls, a cast - -- lookup and a throttle resolution -- all of it wasted on a plate that is - -- throttled to 10fps. cfg.throttle_min is the floor across every category, - -- so nothing that would have updated can be turned away here; the real - -- category-specific throttle is still applied after the classification. - -- Event flags bypass both gates, as before. - local hasEventUpdate = nameplate.eventcache or nameplate.auraUpdate or nameplate.castUpdate or nameplate.targetUpdate or nameplate.comboUpdate - if not hasEventUpdate and (nameplate.lasttick or 0) + cfg.throttle_min > now then return end - - -- PERF: Intelligent throttling based on target/castbar status and plate count - -- Use GUID comparison for target detection: instant, immune to alpha transitions, - -- and immediately correct on de-target (unlike istarget which updates one tick later) - local targetGuid = state and state.targetGuid - local target = (targetGuid and nameplate.cachedGuid and targetGuid == nameplate.cachedGuid) or nil - -- Target plate castbar runs on its own dedicated frame (nameplates.castbarFrame). - -- For non-target plates with castbar active, use castbar throttle to ensure - -- smooth animation without overloading the central loop. - local isCastingNonTarget = not target and nameplate.castbar and nameplate.castbar:IsShown() - if not isCastingNonTarget and not target and cfg.showcastbar and nameplate.cachedGuid then - local castInfo = GetCastInfo(nameplate.unit) - if castInfo and castInfo.endTime > now then - isCastingNonTarget = true - end - end - - -- hide castbar before throttle if no cast active - if not isCastingNonTarget and not target then - if nameplate.castbar.isShown then - nameplate.castbar.isShown = nil - nameplate.castbar.lastEndTime = nil - nameplate.castbar:Hide() - end - end - - -- Resolved in CacheConfig, so these are table reads rather than a walk - -- through the saved variables and preset tables. - local throttle - if target then - throttle = cfg.throttle_target - elseif visiblePlateCount > 20 then - throttle = cfg.throttle_mass - else - throttle = cfg.throttle_normal - end - - -- Non-target plates with active castbar use the castbar throttle - if isCastingNonTarget and cfg.throttle_castbar < throttle then - throttle = cfg.throttle_castbar - end - - -- The category-specific gate. hasEventUpdate was read above, before the - -- classification, and still bypasses the throttle. - if not hasEventUpdate and (nameplate.lasttick or 0) + throttle > now then return end - nameplate.lasttick = now - - -- ========================================================================= - -- EVERYTHING BELOW RUNS AT THROTTLED RATE (50 FPS target, 10 FPS others) - -- ========================================================================= - - local update - local original = nameplate.original - local mouseover = nameplate.cachedGuid and nameplate.cachedGuid == frameState.mouseoverGuid or nil - - -- trigger queued event update - if hasEventUpdate then - nameplates:OnDataChanged(nameplate) - nameplate.eventcache = nil - nameplate.auraUpdate = nil - nameplate.castUpdate = nil - nameplate.targetUpdate = nil - nameplate.comboUpdate = nil - end - - -- ========================================================================= - -- OVERLAP/CLICKTHROUGH HANDLING - -- ========================================================================= - local useOverlap = C.nameplates["overlap"] == "1" or C.nameplates["vertical_offset"] ~= "0" - local clickable = C.nameplates["clickthrough"] ~= "1" - - if not clickable then - frame:EnableMouse(false) - nameplate:EnableMouse(false) - else - local plate = useOverlap and nameplate or frame - plate:EnableMouse(clickable) - end - - if C.nameplates["overlap"] == "1" then - if frame:GetWidth() > 1 then - frame:SetSize(1, 1) - end - else - if not nameplate.dwidth then - nameplate.dwidth = floor(nameplate:GetWidth() * UIParent:GetScale()) - end - - if floor(frame:GetWidth()) ~= nameplate.dwidth then - local nameW, nameH = nameplate:GetSize() - local uiScale = UIParent:GetScale() - frame:SetSize(nameW * uiScale, nameH * uiScale) - end - end - - local mouseEnabled = nameplate:IsMouseEnabled() - if C.nameplates["clickthrough"] == "0" and C.nameplates["overlap"] == "1" and SpellIsTargeting() == mouseEnabled then - nameplate:EnableMouse(not mouseEnabled) - end - - -- Cache strata changes - if nameplate.istarget ~= target then - nameplate.target_strata = nil - end - - if target and nameplate.target_strata ~= 1 then - nameplate:SetFrameStrata("LOW") - nameplate.target_strata = 1 - elseif not target and nameplate.target_strata ~= 0 then - nameplate:SetFrameStrata("BACKGROUND") - nameplate.target_strata = 0 - end - - nameplate.istarget = target - - -- Set non-target plate alpha - local configAlpha = cfg.notargalpha or 0.5 - local desiredAlpha = (target or not state.targetGuid) and 1 or configAlpha - - if nameplate.cachedAlpha ~= desiredAlpha then - nameplate:SetAlpha(desiredAlpha) - nameplate.cachedAlpha = desiredAlpha - end - - -- queue update on visual target update - if nameplate.cache.target ~= target then - nameplate.cache.target = target - update = true - end - - -- queue update on visual mouseover update - if nameplate.cache.mouseover ~= mouseover then - nameplate.cache.mouseover = mouseover - update = true - end - - -- trigger update when name color changed (includes combat state check). - -- Skipped once OnDataChanged owns the name colour (class-coloured friendly - -- players), so the two writers cannot overwrite each other. - if not nameplate.cache.ownname then - local r, g, b = original.name:GetTextColor() - local inCombatWithPlayer = cfg.namefightcolor and UnitAffectingCombat(nameplate.unit) and UnitAffectingCombat("player") - - if r + g + b ~= nameplate.cache.namecolor or (cfg.namefightcolor and nameplate.cache.inCombat ~= inCombatWithPlayer) then - nameplate.cache.namecolor = r + g + b - nameplate.cache.inCombat = inCombatWithPlayer - - if cfg.namefightcolor then - if (r > .9 and g < .2 and b < .2) or inCombatWithPlayer then - nameplate.name:SetTextColor(1,0.4,0.2,1) - else - nameplate.name:SetTextColor(r,g,b,1) - end - else - nameplate.name:SetTextColor(1,1,1,1) - end - update = true - end - end - - -- trigger update when level color changed. Skipped while the level came - -- from the database (?? plates), where OnDataChanged owns the colour -- - -- otherwise both writers race and cache.levelcolor goes stale, which - -- leaves a recycled plate wearing the previous unit's colour. - if not nameplate.cache.levelfromdb then - local r, g, b = original.level:GetTextColor() - if r + g + b ~= nameplate.cache.levelcolor then - nameplate.cache.levelcolor = r + g + b - nameplate.level:SetTextColor(r,g,b,1) - update = true - end - end - - -- use timer based updates - if not nameplate.tick or nameplate.tick < now then - update = true - end - - -- run full updates if required - if update then - nameplates:OnDataChanged(nameplate) - nameplate.tick = now + .5 - end - - -- Zoom animation - if target and cfg.targetzoom then - if not nameplate.health.zoomed then - local zoomval = cfg.zoomval - local wc = cfg.width * zoomval - local hc = cfg.heighthealth * (zoomval * .9) - nameplate.health.targetWidth = wc - nameplate.health.targetHeight = hc - end - - local w, h = nameplate.health:GetSize() - local wc, hc = nameplate.health.targetWidth, nameplate.health.targetHeight - - if wc and hc then - if wc > w + 0.5 then - nameplate.health:SetWidth(w*1.05) - nameplate.health.zoomTransition = true - elseif hc > h + 0.5 then - nameplate.health:SetHeight(h*1.05) - nameplate.health.zoomTransition = true - else - if nameplate.health.zoomTransition then - nameplate.health:SetSize(wc, hc) - nameplate.health.zoomTransition = nil - end - nameplate.health.zoomed = true - end - end - elseif nameplate.health.zoomed or nameplate.health.zoomTransition then - local w, h = nameplate.health:GetSize() - local wc = cfg.width - local hc = cfg.heighthealth - - if w > wc + 0.5 then - nameplate.health:SetWidth(w*.95) - elseif h > hc + 0.5 then - nameplate.health:SetHeight(h*0.95) - else - nameplate.health:SetSize(wc, hc) - nameplate.health.zoomTransition = nil - nameplate.health.zoomed = nil - nameplate.health.targetWidth = nil - nameplate.health.targetHeight = nil - end - end - - -- Target plate castbar is handled by nameplates.castbarFrame (dedicated OnUpdate, - -- engine framerate, decoupled from central loop). Only update non-target castbars here. - local isTargetPlate = target or nameplate.istarget or (nameplate.health and nameplate.health.zoomed) - if cfg.showcastbar and not cfg.targetcastbar and not isTargetPlate then - local cbThrottle = cfg.throttle_castbar - if visiblePlateCount > 20 and cfg.throttle_mass > cbThrottle then - cbThrottle = cfg.throttle_mass - end - if (nameplate.castbar_tick or 0) + cbThrottle <= now then - nameplate.castbar_tick = now - nameplates.UpdateCastbar(nameplate, now) - end - elseif cfg.showcastbar and cfg.targetcastbar and not isTargetPlate then - if nameplate.castbar.isShown then - nameplate.castbar.isShown = nil - nameplate.castbar.lastEndTime = nil - nameplate.castbar:Hide() - end - elseif not cfg.showcastbar then - if nameplate.castbar.isShown then - nameplate.castbar.isShown = nil - nameplate.castbar.lastEndTime = nil - nameplate.castbar:Hide() - end - end + ApplyFrameLayout(parent) end -- ============================================================================ - -- DEDICATED TARGET CASTBAR FRAME - -- Runs on its own OnUpdate, decoupled from the central nameplate loop. - -- Mirrors the approach used in castbar.lua for the target unit frame castbar. + -- Nameplate visibility switches -- ============================================================================ - - -- Skip SetText when the rounded value hasn't changed since last frame. - -- `string.format("%.2f", ...)` allocates a fresh string every call; this - -- gate compares cheap integers and only formats when the displayed value - -- would actually differ. Reset via lastEndTime-changed branch above. - local function SetCastbarText(castbar, remaining) - local rounded - if C.unitframes.castbardecimals == "1" then - rounded = floor(remaining * 10) - if castbar.lastTextTick ~= rounded then - castbar.lastTextTick = rounded - castbar.text:SetText(rounded / 10) - end - else - rounded = floor(remaining * 100) - if castbar.lastTextTick ~= rounded then - castbar.lastTextTick = rounded - castbar.text:SetFormattedText("%.2f", remaining) - end - end - end - - -- Shared castbar update logic (used by both dedicated frame and central loop) - nameplates.UpdateCastbar = function(nameplate, now) - if not nameplate or not nameplate.castbar then return end - local castInfo = GetCastInfo(nameplate.unit) - if not castInfo or castInfo.endTime < now then - nameplate.castbar.isShown = nil - nameplate.castbar.lastEndTime = nil - nameplate.castbar:Hide() - return - end - - local isChannel = castInfo.isChannel - local duration = castInfo.duration - if nameplate.castbar.lastEndTime ~= castInfo.endTime then - nameplate.castbar.lastEndTime = castInfo.endTime - nameplate.castbar.lastTextTick = nil - -- Relative 0..duration range to avoid float precision loss with large - -- absolute timestamps. - nameplate.castbar:SetMinMaxValues(0, duration) - SetCastbarShield(nameplate.castbar, nameplate.castbar.icon, castInfo.noInterrupt, isChannel) - if castInfo.icon then - nameplate.castbar.icon.tex:SetTexture(castInfo.icon) - nameplate.castbar.icon.tex:SetTexCoord(.1,.9,.1,.9) - end - if cfg.spellname then - nameplate.castbar.spell:SetText(castInfo.spellName) - else - nameplate.castbar.spell:SetText("") - end - end - - local barValue = isChannel and (castInfo.endTime - now) or (now - castInfo.startTime) - if barValue < 0 then barValue = 0 end - if barValue > duration then barValue = duration end - nameplate.castbar:SetValue(barValue) - SetCastbarText(nameplate.castbar, castInfo.endTime - now) - if not nameplate.castbar.isShown then nameplate.castbar.isShown = true; nameplate.castbar:Show() end - end - - -- Dedicated frame that updates ONLY the target plate castbar. Unthrottled: - -- now that casts are event-driven, this just reads the cache + SetValue, so - -- it animates the fill every frame for the smoothest sweep on the bar the - -- player watches most. (Non-target plates stay throttled via the central - -- loop's nameplates_castbar gate.) - nameplates.castbarFrame = CreateFrame("Frame", nil, UIParent) - nameplates.castbarFrame:SetScript("OnUpdate", function() - if not cfg.showcastbar or not frameState.targetGuid then return end - local nameplate = plateByGuid[frameState.targetGuid] - if not nameplate then return end - nameplates.UpdateCastbar(nameplate, GetTime()) - end) - - -- set nameplate game settings nameplates.SetGameVariables = function() -- update visibility (hostile) if C.nameplates["showhostile"] == "1" then @@ -1762,7 +1399,7 @@ nameplates:RegisterUnitEvent("UNIT_SPELLCAST_NOT_INTERRUPTIBLE", "target", "focu HideNameplates() end - -- update visibility (hostile) + -- update visibility (friendly) if C.nameplates["showfriendly"] == "1" then _G.FRIENDNAMEPLATES_ON = true ShowFriendNameplates() @@ -1774,20 +1411,56 @@ nameplates:RegisterUnitEvent("UNIT_SPELLCAST_NOT_INTERRUPTIBLE", "target", "focu nameplates:SetGameVariables() + -- Handle friendly zone nameplate disable feature + local function UpdateZone() + local disableHostile = C.nameplates["disable_hostile_in_friendly"] == "1" + local disableFriendly = C.nameplates["disable_friendly_in_friendly"] == "1" + if not (disableHostile or disableFriendly) then return end + + local nowFriendly = GetZonePVPInfo() == "friendly" + + if nowFriendly and not inFriendlyZone then + -- Entering friendly zone - save current state and hide based on options + inFriendlyZone = true + savedHostileState = C.nameplates["showhostile"] + savedFriendlyState = C.nameplates["showfriendly"] + + if disableHostile then + _G.NAMEPLATES_ON = nil + HideNameplates() + end + + if disableFriendly then + _G.FRIENDNAMEPLATES_ON = nil + HideFriendNameplates() + end + elseif not nowFriendly and inFriendlyZone then + -- Leaving friendly zone - restore previous state + inFriendlyZone = false + + if savedHostileState == "1" then + _G.NAMEPLATES_ON = true + ShowNameplates() + end + + if savedFriendlyState == "1" then + _G.FRIENDNAMEPLATES_ON = true + ShowFriendNameplates() + end + + savedHostileState = nil + savedFriendlyState = nil + end + end + nameplates.UpdateConfig = function() - -- Refresh config cache for all cfg.* values CacheConfig() - RebuildOfftanks() - - -- update debuff filters - DebuffFilterPopulate() -- Check friendly zone state when config changes local disableHostile = C.nameplates["disable_hostile_in_friendly"] == "1" local disableFriendly = C.nameplates["disable_friendly_in_friendly"] == "1" - local pvpType = GetZonePVPInfo() - local nowFriendly = (pvpType == "friendly") - + local nowFriendly = GetZonePVPInfo() == "friendly" + if nowFriendly and (disableHostile or disableFriendly) then if not inFriendlyZone then -- Just entered friendly zone or feature just enabled @@ -1795,101 +1468,211 @@ nameplates:RegisterUnitEvent("UNIT_SPELLCAST_NOT_INTERRUPTIBLE", "target", "focu savedHostileState = C.nameplates["showhostile"] savedFriendlyState = C.nameplates["showfriendly"] end - + -- Apply current settings based on options if disableHostile then _G.NAMEPLATES_ON = nil HideNameplates() - else + elseif savedHostileState == "1" then -- Restore hostile if option is off but we're in friendly zone - if savedHostileState == "1" then - _G.NAMEPLATES_ON = true - ShowNameplates() - end + _G.NAMEPLATES_ON = true + ShowNameplates() end - + if disableFriendly then _G.FRIENDNAMEPLATES_ON = nil HideFriendNameplates() - else + elseif savedFriendlyState == "1" then -- Restore friendly if option is off but we're in friendly zone - if savedFriendlyState == "1" then - _G.FRIENDNAMEPLATES_ON = true - ShowFriendNameplates() - end + _G.FRIENDNAMEPLATES_ON = true + ShowFriendNameplates() end - - return -- Don't call SetGameVariables - elseif inFriendlyZone and not (disableHostile or disableFriendly) then - -- Both features disabled while in friendly zone - restore state - inFriendlyZone = false - - if savedHostileState == "1" then - C.nameplates["showhostile"] = savedHostileState - end - - if savedFriendlyState == "1" then - C.nameplates["showfriendly"] = savedFriendlyState - end - - savedHostileState = nil - savedFriendlyState = nil - -- Fall through to SetGameVariables to restore nameplates - end + else + if inFriendlyZone and not (disableHostile or disableFriendly) then + -- Both features disabled while in friendly zone - restore state + inFriendlyZone = false - -- update nameplate visibility - nameplates:SetGameVariables() + if savedHostileState == "1" then + C.nameplates["showhostile"] = savedHostileState + end + + if savedFriendlyState == "1" then + C.nameplates["showfriendly"] = savedFriendlyState + end + + savedHostileState = nil + savedFriendlyState = nil + end + + -- update nameplate visibility + nameplates:SetGameVariables() + end -- apply all config changes - for plate in pairs(registry) do - nameplates.OnConfigChange(plate) - if plate.nameplate and plate.nameplate.cachedGuid then - nameplates:OnDataChanged(plate.nameplate) + for parent in pairs(registry) do + nameplates.OnConfigChange(parent) + local plate = parent.nameplate + if plate.unit then + -- the config decides what the plate shows, so read the unit again + for i = 1, NUM_PER_UNIT do plate[PER_UNIT[i]] = nil end + Refresh(plate) end end end - local hookOnConfigChange = nameplates.OnConfigChange - nameplates.OnConfigChange = function(self) - hookOnConfigChange(self) + -- ============================================================================ + -- Module events + -- ============================================================================ + local events = {} - local parent = self - local nameplate = self.nameplate - local plate = (C.nameplates["overlap"] == "1" or C.nameplates["vertical_offset"] ~= "0") and nameplate or parent - - -- disable all clicks for now - parent:EnableMouse(false) - nameplate:EnableMouse(false) - - -- adjust vertical offset - if C.nameplates["vertical_offset"] ~= "0" then - nameplate:SetPoint("TOP", parent, "TOP", 0, tonumber(C.nameplates["vertical_offset"])) + events.PLAYER_LOGOUT = function() + -- Stop event handling during logout to prevent crash 132. The plates hold + -- their own unit subscriptions, so silencing this frame alone would leave + -- them dispatching through logout. Leaving the world destroys the units + -- before PLAYER_LOGOUT fires, and a destroyed plate already dropped its + -- events and lost its methods, so only the live ones need silencing. + nameplates:UnregisterAllEvents() + nameplates:SetScript("OnEvent", nil) + driver:SetScript("OnUpdate", nil) + for parent in pairs(registry) do + local plate = parent.nameplate + if plate and plate.UnregisterAllEvents then + plate:UnregisterAllEvents() + plate:SetScript("OnEvent", nil) + end end + end - -- replace clickhandler - if C.nameplates["overlap"] == "1" or C.nameplates["vertical_offset"] ~= "0" then - plate:SetScript("OnClick", function() parent:Click() end) + events.PLAYER_ENTERING_WORLD = function() + CacheConfig() + nameplates:SetGameVariables() + RebuildRaidGuidCache() + targetGuid = UnitGUID("target") + playerInCombat = UnitAffectingCombat("player") and true or nil + myGuild = GetGuildInfo("player") + UpdateZone() + end + + events.ZONE_CHANGED_NEW_AREA = UpdateZone + + events.PLAYER_GUILD_UPDATE = function(unit) + if unit ~= "player" then return end + myGuild = GetGuildInfo("player") + for _, plate in pairs(plateByGuid) do UpdateName(plate) end + end + + events.RAID_ROSTER_UPDATE = function() + RebuildRaidGuidCache() + -- the offtank list reads the group by name + for _, plate in pairs(plateByGuid) do UpdateCombatColor(plate) end + end + events.PARTY_MEMBERS_CHANGED = events.RAID_ROSTER_UPDATE + + events.NAME_PLATE_CREATED = function(frame) + -- arg1 = the nameplate Frame; build the pfUI overlay once per pool slot + if frame and not registry[frame] then + nameplates.OnCreate(frame) + registry[frame] = true end + end - -- enable mouselook on rightbutton down - if C.nameplates["rightclick"] == "1" then - plate:SetScript("OnMouseDown", nameplates.mouselook.OnMouseDown) + events.NAME_PLATE_UNIT_ADDED = Bind + events.NAME_PLATE_UNIT_REMOVED = Unbind + + events.PLAYER_TARGET_CHANGED = function() + local old = targetGuid + targetGuid = UnitGUID("target") + if old == targetGuid then return end + + local oldplate = old and plateByGuid[old] + local newplate = targetGuid and plateByGuid[targetGuid] + if oldplate then SetTarget(oldplate, nil) end + if newplate then SetTarget(newplate, true) end + + -- having a target at all dims every other plate + if (old == nil) ~= (targetGuid == nil) then + for _, plate in pairs(plateByGuid) do UpdateAlpha(plate) end else - plate:SetScript("OnMouseDown", nil) + if oldplate then UpdateAlpha(oldplate) end + if newplate then UpdateAlpha(newplate) end end end - local hookOnDataChanged = nameplates.OnDataChanged - nameplates.OnDataChanged = function(self, nameplate) - hookOnDataChanged(self, nameplate) + events.PLAYER_COMBO_POINTS = function() + local plate = targetGuid and plateByGuid[targetGuid] + if plate then UpdateCombo(plate) end + end + events.UNIT_COMBO_POINTS = events.PLAYER_COMBO_POINTS - -- make sure to keep mouse events disabled on parent nameplate - if (C.nameplates["overlap"] == "1" or C.nameplates["vertical_offset"] ~= "0") then - nameplate.parent:EnableMouse(false) + events.PLAYER_REGEN_DISABLED = function() + playerInCombat = true + for _, plate in pairs(plateByGuid) do + UpdateCombatColor(plate) + UpdateNameColor(plate) end end - -- enable mouselook on rightbutton down + events.PLAYER_REGEN_ENABLED = function() + playerInCombat = nil + table.wipe(threatMemory) + for _, plate in pairs(plateByGuid) do + UpdateCombatColor(plate) + UpdateNameColor(plate) + end + end + + events.PLAYER_LEVEL_UP = function() + -- difficulty colours, and the skull threshold, move with the player + for _, plate in pairs(plateByGuid) do + plate.cache.level = nil + UpdateLevel(plate) + end + end + + events.CURRENT_SPELL_CAST_CHANGED = UpdateSpellTargeting + + -- The interruptible flips only fire for target and focus, not nameplate + -- tokens. castState is keyed by GUID, so they still reach those units' plates. + events.UNIT_SPELLCAST_INTERRUPTIBLE = function(unit) + local guid = UnitGUID(unit) + local info = guid and castState[guid] + local plate = info and plateByGuid[guid] + if not plate then return end + info.noInterrupt = event == "UNIT_SPELLCAST_NOT_INTERRUPTIBLE" + -- UpdateCastbar only restyles on a new cast; force it + plate.castbar.lastEndTime = nil + UpdateCast(plate) + end + events.UNIT_SPELLCAST_NOT_INTERRUPTIBLE = events.UNIT_SPELLCAST_INTERRUPTIBLE + + -- auto-attack state, for the right-click emulation below + events.PLAYER_ENTER_COMBAT = function() + nameplates.autoattack = true + if PlayerFrame then PlayerFrame.inCombat = 1 end + end + + events.PLAYER_LEAVE_COMBAT = function() + nameplates.autoattack = nil + if PlayerFrame then PlayerFrame.inCombat = nil end + end + + for name in pairs(events) do + if name == "UNIT_COMBO_POINTS" then + nameplates:RegisterUnitEvent(name, "player") + elseif name == "UNIT_SPELLCAST_INTERRUPTIBLE" or name == "UNIT_SPELLCAST_NOT_INTERRUPTIBLE" then + nameplates:RegisterUnitEvent(name, "target", "focus") + else + nameplates:RegisterEvent(name) + end + end + + nameplates:SetScript("OnEvent", function() + events[event](arg1) + end) + + -- ============================================================================ + -- Right-click mouselook + -- ============================================================================ nameplates.mouselook = {} nameplates.mouselook.OnMouseDown = function() if arg1 and arg1 == "RightButton" then @@ -1912,7 +1695,7 @@ nameplates:RegisterUnitEvent("UNIT_SPELLCAST_NOT_INTERRUPTIBLE", "target", "focu if mouselook.time + tonumber(C.nameplates["clickthreshold"]) >= GetTime() then -- run a usual nameplate rightclick action mouselook.frame:Click("LeftButton") - if UnitCanAttack("player", "target") and not nameplates.combat.inCombat then AttackTarget() end + if UnitCanAttack("player", "target") and not nameplates.autoattack then AttackTarget() end end mouselook.frame = nil end)