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