Files
RelationshipsThreatPlates/RelationshipsThreatPlates_Nameplates.lua
T
2026-07-10 09:44:50 +01:00

1416 lines
55 KiB
Lua

----------------------------------------------------------------------
-- RelationshipsThreatPlates_Nameplates.lua
-- Nameplate scanning, hooking, and threat bar creation
-- Creates Kui Nameplates-style threat bars above health bars
-- Standalone for WoW 1.12 (Vanilla / Turtle WoW / Octo WoW)
----------------------------------------------------------------------
local addon = RelationshipsThreatPlates
addon.Nameplates = {}
-- Plate registry: nameplate frame -> our data
addon.Nameplates.plates = {}
-- Known nameplates cache (to avoid re-scanning)
local knownPlates = {}
-- Solid texture path for all bar visuals
local SOLID_TEXTURE = "Interface\\Buttons\\WHITE8x8"
-- Font path
local FONT_PATH = "Fonts\\FRIZQT__.TTF"
-- ==================================================================
-- Helpers for same-name mob handling
-- ==================================================================
local function GetPlateNameText(data, nameplate)
local name = nil
-- Try the stored FontString reference first
if data and data.nameFontString then
local ok, txt = pcall(data.nameFontString.GetText, data.nameFontString)
if ok and txt and strlen(txt) > 0 then
name = txt
end
end
-- Fallback: scan regions for FontString
if (not name or strlen(name) == 0) and nameplate then
local regions = { nameplate:GetRegions() }
local numRegions = table.getn(regions)
for j = 1, numRegions do
local region = regions[j]
if region then
local ok, objType = pcall(region.GetObjectType, region)
if ok and objType == "FontString" then
local ok2, txt = pcall(region.GetText, region)
if ok2 and txt and strlen(txt) > 0 and strlen(txt) < 60 then
name = txt
break
end
end
end
end
end
return name
end
local function GetPlateHealthPercent(data)
if not data or not data.healthBar then return nil end
local okMinMax, minVal, maxVal = pcall(data.healthBar.GetMinMaxValues, data.healthBar)
local okValue, value = pcall(data.healthBar.GetValue, data.healthBar)
if not okMinMax or not okValue then return nil end
if not maxVal or maxVal <= minVal then return nil end
return ((value - minVal) / (maxVal - minVal)) * 100
end
local function GetUnitHealthPercent(unit)
if not unit or not UnitExists(unit) then return nil end
local okCur, cur = pcall(UnitHealth, unit)
local okMax, maxVal = pcall(UnitHealthMax, unit)
if not okCur or not okMax then return nil end
if not maxVal or maxVal <= 0 then return nil end
return (cur / maxVal) * 100
end
local function IsSameUnitToken(unitA, unitB)
if not unitA or not unitB then return false end
if unitA == unitB then return true end
if UnitIsUnit then
local ok, same = pcall(UnitIsUnit, unitA, unitB)
if ok and same then
return true
end
end
return false
end
-- ==================================================================
-- Health-change based combat detection for nameplates.
--
-- In Vanilla 1.12, non-targeted mobs often have NO unit token.
-- The only way to know they're in combat is by observing their
-- nameplate health bar decreasing. If a mob's health is going
-- down, it's in combat. If it's stable at 100%, it's idle.
--
-- This is the KEY technique that makes multi-mob threat display
-- work without unit tokens, while still filtering idle same-name
-- mobs (whose health stays at 100%).
-- ==================================================================
local function IsPlateInCombat(data)
if not data then return false end
-- Method 1: If we have a unit token, use the API directly
if data.unit and UnitExists(data.unit) then
local ok, inCombat = pcall(UnitAffectingCombat, data.unit)
if ok then
return inCombat
end
end
-- Method 2: Health-change detection.
-- If the plate's health bar has decreased since last check,
-- the mob is definitely in combat (taking damage).
-- We track previousHealth for comparison.
local currentHealth = GetPlateHealthPercent(data)
if currentHealth == nil then
-- Can't read health bar — assume not in combat
return false
end
-- If health is significantly below 100%, the mob has been damaged
-- and is almost certainly in combat.
if currentHealth < 98 then
return true
end
-- If health decreased since last check, the mob is taking damage
-- right now — definitely in combat.
if data.previousHealth and currentHealth < data.previousHealth - 0.5 then
return true
end
-- Health is near 100% and stable — likely idle.
-- Exception: a freshly body-pulled mob might be at full health
-- briefly. We handle this with a grace period in UpdateAllPlates.
return false
end
function addon.Nameplates:GetCandidateUnitsByName(name)
local candidates = {}
if not name then return candidates end
local unitTokens = { "target", "mouseover", "targettarget", "pet" }
for i = 1, 4 do
unitTokens[table.getn(unitTokens) + 1] = "party" .. i .. "target"
unitTokens[table.getn(unitTokens) + 1] = "party" .. i .. "targettarget"
end
for i = 1, 40 do
unitTokens[table.getn(unitTokens) + 1] = "raid" .. i .. "target"
unitTokens[table.getn(unitTokens) + 1] = "raid" .. i .. "targettarget"
end
for i = 1, table.getn(unitTokens) do
local unit = unitTokens[i]
local ok, exists = pcall(UnitExists, unit)
if ok and exists then
local ok2, uname = pcall(UnitName, unit)
if ok2 and uname and uname == name then
local duplicate = false
for j = 1, table.getn(candidates) do
if IsSameUnitToken(candidates[j].unit, unit) then
duplicate = true
break
end
end
if not duplicate then
local okCombat, inCombat = pcall(UnitAffectingCombat, unit)
local okAttack, canAttack = pcall(UnitCanAttack, "player", unit)
candidates[table.getn(candidates) + 1] = {
unit = unit,
healthPct = GetUnitHealthPercent(unit),
inCombat = okCombat and inCombat or false,
canAttack = okAttack and canAttack or nil,
}
end
end
end
end
return candidates
end
local function ScoreCandidateForPlate(data, candidate)
if not data or not candidate then return -999999 end
local score = 0
local platePct = GetPlateHealthPercent(data)
-- MANDATORY: If candidate is NOT in combat, heavily penalize.
-- This prevents idle same-name mobs from getting assigned
-- combat-unit tokens and showing incorrect threat bars.
if not candidate.inCombat then
score = score - 5000
end
-- Check if the plate's health bar is at 100% (likely idle mob)
-- A mob at full health that hasn't been damaged is very likely idle.
local plateLooksIdle = false
if platePct and platePct >= 99.5 then
plateLooksIdle = true
end
-- If plate looks idle but candidate is in combat, this is a
-- mismatch — the combat unit is probably a DIFFERENT mob with
-- the same name that has been damaged.
if plateLooksIdle and candidate.inCombat then
score = score - 3000
end
-- If plate looks damaged and candidate is in combat, great match
if not plateLooksIdle and candidate.inCombat then
score = score + 500
end
-- If plate looks idle and candidate is also idle, they might match
-- (but we'll still hide the bar later since idle = no threat)
if plateLooksIdle and not candidate.inCombat then
score = score + 50
end
-- Previous unit continuity bonus
if data._previousUnit and IsSameUnitToken(data._previousUnit, candidate.unit) then
score = score + 1000
end
-- Health percentage matching — the STRONGEST discriminator for
-- same-name mobs. If the plate shows 72% health and the unit
-- token reports 72% health, they're almost certainly the same mob.
if platePct and candidate.healthPct then
local diff = math.abs(platePct - candidate.healthPct)
score = score + math.max(0, 500 - (diff * 15))
end
-- CanAttack check
if candidate.canAttack == false then
score = score - 1000
end
return score
end
function addon.Nameplates:ResolveVisiblePlateUnits()
local platesByName = {}
for nameplate, data in pairs(self.plates) do
data._previousUnit = data.unit
data.unit = nil
data.guid = nil
if nameplate:IsVisible() then
local name = GetPlateNameText(data, nameplate)
if name and strlen(name) > 0 then
data.plateName = name
if not platesByName[name] then
platesByName[name] = {}
end
platesByName[name][table.getn(platesByName[name]) + 1] = data
else
data.plateName = nil
end
else
data.plateName = nil
end
end
for name, plates in pairs(platesByName) do
local candidates = self:GetCandidateUnitsByName(name)
local plateCount = table.getn(plates)
local candidateCount = table.getn(candidates)
if plateCount == 1 and candidateCount == 1 then
local data = plates[1]
data.unit = candidates[1].unit
data.guid = addon:GetUnitID(candidates[1].unit)
elseif candidateCount > 0 then
local usedCandidates = {}
for assignStep = 1, plateCount do
local bestPlateIndex = nil
local bestCandidateIndex = nil
local bestScore = -999999
for plateIndex = 1, plateCount do
local data = plates[plateIndex]
if not data.unit then
for candidateIndex = 1, candidateCount do
if not usedCandidates[candidateIndex] then
local score = ScoreCandidateForPlate(data, candidates[candidateIndex])
if score > bestScore then
bestScore = score
bestPlateIndex = plateIndex
bestCandidateIndex = candidateIndex
end
end
end
end
end
if bestPlateIndex and bestCandidateIndex then
local data = plates[bestPlateIndex]
local candidate = candidates[bestCandidateIndex]
data.unit = candidate.unit
data.guid = addon:GetUnitID(candidate.unit)
usedCandidates[bestCandidateIndex] = true
else
break
end
end
end
end
end
-- ==================================================================
-- Check if a WoW API function exists and is callable
-- In Vanilla 1.12, many APIs like UnitThreatSituation do not exist
-- ==================================================================
local function APICheck(func)
if func then
local ok, _ = pcall(func)
return ok
end
return false
end
-- ==================================================================
-- Initialize the nameplate module
-- ==================================================================
function addon.Nameplates:Initialize()
-- Set up the periodic nameplate scanner
self.scanner = CreateFrame("Frame", "RelationshipsThreatPlatesScanner")
self.scanner.elapsed = 0
self.scanner:SetScript("OnUpdate", function()
this.elapsed = this.elapsed + arg1
if this.elapsed >= 0.5 then
this.elapsed = 0
addon.Nameplates:ScanForNameplates()
end
end)
-- Set up the periodic threat updater
self.updater = CreateFrame("Frame", "RelationshipsThreatPlatesUpdater")
self.updater.elapsed = 0
self.updater:SetScript("OnUpdate", function()
this.elapsed = this.elapsed + arg1
if this.elapsed >= 0.3 then
this.elapsed = 0
addon.Nameplates:UpdateAllPlates()
end
end)
-- Initial scan
self:ScanForNameplates()
end
-- ==================================================================
-- Scan for nameplates using the vanilla 1.12 WorldFrame method
--
-- In vanilla 1.12, nameplates are direct children of WorldFrame.
-- A nameplate frame has this structure:
-- - It is a direct child of WorldFrame
-- - It contains a child Frame that has a StatusBar (the health bar)
-- - The health bar's children include textures (the bar fill + bg)
-- - The nameplate has FontString regions for the name text
-- - Nameplates have characteristic dimensions (~110 wide, ~40 tall)
--
-- We detect them by looking for frames with a StatusBar child
-- that also have FontString regions.
-- ==================================================================
function addon.Nameplates:ScanForNameplates()
local numChildren = WorldFrame:GetNumChildren()
-- Cache children table to avoid calling GetChildren repeatedly
local children = { WorldFrame:GetChildren() }
for i = 1, numChildren do
local nameplate = children[i]
if nameplate and not knownPlates[nameplate] then
-- Only mark a plate as "known" once we have actually
-- attached a threat bar to it. Vanilla recycles nameplate
-- frames from a pool: a currently-invisible plate frame
-- can (and usually will) reappear later for another mob.
-- If we flagged it known while invisible, we would never
-- attach a bar when it became visible again — that is the
-- root cause of "plates stop appearing after changing a
-- setting" (RecreateAll clears knownPlates, then this
-- scan runs while some pooled plates are hidden).
if nameplate:IsVisible() then
local healthBar, nameText = self:IdentifyNameplate(nameplate)
if healthBar then
knownPlates[nameplate] = true
self:AttachThreatBar(nameplate, healthBar, nameText)
end
end
-- Invisible plates are intentionally left un-flagged so
-- the periodic scan will re-check them once they show.
end
end
end
-- ==================================================================
-- Identify if a frame is a vanilla nameplate
-- Returns: healthBar (StatusBar), nameText (FontString) or nil
-- ==================================================================
function addon.Nameplates:IdentifyNameplate(frame)
if not frame then return nil end
if not frame:IsVisible() then return nil end
-- Nameplates are not too large and not too small
local w = frame:GetWidth()
local h = frame:GetHeight()
if not w or not h then return nil end
if w < 50 or w > 200 then return nil end
if h < 10 or h > 100 then return nil end
local healthBar = nil
local nameText = nil
-- Scan children for StatusBar (health bar)
-- In vanilla, the health bar is usually the first child or
-- is nested inside a child frame
local kids = { frame:GetChildren() }
local numKids = table.getn(kids)
for j = 1, numKids do
local child = kids[j]
if child then
-- Check if this child IS a StatusBar
-- In vanilla 1.12, GetObjectType works on all frame types
local ok, objType = pcall(child.GetObjectType, child)
if ok and objType == "StatusBar" then
healthBar = child
else
-- The StatusBar might be nested one level deeper
-- (some nameplate implementations wrap the bar)
local grandKids = { child:GetChildren() }
local numGrandKids = table.getn(grandKids)
for k = 1, numGrandKids do
local grandChild = grandKids[k]
if grandChild then
local ok2, objType2 = pcall(grandChild.GetObjectType, grandChild)
if ok2 and objType2 == "StatusBar" then
healthBar = grandChild
break
end
end
end
end
if healthBar then break end
end
end
-- Scan regions for FontString (name text)
local regions = { frame:GetRegions() }
local numRegions = table.getn(regions)
for j = 1, numRegions do
local region = regions[j]
if region then
local ok, objType = pcall(region.GetObjectType, region)
if ok and objType == "FontString" then
-- This is a FontString - store as name text candidate
if not nameText then
nameText = region
end
end
end
end
-- Must have at least a health bar to be a valid nameplate
if healthBar then
return healthBar, nameText
end
return nil
end
-- ==================================================================
-- Create and attach a Kui-style threat bar to a nameplate
-- ==================================================================
function addon.Nameplates:AttachThreatBar(nameplate, healthBar, nameFontString)
if self.plates[nameplate] then return end
if not RelationshipsThreatPlatesDB then return end
local db = RelationshipsThreatPlatesDB
-- ==============================================================
-- Create the threat bar container frame
-- ==============================================================
local container = CreateFrame("Frame", nil, nameplate)
container:SetFrameLevel(nameplate:GetFrameLevel() + 10)
local barHeight = db.barHeight or 8
local offsetY = db.barOffsetY or 1
local offsetX = db.barOffsetX or -17.8
local widthScale = db.barWidthScale or 0.8
-- Position: anchored ABOVE the health bar, centered on it
-- widthScale controls how wide the bar is relative to the health bar
-- (1.0 = same width, 0.5 = half width, etc.)
local gap = 2 + offsetY -- minimum 2px gap, plus user offset
local healthWidth = healthBar:GetWidth() or 100
local barWidth = math.floor(healthWidth * widthScale + 0.5)
-- Minimum width so the bar is always visible
if barWidth < 20 then barWidth = 20 end
container:ClearAllPoints()
container:SetPoint("BOTTOM", healthBar, "TOP", offsetX, gap)
container:SetWidth(barWidth)
container:SetHeight(barHeight + 2)
-- ==============================================================
-- Dark background (Kui-style)
-- ==============================================================
local bg = container:CreateTexture(nil, "BACKGROUND")
bg:SetTexture(SOLID_TEXTURE)
bg:SetVertexColor(0.06, 0.06, 0.06, 0.9)
bg:SetAllPoints(container)
container.bg = bg
-- ==============================================================
-- Shadow border (Kui-style, when enabled)
-- ==============================================================
if db.glowEnabled and db.glowAsShadow then
local glow = container:CreateTexture(nil, "BACKGROUND")
glow:SetTexture(SOLID_TEXTURE)
glow:SetVertexColor(0, 0, 0, 0.2)
local gs = db.glowSize or 3
glow:SetPoint("TOPLEFT", container, -gs, gs)
glow:SetPoint("BOTTOMRIGHT", container, gs, -gs)
container.glow = glow
end
-- ==============================================================
-- Threat status bar (inset 1px from container edges)
-- ==============================================================
local bar = CreateFrame("StatusBar", nil, container)
bar:SetFrameLevel(container:GetFrameLevel() + 1)
bar:SetStatusBarTexture(SOLID_TEXTURE)
bar:SetMinMaxValues(0, 100)
bar:ClearAllPoints()
bar:SetPoint("TOPLEFT", container, "TOPLEFT", 1, -1)
bar:SetPoint("BOTTOMRIGHT", container, "BOTTOMRIGHT", -1, 1)
bar:SetValue(0)
bar:SetStatusBarColor(0.33, 0.80, 0.33, db.barAlpha or 1)
container.bar = bar
-- ==============================================================
-- Fill background texture (dim colour behind the bar)
-- ==============================================================
if db.showFillBG then
local fill = bar:CreateTexture(nil, "BACKGROUND")
fill:SetTexture(SOLID_TEXTURE)
fill:SetAllPoints(bar)
fill:SetVertexColor(0.33, 0.80, 0.33)
fill:SetAlpha(db.fillAlpha or 0.2)
container.fill = fill
end
-- ==============================================================
-- Spark texture (bright leading edge)
-- ==============================================================
if db.showSpark then
local spark = bar:CreateTexture(nil, "ARTWORK")
spark:SetTexture(SOLID_TEXTURE)
spark:SetWidth(2)
spark:SetHeight(barHeight)
spark:SetVertexColor(1, 1, 1, 0.8)
spark:Hide()
container.spark = spark
end
-- ==============================================================
-- Threat text (outline font, centered in bar)
-- ==============================================================
if db.showText then
local text = bar:CreateFontString(nil, "OVERLAY")
local fSize = db.fontSize or 9
if db.fontOutline then
text:SetFont(FONT_PATH, fSize, "OUTLINE")
else
text:SetFont(FONT_PATH, fSize)
end
text:SetShadowColor(0, 0, 0, 1)
text:SetShadowOffset(1, -1)
text:SetPoint("CENTER", bar, "CENTER", 0, 0)
text:SetTextColor(1, 1, 1, 1)
text:SetJustifyH("CENTER")
text:SetJustifyV("MIDDLE")
text:SetText("")
container.text = text
end
-- ==============================================================
-- Threat glow (red glow for high threat)
-- ==============================================================
if db.glowEnabled then
local threatGlow = container:CreateTexture(nil, "BACKGROUND")
threatGlow:SetTexture(SOLID_TEXTURE)
local gs = db.glowSize or 3
threatGlow:SetPoint("TOPLEFT", container, -gs, gs)
threatGlow:SetPoint("BOTTOMRIGHT", container, gs, -gs)
threatGlow:SetVertexColor(0.9, 0, 0, 0)
threatGlow:Hide()
container.threatGlow = threatGlow
end
-- ==============================================================
-- Store the plate data
-- ==============================================================
local plateData = {
container = container,
bar = bar,
fill = container.fill,
spark = container.spark,
text = container.text,
bg = bg,
glow = container.glow,
threatGlow = container.threatGlow,
healthBar = healthBar,
nameFontString = nameFontString,
nameplate = nameplate,
unit = nil,
guid = nil,
lastPct = 0,
plateName = nil,
isUniqueName = true,
lastThreatTime = nil,
-- Health tracking for combat detection without unit tokens.
-- These are updated every cycle in UpdateAllPlates.
previousHealth = nil, -- health % from previous cycle
combatDetectedAt = nil, -- timestamp when combat was first detected
}
self.plates[nameplate] = plateData
-- Show or hide based on visibility
if nameplate:IsVisible() then
self:OnNameplateShow(nameplate)
else
container:Hide()
end
end
-- ==================================================================
-- Nameplate show handler
-- ==================================================================
function addon.Nameplates:OnNameplateShow(nameplate)
local data = self.plates[nameplate]
if not data then return end
self:ResolveNameplateName(nameplate)
if RelationshipsThreatPlatesDB and RelationshipsThreatPlatesDB.enabled then
data.container:Show()
else
data.container:Hide()
end
end
-- ==================================================================
-- Read the name text from a nameplate
-- ==================================================================
function addon.Nameplates:ResolveNameplateName(nameplate)
local data = self.plates[nameplate]
if not data then return end
local name = GetPlateNameText(data, nameplate)
if name and strlen(name) > 0 then
data.plateName = name
-- Important for same-name mobs: only bind immediately if we can
-- resolve EXACTLY one visible unit token for this name. If there
-- are multiple candidates, defer assignment to ResolveVisiblePlateUnits.
local candidates = self:GetCandidateUnitsByName(name)
if table.getn(candidates) == 1 then
data.unit = candidates[1].unit
data.guid = addon:GetUnitID(candidates[1].unit)
else
data.unit = nil
data.guid = nil
end
else
data.plateName = nil
data.unit = nil
data.guid = nil
end
end
-- ==================================================================
-- Find a unit token by its name
-- ==================================================================
function addon.Nameplates:FindUnitByName(name)
if not name then return nil end
-- Direct unit tokens (highest priority - these have full API access)
local quickUnits = { "target", "mouseover", "targettarget", "pet" }
for i = 1, table.getn(quickUnits) do
local unit = quickUnits[i]
local ok, exists = pcall(UnitExists, unit)
if ok and exists then
local ok2, uname = pcall(UnitName, unit)
if ok2 and uname and uname == name then
return unit
end
end
end
-- Party members' targets (gives us API access to mobs party members are targeting)
for i = 1, 4 do
local unit = "party" .. i .. "target"
local ok, exists = pcall(UnitExists, unit)
if ok and exists then
local ok2, uname = pcall(UnitName, unit)
if ok2 and uname and uname == name then
return unit
end
end
end
-- Party members' target-of-target (lets us see who a mob is targeting)
-- This is crucial for heuristic threat estimation in parties
for i = 1, 4 do
local unit = "party" .. i .. "targettarget"
local ok, exists = pcall(UnitExists, unit)
if ok and exists then
local ok2, uname = pcall(UnitName, unit)
if ok2 and uname and uname == name then
return unit
end
end
end
-- Raid member targets
for i = 1, 40 do
local unit = "raid" .. i .. "target"
local ok, exists = pcall(UnitExists, unit)
if ok and exists then
local ok2, uname = pcall(UnitName, unit)
if ok2 and uname and uname == name then
return unit
end
end
end
-- Raid member target-of-target
for i = 1, 40 do
local unit = "raid" .. i .. "targettarget"
local ok, exists = pcall(UnitExists, unit)
if ok and exists then
local ok2, uname = pcall(UnitName, unit)
if ok2 and uname and uname == name then
return unit
end
end
end
return nil
end
-- ==================================================================
-- Update threat display for a specific unit
-- ==================================================================
function addon.Nameplates:UpdateThreatForUnit(unit, pct, situation)
if not RelationshipsThreatPlatesDB or not RelationshipsThreatPlatesDB.enabled then return end
if not unit or not UnitExists(unit) then return end
local realGuid = addon:SafeGUID(unit)
for nameplate, data in pairs(self.plates) do
local isMatch = false
-- In Vanilla, unit-name fallback is NOT unique for same-name mobs.
-- Therefore, match by exact unit token whenever possible.
if data.unit and UnitExists(data.unit) then
if IsSameUnitToken(data.unit, unit) then
isMatch = true
end
elseif realGuid and data.guid == realGuid then
-- GUID-based fallback is only safe on clients/servers that
-- actually expose UnitGUID.
isMatch = true
end
if isMatch then
self:ApplyThreatToPlate(data, pct, situation)
end
end
end
-- ==================================================================
-- Apply threat values to a specific plate
-- ==================================================================
function addon.Nameplates:ApplyThreatToPlate(data, pct, situation)
if not data or not data.bar then return end
if not RelationshipsThreatPlatesDB or not RelationshipsThreatPlatesDB.enabled then
data.container:Hide()
return
end
-- Check visibility filters
if not self:ShouldShowOnPlate(data, situation) then
data.container:Hide()
return
end
-- FINAL SAFETY NET: If we have a unit token, verify the mob is
-- actually in combat. This catches any edge cases where the threat
-- system returned a non-zero percentage for an idle mob.
-- Without this check, healing threat split could still cause bars
-- to appear on nearby idle mobs that share a unit token.
if data.unit and UnitExists(data.unit) then
local ok, inCombat = pcall(UnitAffectingCombat, data.unit)
if ok and not inCombat then
data.container:Hide()
return
end
end
-- ADDITIONAL SAFETY NET for SAME-NAME mobs only:
-- If the nameplate health bar is at or near 100%, this is very
-- likely an idle mob that hasn't been attacked. A mob in active
-- combat with threat data should show damage on its health bar.
--
-- IMPORTANT: This check only applies when there are multiple
-- plates sharing the same name (same-name mobs). For UNIQUE-name
-- mobs (only one plate with this name visible), we rely on
-- IsPlateInCombat() which uses health-change detection, so this
-- full-health safety net is redundant and counterproductive.
--
-- The isUniqueName flag is set by UpdateAllPlates before calling
-- this function. If not set, default to false (apply safety net).
if not data.isUniqueName then
local plateHealthPct = GetPlateHealthPercent(data)
if plateHealthPct and plateHealthPct >= 99.5 then
-- Plate is at full/near-full health. Check if we have a
-- unit token in combat. If the unit token is valid and in
-- combat, it might be a freshly pulled mob — allow it.
-- Also allow if IsPlateInCombat detected a health decrease.
local unitInCombat = false
if data.unit and UnitExists(data.unit) then
local ok, inCombat = pcall(UnitAffectingCombat, data.unit)
if ok and inCombat then
unitInCombat = true
end
end
-- Check health-change combat detection
local plateCombatDetected = IsPlateInCombat(data)
if not unitInCombat and not plateCombatDetected then
data.container:Hide()
return
end
-- Unit/plate appears in combat but at full health. This can
-- happen briefly after the first hit. Allow a short grace period.
if not data.lastThreatTime then
data.lastThreatTime = GetTime()
end
if GetTime() - data.lastThreatTime > 0.5 then
-- Showing a bar for 5+ seconds on a full-health
-- mob in combat. Likely a same-name mob mismatch.
data.container:Hide()
return
end
else
-- Mob is damaged, reset the grace period tracker
data.lastThreatTime = GetTime()
end
else
-- Unique-name mob: update the grace tracker but don't use
-- full-health as a reason to hide. ThreatEngine data for a
-- unique name is unambiguous.
data.lastThreatTime = GetTime()
end
-- If no threat percentage, hide the bar.
-- KEY FIX: Removed the old gate that required player to be in combat.
-- Now we only check if there's actual threat data to show. The combat
-- verification is done earlier in the pipeline (IsPlateInCombat,
-- GetThreatByName, etc.).
if pct == 0 then
data.container:Hide()
return
end
data.container:Show()
-- Preserve the RAW threat percent (may exceed 100 when the player
-- is the top threat holder and well ahead of the runner-up). The
-- text label shows this uncapped value so the user can see e.g.
-- "185%" for a tank comfortably holding threat. The bar fill
-- itself is clamped to 0-100 because the statusbar range is fixed.
local rawPct = pct or 0
-- Smooth the percentage (bar fill only — clamp to 0..100)
local oldPct = data.lastPct or 0
local barPct = math.min(100, math.max(0, rawPct))
local smoothPct
-- Snap instantly when jumping to full aggro or dropping to 0
-- (mob switched target). Smooth intermediate transitions moderately.
if barPct == 0 or barPct >= 99.5 or math.abs(barPct - oldPct) > 40 then
smoothPct = barPct
else
smoothPct = oldPct + (barPct - oldPct) * 0.6
end
data.lastPct = smoothPct
smoothPct = math.min(100, math.max(0, smoothPct))
-- Update bar value (0-100 range)
data.bar:SetValue(smoothPct)
-- Update bar colour (smooth gradient) — driven by barPct so colours
-- top out at aggro red even if rawPct exceeds 100.
local r, g, b = addon.Media.GetThreatColour(barPct)
data.bar:SetStatusBarColor(r, g, b, RelationshipsThreatPlatesDB.barAlpha or 1)
-- Update statusbar texture vertex colour
local tex = data.bar:GetStatusBarTexture()
if tex then
tex:SetVertexColor(r, g, b)
end
-- Update fill background
if data.fill then
data.fill:SetVertexColor(r, g, b)
data.fill:SetAlpha(RelationshipsThreatPlatesDB.fillAlpha or 0.2)
end
-- Update spark
if data.spark then
if smoothPct > 0 and smoothPct < 100 then
-- Re-anchor spark to the leading edge of the bar fill
data.spark:ClearAllPoints()
if tex then
data.spark:SetPoint("TOP", tex, "TOPRIGHT", 0, 0)
data.spark:SetPoint("BOTTOM", tex, "BOTTOMRIGHT", 0, 0)
end
-- Brighten spark colour
local br = math.min(1, r + (1 - r) * 0.3)
local bg2 = math.min(1, g + (1 - g) * 0.3)
local bb = math.min(1, b + (1 - b) * 0.3)
data.spark:SetVertexColor(br, bg2, bb, 0.8)
data.spark:Show()
else
data.spark:Hide()
end
end
-- Update text — uses rawPct so values above 100% are shown as-is.
if data.text then
local textStr
if RelationshipsThreatPlatesDB.tankMode and data.plateName then
-- Tank mode: show raw threat lead over the 2nd-highest player
-- (positive = you have that much lead as top-threat holder;
-- negative = you're that much behind the current top).
textStr = addon.Threat:GetTankDisplayForMob(data.plateName)
if not textStr or textStr == "--" then
-- Fall back to % when no absolute values are available.
textStr = math.floor(rawPct + 0.5) .. "%"
end
else
textStr = math.floor(rawPct + 0.5) .. "%"
end
data.text:SetText(textStr)
if rawPct >= 100 then
-- Aggro / over-aggro: bright red text so the top holder
-- stands out even when the bar itself is pegged at full.
data.text:SetTextColor(1, 0.2, 0.2, 1)
elseif rawPct >= 60 then
data.text:SetTextColor(1, 0.9, 0.8, 1)
else
data.text:SetTextColor(1, 1, 1, 1)
end
end
-- Update threat glow (drive by clamped barPct so it caps at max intensity)
if data.threatGlow then
if barPct >= 80 then
local intensity = (barPct - 80) / 20
if intensity > 1 then intensity = 1 end
data.threatGlow:SetVertexColor(0.9, 0, 0, 0.6 * intensity)
data.threatGlow:Show()
else
data.threatGlow:Hide()
end
end
end
-- ==================================================================
-- Should this plate show a threat bar based on settings?
-- KEY CHANGE: Now shows threat bars for mobs attacking any group
-- member, not just the player. This ensures ALL attacking mobs
-- display threat percentages.
-- ==================================================================
function addon.Nameplates:ShouldShowOnPlate(data, situation)
if not RelationshipsThreatPlatesDB then return false end
if not RelationshipsThreatPlatesDB.enabled then return false end
local unit = data.unit
if not unit then
-- No unit token resolved. Check if the mob is attacking
-- a group member via mob target name heuristic.
-- This allows threat bars to appear on nameplates even
-- when we can't resolve a unit token.
if data.plateName then
-- Try to find who this mob is targeting
local foundUnit = self:FindUnitByName(data.plateName)
if foundUnit then
local ok_mt, mt = pcall(UnitName, foundUnit .. "target")
if ok_mt and mt then
-- Mob is targeting someone in our group
if mt == UnitName("player") or addon.Threat:IsPartyMember(mt) then
return RelationshipsThreatPlatesDB.showOnHostile
end
end
end
end
return true
end
if not UnitExists(unit) then return false end
local reaction = UnitReaction(unit, "player")
if not reaction then return true end
if reaction <= 3 then
return RelationshipsThreatPlatesDB.showOnHostile
elseif reaction == 4 then
return RelationshipsThreatPlatesDB.showOnNeutral
else
return RelationshipsThreatPlatesDB.showOnFriendly
end
end
-- ==================================================================
-- Find a combat-verified unit token for a nameplate that has no
-- resolved unit token. This is the same-name mob disambiguator.
--
-- Strategy:
-- 1. Find ALL unit tokens matching the plate's name
-- 2. Filter to only those that are in combat (UnitAffectingCombat)
-- 3. Score remaining candidates by health % match with the plate
-- 4. Return the best match, or nil if no combat unit found
--
-- Returns: unit token string, or nil
-- ==================================================================
function addon.Nameplates:FindCombatUnitForPlate(data)
if not data or not data.plateName then return nil end
if strlen(data.plateName) == 0 then return nil end
-- KEY FIX: Only require that someone in the group is in combat,
-- not necessarily the player. This allows finding combat units
-- for mobs attacking party/raid members.
if not addon.ThreatEngine:IsGroupInCombat() then return nil end
local name = data.plateName
-- Collect ALL unit tokens matching this name
local candidates = self:GetCandidateUnitsByName(name)
if table.getn(candidates) == 0 then return nil end
-- Get plate health percentage for matching
local plateHealthPct = GetPlateHealthPercent(data)
-- Score each candidate: we want a unit that is:
-- 1. In combat (mandatory — idle mobs must never match)
-- 2. Health % close to the plate's health bar
-- 3. Hostile and attackable
local bestUnit = nil
local bestScore = -999999
for i = 1, table.getn(candidates) do
local c = candidates[i]
-- MANDATORY: Unit must be in combat
if c.inCombat then
local score = 0
-- Health % matching (most important discriminator)
if plateHealthPct and c.healthPct then
local diff = math.abs(plateHealthPct - c.healthPct)
-- Close health match = strong signal this is the right mob
score = score + math.max(0, 500 - (diff * 15))
end
-- Hostile/attackable bonus
if c.canAttack then
score = score + 50
end
if score > bestScore then
bestScore = score
bestUnit = c.unit
end
end
end
return bestUnit
end
-- ==================================================================
-- Update all plates (called periodically)
--
-- DESIGN: Shows threat bars for ALL mobs in combat with the player
-- or any group member, not just the targeted mob.
--
-- COMBAT DETECTION STRATEGY:
-- In Vanilla 1.12, non-targeted mobs have NO unit tokens.
-- We use THREE methods to detect if a mob is in combat:
-- 1. Unit token API (UnitAffectingCombat) — if we have a token
-- 2. Health decrease detection — if the plate's health bar is
-- dropping, the mob is taking damage and is in combat
-- 3. ThreatEngine data — if we have threat data for this mob,
-- someone in the group has hit it, so it's in combat
--
-- SAME-NAME MOB HANDLING:
-- - UNIQUE-name mobs (only 1 plate with that name): ThreatEngine
-- data is unambiguous. Can use it directly.
-- - SAME-name mobs (multiple plates share a name): Must verify
-- combat to avoid showing threat on idle same-name mobs.
-- Health-change detection is the KEY discriminator — idle mobs
-- stay at 100% health while combat mobs take damage.
--
-- This approach ensures:
-- - All attacking mobs show threat bars (multi-mob requirement)
-- - Idle same-name mobs don't get false threat bars
-- - Mobs attacking party members show threat even when player
-- isn't in combat
-- ==================================================================
function addon.Nameplates:UpdateAllPlates()
if not RelationshipsThreatPlatesDB or not RelationshipsThreatPlatesDB.enabled then
for nameplate, data in pairs(self.plates) do
data.container:Hide()
end
return
end
local now = GetTime()
local groupInCombat = addon.ThreatEngine:IsGroupInCombat()
-- Step 1: Count how many visible plates share each name.
-- This determines whether a name is unique or ambiguous.
local platesPerName = {}
for nameplate, data in pairs(self.plates) do
if nameplate:IsVisible() and data.plateName then
if not platesPerName[data.plateName] then
platesPerName[data.plateName] = 0
end
platesPerName[data.plateName] = platesPerName[data.plateName] + 1
end
end
-- Step 2: Resolve unit tokens for all visible plates.
-- ResolveVisiblePlateUnits handles same-name mob assignment via
-- health-matching and combat-status scoring.
self:ResolveVisiblePlateUnits()
-- Step 3: Update health tracking for combat detection.
-- Store current health for comparison next cycle.
-- This is done BEFORE the main update loop so IsPlateInCombat
-- can use the previous cycle's health for change detection.
for nameplate, data in pairs(self.plates) do
if nameplate:IsVisible() then
local currentHealth = GetPlateHealthPercent(data)
-- We'll update previousHealth AFTER the main loop,
-- so IsPlateInCombat can compare current vs previous.
data._currentHealth = currentHealth
end
end
-- Step 4: Update threat display for each visible plate.
for nameplate, data in pairs(self.plates) do
if nameplate:IsVisible() then
local isUniqueName = (platesPerName[data.plateName] or 1) <= 1
data.isUniqueName = isUniqueName
local plateInCombat = IsPlateInCombat(data)
local hasThreatData = data.plateName and addon.ThreatEngine:HasDataForTarget(data.plateName)
if data.unit and UnitExists(data.unit) then
-- We have a resolved unit token. Verify it's in combat.
local ok_combat, mobInCombat = pcall(UnitAffectingCombat, data.unit)
if ok_combat and not mobInCombat then
-- Unit is NOT in combat — idle mob. Never show threat.
data.container:Hide()
else
-- Unit is in combat (or can't verify). Get threat.
local pct, situation = addon.Threat:GetThreatPercent(data.unit)
self:ApplyThreatToPlate(data, pct, situation or 0)
end
elseif isUniqueName and data.plateName then
-- UNIQUE-NAME MOB with no unit token:
-- This is the common case for non-targeted attacking mobs.
-- Since only ONE mob on screen has this name, ThreatEngine
-- data is unambiguous. Use health-based combat detection.
if plateInCombat then
-- Mob is in combat (health dropping or below 100%).
-- Record when we first detected combat for grace period.
if not data.combatDetectedAt then
data.combatDetectedAt = now
end
-- Try ThreatEngine data first
local pct, sit = addon.Threat:GetThreatByName(data.plateName)
if pct > 0 then
self:ApplyThreatToPlate(data, pct, sit or 0)
else
-- No ThreatEngine data but mob IS in combat.
-- This happens for body pulls, or when only
-- a party member has hit the mob.
--
-- If a group member is in combat, show a default
-- threat bar. The mob is fighting someone.
if groupInCombat then
self:ApplyThreatToPlate(data, 20, 0) -- was 100,3: showing full aggro for any party-combat mob with no threat data was misleading
else
data.container:Hide()
end
end
elseif hasThreatData and groupInCombat then
-- Plate looks idle (full health) but ThreatEngine has
-- data and group is in combat. This can happen briefly
-- after a mob is first hit (health bar hasn't updated)
-- or for a mob being attacked by someone else.
-- Give a grace period.
if not data.combatDetectedAt then
data.combatDetectedAt = now
end
if now - data.combatDetectedAt < 0.5 then
local pct, sit = addon.Threat:GetThreatByName(data.plateName)
if pct > 0 then
self:ApplyThreatToPlate(data, pct, sit or 0)
else
self:ApplyThreatToPlate(data, 20, 0) -- was 100,3: showing full aggro for any party-combat mob with no threat data was misleading
end
else
-- Grace period expired and health still at 100%.
-- This is likely an idle mob. Hide bar.
data.combatDetectedAt = nil
data.container:Hide()
end
elseif groupInCombat then
-- No health decrease, no ThreatEngine data, but group
-- is in combat. Check if this mob is attacking a group
-- member by looking for a unit token via party targets.
--
-- This catches the case of a body-pulled mob (at 100%
-- health, no damage dealt to it yet, but actively
-- attacking a group member).
local mobAttackingGroup = false
local foundUnit = self:FindUnitByName(data.plateName)
if foundUnit then
local ok_mt, mt = pcall(UnitName, foundUnit .. "target")
if ok_mt and mt then
if mt == UnitName("player") or addon.Threat:IsPartyMember(mt) then
mobAttackingGroup = true
end
end
end
if mobAttackingGroup then
-- Mob is confirmed attacking a group member!
-- Show a high-threat bar.
if not data.combatDetectedAt then
data.combatDetectedAt = now
end
self:ApplyThreatToPlate(data, 20, 0) -- was 100,3: showing full aggro for any party-combat mob with no threat data was misleading
else
-- Can't confirm mob is attacking anyone.
-- It might be idle. Don't show bar.
data.combatDetectedAt = nil
data.container:Hide()
end
else
-- No combat detected, no threat data, group not in combat.
data.combatDetectedAt = nil
data.container:Hide()
end
elseif not isUniqueName and data.plateName then
-- SAME-NAME MOB with no unit token:
-- Multiple plates share this name. Must be careful.
-- Use health-based combat detection as the primary filter.
if plateInCombat then
-- This specific plate's health is dropping — it's in combat.
if not data.combatDetectedAt then
data.combatDetectedAt = now
end
-- Try to find a combat-verified unit with matching health.
local unit = self:FindCombatUnitForPlate(data)
if unit then
data.unit = unit
data.guid = addon:GetUnitID(unit)
local pct, situation = addon.Threat:GetThreatPercent(unit)
self:ApplyThreatToPlate(data, pct, situation or 0)
elseif hasThreatData then
-- No matching unit token but ThreatEngine has data.
-- For same-name mobs this is ambiguous, but the
-- health-drop verification means THIS plate IS in
-- combat, so it's safe to show ThreatEngine data.
local pct, sit = addon.Threat:GetThreatByName(data.plateName)
if pct > 0 then
self:ApplyThreatToPlate(data, pct, sit or 0)
else
-- In combat but no threat data — mob is being
-- attacked by someone we can't track.
if groupInCombat then
self:ApplyThreatToPlate(data, 20, 0) -- was 100,3: showing full aggro for any party-combat mob with no threat data was misleading
else
data.container:Hide()
end
end
else
-- In combat (health dropping) but no ThreatEngine data.
-- Show default high-threat bar.
if groupInCombat then
self:ApplyThreatToPlate(data, 20, 0) -- was 100,3: showing full aggro for any party-combat mob with no threat data was misleading
else
data.container:Hide()
end
end
else
-- Same-name plate at full health and stable — likely idle.
-- But check if a unit token reveals this specific mob
-- is attacking a group member (body pull scenario).
if groupInCombat then
local unit = self:FindCombatUnitForPlate(data)
if unit then
-- Found a combat-verified unit for this plate.
data.unit = unit
data.guid = addon:GetUnitID(unit)
local pct, situation = addon.Threat:GetThreatPercent(unit)
if pct > 0 then
if not data.combatDetectedAt then
data.combatDetectedAt = now
end
self:ApplyThreatToPlate(data, pct, situation or 0)
else
data.combatDetectedAt = nil
data.container:Hide()
end
else
-- No combat unit found. Idle same-name mob.
data.combatDetectedAt = nil
data.container:Hide()
end
else
data.combatDetectedAt = nil
data.container:Hide()
end
end
else
-- No plate name at all — can't determine anything.
data.container:Hide()
end
else
-- Plate not visible
data.combatDetectedAt = nil
data.container:Hide()
end
end
-- Step 5: Update previousHealth for next cycle's change detection.
for nameplate, data in pairs(self.plates) do
data.previousHealth = data._currentHealth
data._currentHealth = nil
end
end
-- ==================================================================
-- Force update all (called on settings change)
-- ==================================================================
function addon.Nameplates:UpdateAll()
for nameplate, data in pairs(self.plates) do
if RelationshipsThreatPlatesDB and RelationshipsThreatPlatesDB.enabled then
data.container:Show()
else
data.container:Hide()
end
end
self:UpdateAllPlates()
end
-- ==================================================================
-- Recreate all bars (called when visual settings change)
-- ==================================================================
function addon.Nameplates:RecreateAll()
for nameplate, data in pairs(self.plates) do
if data.container then
data.container:Hide()
data.container:SetParent(nil)
end
self.plates[nameplate] = nil
end
-- Don't clear knownPlates - we need to re-scan for new nameplates
-- But we do need to re-attach to known ones
knownPlates = {}
self:ScanForNameplates()
end