From 51b54d2cddd5aa4bd73b0e1d485f6d5684c66c69 Mon Sep 17 00:00:00 2001 From: Relationship <139162359+Relationship-OctoWoW@users.noreply.github.com> Date: Fri, 3 Jul 2026 06:57:57 +0100 Subject: [PATCH] Add files via upload --- ThreatPlates_Nameplates.lua | 1387 +++++++++++++++++++++++++++++++++++ 1 file changed, 1387 insertions(+) create mode 100644 ThreatPlates_Nameplates.lua diff --git a/ThreatPlates_Nameplates.lua b/ThreatPlates_Nameplates.lua new file mode 100644 index 0000000..242b520 --- /dev/null +++ b/ThreatPlates_Nameplates.lua @@ -0,0 +1,1387 @@ +---------------------------------------------------------------------- +-- 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