Files
pfUI/modules/nameplates.lua
Brues 1108746949 castbar: mark a cast that none of your interrupts can stop
ClassicAPI carries notInterruptible on C_Spell.UnitCastingInfo (arg 8) and
UnitChannelInfo (arg 7); both call sites already stepped over it positionally.
The target, focus and nameplate bars now paint it.

There is no shield art to hang on the icon -- this client ships three
CastingBar textures (Border, Flash, Spark), and Blizzard's shield arrived in
3.x -- so the state is carried by color instead: the bar and the icon's
border both take a new appearance.castbar.shieldcolor, which keeps it legible
on a bar configured without an icon. One api helper paints both so the two
modules cannot drift apart on it.

Dusty rose (.8,.45,.45) rather than a steel grey. Grey is the retail
convention, but pfUI's casting color is already a near-grey pale blue, so the
two separated on brightness alone and washed out against the bar texture. The
rose sits above failcolor in luminance and well below it in saturation, and
the two are further told apart by motion: a failed cast flashes the bar full
and fades, a shielded one fills normally and tints the icon border, which
failcolor never touches.

Not on the player's own bar. The flag is measured against the reader's own
interrupts and silences, so on your own cast it answers a question nobody
asked.

Stamped once at cast start, with the rest of the cast data, rather than
re-polled. ClassicAPI evaluates lazily from the caster's live auras but does
not yet fire the 3.3.5 UNIT_SPELLCAST_INTERRUPTIBLE / _NOT_INTERRUPTIBLE
change events, and polling per frame would undo the event-driven rework these
bars just got -- so a caster who bubbles mid-cast keeps the bar it started
with until the next one.

Two limits worth knowing when it stays dark. The value is relative to your own
kit -- with no interrupt and no silence known it is never reported, whatever
the cast -- and a creature's built-in interrupt immunity lives in
creature_template, which 1.12's SMSG_SPELL_START does not carry, so a boss
with that immunity reads interruptible while your kick still fails on it.
2026-09-13 15:19:42 -05:00

1964 lines
78 KiB
Lua

