From 2a301fdbdcf9395d78b60b77344937556bfe7349 Mon Sep 17 00:00:00 2001 From: Relationship <139162359+Relationship-OctoWoW@users.noreply.github.com> Date: Fri, 3 Jul 2026 07:40:09 +0100 Subject: [PATCH] Delete ThreatPlates_Nameplates.lua --- ThreatPlates_Nameplates.lua | 1387 ----------------------------------- 1 file changed, 1387 deletions(-) delete mode 100644 ThreatPlates_Nameplates.lua diff --git a/ThreatPlates_Nameplates.lua b/ThreatPlates_Nameplates.lua deleted file mode 100644 index 242b520..0000000 --- a/ThreatPlates_Nameplates.lua +++ /dev/null @@ -1,1387 +0,0 @@ ----------------------------------------------------------------------- --- 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