1388 lines
52 KiB
Lua
1388 lines
52 KiB
Lua
----------------------------------------------------------------------
|
|
-- ThreatPlates_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 = ThreatPlates
|
|
|
|
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", "ThreatPlatesScanner")
|
|
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", "ThreatPlatesUpdater")
|
|
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
|
|
-- Quick visibility filter - don't scan invisible frames
|
|
if nameplate:IsVisible() then
|
|
knownPlates[nameplate] = true
|
|
|
|
-- Check if this frame is a nameplate
|
|
local healthBar, nameText = self:IdentifyNameplate(nameplate)
|
|
|
|
if healthBar then
|
|
self:AttachThreatBar(nameplate, healthBar, nameText)
|
|
end
|
|
else
|
|
-- Mark as known even if invisible; we'll handle
|
|
-- visibility in the update loop
|
|
knownPlates[nameplate] = true
|
|
end
|
|
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 ThreatPlatesDB then return end
|
|
|
|
local db = ThreatPlatesDB
|
|
|
|
-- ==============================================================
|
|
-- 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 ThreatPlatesDB and ThreatPlatesDB.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 ThreatPlatesDB or not ThreatPlatesDB.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 ThreatPlatesDB or not ThreatPlatesDB.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()
|
|
|
|
-- Smooth the percentage
|
|
local oldPct = data.lastPct or 0
|
|
local smoothPct
|
|
if pct == 0 then
|
|
smoothPct = 0
|
|
else
|
|
smoothPct = oldPct + (pct - oldPct) * 0.3
|
|
end
|
|
data.lastPct = smoothPct
|
|
|
|
smoothPct = math.min(100, math.max(0, smoothPct))
|
|
pct = math.min(100, math.max(0, pct))
|
|
|
|
-- Update bar value
|
|
data.bar:SetValue(smoothPct)
|
|
|
|
-- Update bar colour (smooth gradient)
|
|
local r, g, b = addon.Media.GetThreatColour(pct)
|
|
data.bar:SetStatusBarColor(r, g, b, ThreatPlatesDB.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(ThreatPlatesDB.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
|
|
if data.text then
|
|
local displayPct = math.floor(pct + 0.5)
|
|
data.text:SetText(displayPct .. "%")
|
|
|
|
if pct >= 90 then
|
|
data.text:SetTextColor(1, 0.2, 0.2, 1)
|
|
elseif pct >= 60 then
|
|
data.text:SetTextColor(1, 0.9, 0.8, 1)
|
|
else
|
|
data.text:SetTextColor(1, 1, 1, 1)
|
|
end
|
|
end
|
|
|
|
-- Update threat glow
|
|
if data.threatGlow then
|
|
if pct >= 80 then
|
|
local intensity = (pct - 80) / 20
|
|
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 ThreatPlatesDB then return false end
|
|
if not ThreatPlatesDB.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 ThreatPlatesDB.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 ThreatPlatesDB.showOnHostile
|
|
elseif reaction == 4 then
|
|
return ThreatPlatesDB.showOnNeutral
|
|
else
|
|
return ThreatPlatesDB.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 ThreatPlatesDB or not ThreatPlatesDB.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, 100, 3)
|
|
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, 100, 3)
|
|
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, 100, 3)
|
|
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, 100, 3)
|
|
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, 100, 3)
|
|
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 ThreatPlatesDB and ThreatPlatesDB.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
|