pfUI:RegisterModule("nameplates", function ()
-- disable original castbars
pcall(SetCVar, "ShowVKeyCastbar", 0)
-- Local function references for performance
local GetTime = GetTime
local UnitName = UnitName
local UnitIsPlayer = UnitIsPlayer
local UnitAffectingCombat = UnitAffectingCombat
local UnitIsUnit = UnitIsUnit
local UnitCanAssist = UnitCanAssist
local UnitHealth = UnitHealth
local UnitHealthMax = UnitHealthMax
local pairs = pairs
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
local unitcolors = {
["ENEMY_NPC"] = { .9, .2, .3, .8 },
["NEUTRAL_NPC"] = { 1, 1, .3, .8 },
["FRIENDLY_NPC"] = { .6, 1, 0, .8 },
["ENEMY_PLAYER"] = { .9, .2, .3, .8 },
["FRIENDLY_PLAYER"] = { .2, .6, 1, .8 }
}
local offtanks = {}
local combatstate = {
-- gets overwritten by user config
["OFFTANK"] = { r = .7, g = .4, b = .2, a = 1 },
["NOTHREAT"] = { r = .7, g = .7, b = .2, a = 1 },
["THREAT"] = { r = .7, g = .2, b = .2, a = 1 },
["CASTING"] = { r = .7, g = .2, b = .7, a = 1 },
["STUN"] = { r = .2, g = .7, b = .7, a = 1 },
["NONE"] = { r = .2, g = .2, b = .2, a = 1 },
}
local elitestrings = {
["elite"] = "+",
["rareelite"] = "R+",
["rare"] = "R",
["boss"] = "B"
}
-- 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 = {}
-- 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
-- is already mid-cast (its START fired before the plate existed). Picks cast
-- vs channel itself. Never called per frame.
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
name, _, texture, startMs, endMs, _, noInterrupt, spellID = C_Spell.UnitChannelInfo(unit)
isChannel = true
end
if not name or not startMs or not endMs then return nil end
return {
spellName = name,
spellID = spellID,
icon = texture,
startTime = startMs / 1000,
endTime = endMs / 1000,
duration = (endMs - startMs) / 1000,
isChannel = isChannel,
noInterrupt = noInterrupt,
}
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
-- ============================================================================
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 0.99 so non-target plates never reach 1.0 (used for target detection)
if cfg.notargalpha > 0.99 then cfg.notargalpha = 0.99 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
-- NEW: Cache debuff config
cfg.debufftimers = C.nameplates.debufftimers == "1"
cfg.debuffanim = tonumber(C.nameplates.debuffanim) or 0
cfg.debufftext = tonumber(C.nameplates.debufftext) or 1
-- 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 "<category>_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
-- Rebuild offtanks lookup table
offtanks = {}
for k, v in pairs({strsplit("#", C.nameplates.combatofftanks)}) do
if v ~= "" then offtanks[string.lower(v)] = true end
end
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()
-- ============================================================================
-- 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 "<guid>target" token needed.
local target = token .. "target"
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 targetingPlayer = hasTarget and UnitIsUnit(target, "player")
if targetingPlayer then
threatMemory[guid] = true
elseif hasTarget and not isCasting then
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
elseif cfg.ccombatthreat and (targetingPlayer or threatMemory[guid]) then
color = combatstate.THREAT
elseif cfg.ccombatofftank and targetName and offtanks[strlower(targetName)] then
color = combatstate.OFFTANK
elseif cfg.ccombatofftank and pfUI.uf and pfUI.uf.raid and targetName and pfUI.uf.raid.tankrole[targetName] then
color = combatstate.OFFTANK
elseif cfg.ccombatnothreat and hasTarget then
color = combatstate.NOTHREAT
elseif cfg.ccombatstun and not hasTarget then
color = combatstate.STUN
end
combatColorCache[guid] = combatColorCache[guid] or {}
combatColorCache[guid].color = color
combatColorCache[guid].expires = now + 0.2
return color
end
local function DoNothing()
return
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
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
name = string.gsub(name, "^(%S+) ", abbrevname)
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
local function GetUnitType(red, green, blue)
if red > .9 and green < .2 and blue < .2 then
return "ENEMY_NPC"
elseif red > .9 and green > .9 and blue < .2 then
return "NEUTRAL_NPC"
elseif red < .2 and green < .2 and blue > 0.9 then
return "FRIENDLY_PLAYER"
elseif red < .2 and green > .9 and blue < .2 then
return "FRIENDLY_NPC"
end
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 = {}
-- populate list
for _, val in pairs({strsplit("#", list)}) do
cache[strlower(val)] = true
end
end
local function DebuffFilter(effect)
if filter == "none" then return true end
if not cache then DebuffFilterPopulate() 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
end
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)
plate.debuffs[index].icon = plate.debuffs[index]:CreateTexture(nil, "BACKGROUND")
plate.debuffs[index].icon:SetTexture(.3,1,.8,1)
plate.debuffs[index].icon:SetAllPoints(plate.debuffs[index])
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)
-- PERF: Use lightweight fake cooldown frame when animation disabled
-- The Model-based CooldownFrameTemplate causes major lag with many nameplates
if cfg.debuffanim ~= 1 then
plate.debuffs[index].cd = CreateFrame("Frame", plate.platename.."Debuff"..index.."Cooldown", plate.debuffs[index])
plate.debuffs[index].cd:SetAllPoints(plate.debuffs[index])
plate.debuffs[index].cd:SetFrameLevel(6)
plate.debuffs[index].cd:SetScript("OnUpdate", CooldownFrame_OnUpdateModel)
plate.debuffs[index].cd.AdvanceTime = DoNothing
plate.debuffs[index].cd.SetSequence = DoNothing
plate.debuffs[index].cd.SetSequenceTime = DoNothing
else
-- Use CooldownFrameTemplate for animation
plate.debuffs[index].cd = CreateFrame("Model", plate.platename.."Debuff"..index.."Cooldown", plate.debuffs[index], "CooldownFrameTemplate")
plate.debuffs[index].cd:SetAllPoints(plate.debuffs[index])
plate.debuffs[index].cd:SetFrameLevel(6)
end
-- Set initial config flags (will be cached per-cooldown later)
plate.debuffs[index].cd.pfCooldownStyleAnimation = cfg.debuffanim
plate.debuffs[index].cd.pfCooldownStyleText = cfg.debufftext
plate.debuffs[index].cd.pfCooldownType = "ALL"
end
local function UpdateDebuffConfig(nameplate, i)
if not nameplate.debuffs[i] then return end
-- update debuff positions
local width = tonumber(C.nameplates.width)
local debuffsize = tonumber(C.nameplates.debuffsize)
local debuffoffset = tonumber(C.nameplates.debuffoffset)
local limit = floor(width / debuffsize)
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_style = C.nameplates.name.fontstyle
local aligna, alignb, offs, space
if C.nameplates.debuffs["position"] == "BOTTOM" then
aligna, alignb, offs, space = "TOPLEFT", "BOTTOMLEFT", -debuffoffset, -1
else
aligna, alignb, offs, space = "BOTTOMLEFT", "TOPLEFT", debuffoffset, 1
end
nameplate.debuffs[i].stacks:SetFont(font, font_size, font_style)
nameplate.debuffs[i]:ClearAllPoints()
if i == 1 then
nameplate.debuffs[i]:SetPoint(aligna, nameplate.health, alignb, 0, offs)
elseif i <= limit then
nameplate.debuffs[i]:SetPoint("LEFT", nameplate.debuffs[i-1], "RIGHT", 1, 0)
elseif i > limit and limit > 0 then
nameplate.debuffs[i]:SetPoint(aligna, nameplate.debuffs[i-limit], alignb, 0, space)
end
nameplate.debuffs[i]:SetSize(debuffsize, debuffsize)
-- Update cooldown display settings
if nameplate.debuffs[i].cd then
local cooldown_text = tonumber(C.nameplates.debufftext) or 1
local cooldown_anim = tonumber(C.nameplates.debuffanim) or 0
nameplate.debuffs[i].cd.pfCooldownStyleText = cooldown_text
nameplate.debuffs[i].cd.pfCooldownStyleAnimation = cooldown_anim
end
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 / 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")
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)
if nameplates.mouselook then
nameplates.mouselook:SetScript("OnUpdate", nil)
end
-- 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.
for plate in pairs(registry) do
if plate.nameplate then
plate.nameplate:UnregisterAllEvents()
plate.nameplate:SetScript("OnEvent", nil)
end
end
return
elseif event == "PLAYER_GUILD_UPDATE" and arg1 == 'player' then
myGuild = GetGuildInfo("player")
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
elseif event == "RAID_ROSTER_UPDATE" or event == "PARTY_MEMBERS_CHANGED" then
RebuildRaidGuidCache()
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
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
-- 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", arg1)
plate.nameplate:RegisterUnitEvent("UNIT_FLAGS", 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
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
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 == "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
end
else
this.eventcache = true
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 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
end
end
for plate in pairs(visiblePlates) do
if plate:IsVisible() then
nameplates.OnUpdate(plate, frameState)
end
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)
nameplates.OnCreate = function(frame)
local parent = frame or this
platecount = platecount + 1
local platename = "pfNamePlate" .. platecount
-- create pfUI nameplate overlay
local nameplate = CreateFrame("Button", platename, parent)
nameplate.platename = platename
nameplate:EnableMouse(0)
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" 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_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" }
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)
else
DisableObject(object)
end
end
nameplate.original.healthbar:HookScript("OnValueChanged", nameplates.OnValueChanged)
-- adjust sizes and scaling of the nameplate
nameplate:SetScale(UIParent:GetScale())
nameplate.health = CreateFrame("StatusBar", nil, nameplate)
nameplate.health:SetFrameLevel(4) -- keep above glow
nameplate.health.text = nameplate.health:CreateFontString(nil, "OVERLAY", "GameFontNormal")
nameplate.health.text:SetAllPoints()
nameplate.health.text:SetTextColor(1,1,1,1)
nameplate.name = nameplate:CreateFontString(nil, "OVERLAY")
nameplate.name:SetPoint("TOP", nameplate, "TOP", 0, 0)
nameplate.glow = nameplate:CreateTexture(nil, "BACKGROUND")
nameplate.glow:SetPoint("CENTER", nameplate.health, "CENTER", 0, 0)
nameplate.glow:SetTexture(pfUI.media["img:dot"])
nameplate.glow:Hide()
nameplate.guild = nameplate:CreateFontString(nil, "OVERLAY")
nameplate.guild:SetPoint("BOTTOM", nameplate.health, "BOTTOM", 0, 0)
nameplate.level = nameplate:CreateFontString(nil, "OVERLAY")
nameplate.level:SetPoint("RIGHT", nameplate.health, "LEFT", -3, 0)
-- Create a dedicated high-level frame for the raid icon so it renders
-- ABOVE nameplate.health (FrameLevel 4) and stays visible even when
-- nameplates are toggled off (the Blizzard parent plate still exists).
nameplate.raidiconframe = CreateFrame("Frame", nil, nameplate)
nameplate.raidiconframe:SetFrameLevel(10)
nameplate.raidiconframe:SetAllPoints(nameplate)
nameplate.raidicon:SetParent(nameplate.raidiconframe)
nameplate.raidicon:SetDrawLayer("OVERLAY", 7)
if C.unitframes.blizzard_raidicons ~= "1" then
nameplate.raidicon:SetTexture(pfUI.media["img:raidicons"])
end
nameplate.totem = CreateFrame("Frame", nil, nameplate)
nameplate.totem:SetPoint("CENTER", nameplate, "CENTER", 0, 0)
nameplate.totem:SetSize(32, 32)
nameplate.totem.icon = nameplate.totem:CreateTexture(nil, "OVERLAY")
nameplate.totem.icon:SetTexCoord(.078, .92, .079, .937)
nameplate.totem.icon:SetAllPoints()
CreateBackdrop(nameplate.totem)
do -- debuffs
nameplate.debuffs = {}
CreateDebuffIcon(nameplate, 1)
end
do -- combopoints
local combopoints = { }
for i = 1, 5 do
combopoints[i] = CreateFrame("Frame", nil, nameplate)
combopoints[i]:Hide()
combopoints[i]:SetFrameLevel(8)
combopoints[i].tex = combopoints[i]:CreateTexture("OVERLAY")
combopoints[i].tex:SetAllPoints()
if i < 3 then
combopoints[i].tex:SetTexture(1, .3, .3, .75)
elseif i < 4 then
combopoints[i].tex:SetTexture(1, 1, .3, .75)
else
combopoints[i].tex:SetTexture(.3, 1, .3, .75)
end
end
nameplate.combopoints = combopoints
end
do -- castbar
local castbar = CreateFrame("StatusBar", nil, nameplate.health)
castbar:Hide()
castbar:SetScript("OnShow", function()
if C.nameplates.debuffs["position"] == "BOTTOM" then
nameplate.debuffs[1]:SetPoint("TOPLEFT", this, "BOTTOMLEFT", 0, -4)
end
end)
castbar:SetScript("OnHide", function()
if C.nameplates.debuffs["position"] == "BOTTOM" then
nameplate.debuffs[1]:SetPoint("TOPLEFT", this:GetParent(), "BOTTOMLEFT", 0, -4)
end
end)
castbar.text = castbar:CreateFontString("Status", "DIALOG", "GameFontNormal")
castbar.text:SetPoint("RIGHT", castbar, "LEFT", -4, 0)
castbar.text:SetNonSpaceWrap(false)
castbar.text:SetTextColor(1,1,1,.5)
castbar.spell = castbar:CreateFontString("Status", "DIALOG", "GameFontNormal")
castbar.spell:SetPoint("CENTER", castbar, "CENTER")
castbar.spell:SetNonSpaceWrap(false)
castbar.spell:SetTextColor(1,1,1,1)
castbar.icon = CreateFrame("Frame", nil, castbar)
castbar.icon.tex = castbar.icon:CreateTexture(nil, "BORDER")
castbar.icon.tex:SetAllPoints()
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
nameplates.OnConfigChange(parent)
end
nameplates.OnConfigChange = function(frame)
local parent = frame
local nameplate = frame.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_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 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.name:SetFont(font, font_size, font_style)
local nameTextPos = C.nameplates.nametextpos or "CENTER"
local nameAnchor = nameTextPos == "RIGHT" and { "BOTTOMRIGHT", "TOPRIGHT" }
or nameTextPos == "CENTER" and { "BOTTOM", "TOP" }
or { "BOTTOMLEFT", "TOPLEFT" }
nameplate.name:ClearAllPoints()
nameplate.name:SetPoint(nameAnchor[1], nameplate.health, nameAnchor[2], 0, -healthoffset)
nameplate.name:SetJustifyH(nameTextPos)
nameplate.health:SetOrientation(orientation)
-- Bar anchors to the plate directly so the name's JustifyH (configurable
-- below) can shift left/right without dragging the bar with it.
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
CreateBackdrop(nameplate.health, default_border)
nameplate.health.text:SetFont(font, font_size - 2, "OUTLINE")
nameplate.health.text:SetJustifyH(C.nameplates.hptextpos)
nameplate.guild:SetFont(font, font_size, font_style)
nameplate.glow:SetSize(C.nameplates.width + 60, C.nameplates.heighthealth + 30)
nameplate.glow:SetVertexColor(glowr, glowg, glowb, glowa)
nameplate.raidicon:ClearAllPoints()
nameplate.raidicon:SetPoint("BOTTOM", nameplate.health, "TOP", C.nameplates.raidiconoffx, C.nameplates.raidiconoffy)
nameplate.level:SetFont(font, font_size, font_style)
nameplate.raidicon:SetSize(C.nameplates.raidiconsize, C.nameplates.raidiconsize)
for i=1,16 do
UpdateDebuffConfig(nameplate, i)
end
for i=1,5 do
nameplate.combopoints[i]:SetSize(combo_size, combo_size)
nameplate.combopoints[i]:SetPoint("TOPRIGHT", nameplate.health, "BOTTOMRIGHT", -(i-1)*(combo_size+default_border*3), -default_border*3)
CreateBackdrop(nameplate.combopoints[i], default_border)
end
nameplate.castbar:SetPoint("TOPLEFT", nameplate.health, "BOTTOMLEFT", 0, -default_border*3)
nameplate.castbar:SetPoint("TOPRIGHT", nameplate.health, "BOTTOMRIGHT", 0, -default_border*3)
nameplate.castbar:SetHeight(C.nameplates.heightcast)
-- Use the same castbar texture and color as the unit frame castbar (castbar.lua)
local cbtexture = pfUI.media[C.appearance.castbar.texture]
nameplate.castbar:SetStatusBarTexture(cbtexture or hptexture)
local cbr, cbg, cbb, cba = GetStringColor(C.appearance.castbar.castbarcolor)
nameplate.castbar:SetStatusBarColor(cbr, cbg, cbb, cba)
-- reset endTime cache so color/texture refresh takes effect on next cast
nameplate.castbar.lastEndTime = nil
CreateBackdrop(nameplate.castbar, default_border)
nameplate.castbar.text:SetFont(font, font_size, "OUTLINE")
nameplate.castbar.spell:SetFont(font, font_size, "OUTLINE")
nameplate.castbar.icon:SetPoint("BOTTOMLEFT", nameplate.castbar, "BOTTOMRIGHT", default_border*3, 0)
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
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 "??"
-- reset per-unit cache when the plate is reassigned. Gate on GUID *and*
-- name — name alone misses pool reuse between same-named units (e.g. plate
-- held a player "Ironforge Guard" and is now reassigned to the NPC by the
-- same name), which would leak a stale "PLAYER" hint into GetUnitInfo.
-- 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). Must run before the cache fills
-- below -- an unrelated unitstr would poison cache.player/minion with the
-- *other* unit's answer, and the nil-gate would then never recompute it.
if unitstr and UnitName(unitstr) ~= name then unitstr = nil end
-- resolve player vs npc from plate's own unit so libunitscan can't return
-- a player record for an NPC sharing the same name (e.g. Chromie). Stored
-- as true/false/nil so it doubles as the GetUnitInfo hint.
if plate.cache.player == nil and unitstr then
plate.cache.player = UnitIsPlayer(unitstr) and true or false
end
if plate.cache.minion == nil and unitstr then
plate.cache.minion = UnitIsMinion(unitstr) and true or false
end
local class, ulevel, elite, player, guild
if not plate.cache.minion then
class, ulevel, elite, player, guild = GetUnitInfo(name, true, plate.cache.player)
end
if plate.cache.player ~= nil then player = plate.cache.player or nil end
-- Use database 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 red, green, blue = plate.original.healthbar:GetStatusBarColor()
local unittype = GetUnitType(red, green, blue) or "ENEMY_NPC"
local font_size = C.nameplates.use_unitfonts == "1" and C.global.font_unit_size or C.global.font_size
-- ignore players with npc names if plate level is lower than player level
if ulevel and ulevel > (level == "??" and -1 or level) then player = nil end
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
if not class and not plate.cache.minion then plate.wait_for_scan = true end
-- 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
local unit = plate.unit
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 and cfg.debufftimers 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
if not cd.configCached or cd.cachedAnim ~= cfg.debuffanim or cd.cachedText ~= cfg.debufftext then
cd.pfCooldownStyleAnimation = cfg.debuffanim
cd.pfCooldownStyleText = cfg.debufftext
cd:SetAlpha(cfg.debuffanim == 1 and 1 or 0)
cd.cachedAnim = cfg.debuffanim
cd.cachedText = cfg.debufftext
cd.configCached = true
end
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 as primary 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
(state and state.targetGuid and frame:GetAlpha() >= 0.99) 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 name = original.name:GetText()
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 unit was found. Pass the cached player hint so the
-- retry probes the same table OnDataChanged will read on the next pass —
-- otherwise an NPC sharing a name with a known player flips wait_for_scan
-- off here, then OnDataChanged re-sets it, every frame until the mob scan
-- lands.
if nameplate.wait_for_scan and not nameplate.cache.minion and GetUnitInfo(name, true, nameplate.cache.player) then
nameplate.wait_for_scan = nil
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
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.
-- ============================================================================
-- 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
_G.NAMEPLATES_ON = true
ShowNameplates()
else
_G.NAMEPLATES_ON = nil
HideNameplates()
end
-- update visibility (hostile)
if C.nameplates["showfriendly"] == "1" then
_G.FRIENDNAMEPLATES_ON = true
ShowFriendNameplates()
else
_G.FRIENDNAMEPLATES_ON = nil
HideFriendNameplates()
end
end
nameplates:SetGameVariables()
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")
if nowFriendly and (disableHostile or disableFriendly) then
if not inFriendlyZone then
-- Just entered friendly zone or feature just enabled
inFriendlyZone = true
savedHostileState = C.nameplates["showhostile"]
savedFriendlyState = C.nameplates["showfriendly"]
end
-- Apply current settings based on options
if disableHostile then
_G.NAMEPLATES_ON = nil
HideNameplates()
else
-- Restore hostile if option is off but we're in friendly zone
if savedHostileState == "1" then
_G.NAMEPLATES_ON = true
ShowNameplates()
end
end
if disableFriendly then
_G.FRIENDNAMEPLATES_ON = nil
HideFriendNameplates()
else
-- Restore friendly if option is off but we're in friendly zone
if savedFriendlyState == "1" then
_G.FRIENDNAMEPLATES_ON = true
ShowFriendNameplates()
end
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
-- update nameplate visibility
nameplates:SetGameVariables()
-- 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)
end
end
end
local hookOnConfigChange = nameplates.OnConfigChange
nameplates.OnConfigChange = function(self)
hookOnConfigChange(self)
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"]))
end
-- replace clickhandler
if C.nameplates["overlap"] == "1" or C.nameplates["vertical_offset"] ~= "0" then
plate:SetScript("OnClick", function() parent:Click() end)
end
-- enable mouselook on rightbutton down
if C.nameplates["rightclick"] == "1" then
plate:SetScript("OnMouseDown", nameplates.mouselook.OnMouseDown)
else
plate:SetScript("OnMouseDown", nil)
end
end
local hookOnDataChanged = nameplates.OnDataChanged
nameplates.OnDataChanged = function(self, nameplate)
hookOnDataChanged(self, nameplate)
-- 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)
end
end
-- enable mouselook on rightbutton down
nameplates.mouselook = CreateFrame("Frame", nil, UIParent)
nameplates.mouselook.time = nil
nameplates.mouselook.frame = nil
nameplates.mouselook.OnMouseDown = function()
if arg1 and arg1 == "RightButton" then
MouselookStart()
-- start detection of the rightclick emulation
nameplates.mouselook.time = GetTime()
nameplates.mouselook.frame = this
nameplates.mouselook:Show()
end
end
nameplates.mouselook:SetScript("OnUpdate", function()
-- break here if nothing to do
if not this.time or not this.frame then
this:Hide()
return
end
-- if threshold is reached (0.5 second) no click action will follow
if not IsMouselooking() and this.time + tonumber(C.nameplates["clickthreshold"]) < GetTime() then
this:Hide()
return
end
-- run a usual nameplate rightclick action
if not IsMouselooking() then
this.frame:Click("LeftButton")
if UnitCanAttack("player", "target") and not nameplates.combat.inCombat then AttackTarget() end
this:Hide()
return
end
end)
pfUI.nameplates = nameplates
end)