5 Commits

Author SHA1 Message Date
Relationship 28ef2ed7c9 Add files via upload 2026-07-12 14:57:27 +01:00
Relationship d0329d309e Update README.md 2026-07-10 09:47:16 +01:00
Relationship 77154b7b11 Add files via upload 2026-07-10 09:44:50 +01:00
Relationship 16705f72ac Add files via upload 2026-07-08 18:24:21 +01:00
Relationship d0a77fe622 Update RelationshipsThreatPlates.toc 2026-07-04 03:37:43 +01:00
8 changed files with 926 additions and 184 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
<img src="https://cdn.discordapp.com/attachments/1510333697277300856/1522457461569622156/image.png?ex=6a488a80&is=6a473900&hm=0cf7bd7efde50648b2d06345995cdd0a30174335f484aae51ad76026f45bb358&"/> <img src="https://cdn.discordapp.com/attachments/1510333697277300856/1522484471616634943/fff.png?ex=6a51de28&is=6a508ca8&hm=63ff5877df99f9df865b93da3528822af42de0d386126f4ccc465c728b00bed1&"/>
* It's recommended to use the KUI-NAMEPLATES addon as it's designed to work with that. * It's recommended to use the KUI-NAMEPLATES addon as it's designed to work with that.
* You can find it here. * You can find it here.
+2 -2
View File
@@ -1,8 +1,8 @@
## Interface: 11200 ## Interface: 11200
## Title: RelationshipsThreatPlates - OctoWoW ## Title: RelationshipsThreatPlates - OctoWoW
## Notes: OctoWoW-compatible RelationshipsThreatPlates ## Notes: OctoWoW-compatible RelationshipsThreatPlates
## Author: Packaged By Relationship ## Author: Relationship
## Version: 1.0 ## Version: 1.4
## SavedVariables: RelationshipsThreatPlatesDB ## SavedVariables: RelationshipsThreatPlatesDB
## X-Website: https://github.com/Relationship-OctoWoW/RelationshipsThreatPlates ## X-Website: https://github.com/Relationship-OctoWoW/RelationshipsThreatPlates
## X-GitHub: https://github.com/Relationship-OctoWoW/RelationshipsThreatPlates ## X-GitHub: https://github.com/Relationship-OctoWoW/RelationshipsThreatPlates
+17 -2
View File
@@ -12,7 +12,7 @@ addon.MAJOR = 1
addon.MINOR = 1 addon.MINOR = 1
-- Version string -- Version string
TP_VERSION = "2.3.1" TP_VERSION = "2.6.1"
TP_ADDON_NAME = "RelationshipsThreatPlates" TP_ADDON_NAME = "RelationshipsThreatPlates"
-- ================================================================== -- ==================================================================
@@ -21,9 +21,11 @@ TP_ADDON_NAME = "RelationshipsThreatPlates"
local DEFAULT_CONFIG = { local DEFAULT_CONFIG = {
enabled = true, enabled = true,
showText = true, showText = true,
tankMode = false,
targetSnap100 = true,
barHeight = 6, barHeight = 6,
barAlpha = 0.5, barAlpha = 0.5,
barOffsetY = 20, barOffsetY = 24,
barOffsetX = -17.8, barOffsetX = -17.8,
barWidthScale = 0.8, barWidthScale = 0.8,
fontSize = 9, fontSize = 9,
@@ -38,6 +40,19 @@ local DEFAULT_CONFIG = {
glowEnabled = false, glowEnabled = false,
glowSize = 3, glowSize = 3,
glowAsShadow = true, glowAsShadow = true,
-- New in 2.6
combatOnly = false,
hideBelowPct = 0,
warningSound = false,
warningSoundName = "RaidWarning",
warningThreshold = 80,
flashOnAggro = false,
flashSpeed = 4,
textFormat = "percent", -- percent | tank | status | combo
colorPreset = "kui", -- kui | cool | mono | warband
lockGUI = false,
guiPos = { guiPos = {
point = "CENTER", point = "CENTER",
relPoint = "CENTER", relPoint = "CENTER",
+201 -6
View File
@@ -35,7 +35,7 @@ local C = {
-- Layout -- Layout
local GUI_WIDTH = 320 local GUI_WIDTH = 320
local GUI_HEIGHT = 480 local GUI_HEIGHT = 520
local HEADER_H = 30 local HEADER_H = 30
local MARGIN = 14 local MARGIN = 14
local FONT_NORMAL = "Fonts\\FRIZQT__.TTF" local FONT_NORMAL = "Fonts\\FRIZQT__.TTF"
@@ -44,6 +44,34 @@ local FONT_SIZE = 10
-- Widget references for refresh -- Widget references for refresh
local widgets = {} local widgets = {}
-- Only settings that change the visual STRUCTURE of a bar need a full
-- RecreateAll (which tears every bar down and re-attaches on the next
-- scan). Everything else — filters, modes, alpha, colours — just needs
-- a soft UpdateAll, which is far less likely to leave plates without
-- bars while pooled nameplate frames are hidden.
local NEEDS_RECREATE = {
barHeight = true,
barWidthScale= true,
barOffsetY = true,
barOffsetX = true,
glowEnabled = true,
glowAsShadow = true,
glowSize = true,
showSpark = true,
showFillBG = true,
showText = true,
fontOutline = true,
fontSize = true,
}
local function ApplySettingChange(configKey)
if NEEDS_RECREATE[configKey] then
addon.Nameplates:RecreateAll()
else
addon.Nameplates:UpdateAll()
end
end
-- ================================================================== -- ==================================================================
-- Helper: safely set backdrop on a frame -- Helper: safely set backdrop on a frame
-- ================================================================== -- ==================================================================
@@ -302,7 +330,7 @@ function addon.GUI:BuildSettings(parent)
else else
this.checkMark:Hide() this.checkMark:Hide()
end end
addon.Nameplates:RecreateAll() ApplySettingChange(configKey)
end) end)
local label = parent:CreateFontString(nil, "OVERLAY") local label = parent:CreateFontString(nil, "OVERLAY")
@@ -341,7 +369,7 @@ function addon.GUI:BuildSettings(parent)
local slider = CreateFrame("Slider", "RelationshipsThreatPlatesSlider_" .. configKey, parent, "OptionsSliderTemplate") local slider = CreateFrame("Slider", "RelationshipsThreatPlatesSlider_" .. configKey, parent, "OptionsSliderTemplate")
slider:SetWidth(W - 60) slider:SetWidth(W - 60)
slider:SetHeight(16) slider:SetHeight(10)
slider:SetPoint("TOPLEFT", parent, "TOPLEFT", MARGIN, -y) slider:SetPoint("TOPLEFT", parent, "TOPLEFT", MARGIN, -y)
slider:SetOrientation("HORIZONTAL") slider:SetOrientation("HORIZONTAL")
slider:SetMinMaxValues(minVal, maxVal) slider:SetMinMaxValues(minVal, maxVal)
@@ -364,6 +392,8 @@ function addon.GUI:BuildSettings(parent)
if thumbTexture then if thumbTexture then
thumbTexture:SetTexture(C.SOLID) thumbTexture:SetTexture(C.SOLID)
thumbTexture:SetVertexColor(unpack(C.ACCENT)) thumbTexture:SetVertexColor(unpack(C.ACCENT))
thumbTexture:SetWidth(8)
thumbTexture:SetHeight(14)
end end
local lowText = getglobal(sliderName .. "Low") local lowText = getglobal(sliderName .. "Low")
@@ -391,19 +421,95 @@ function addon.GUI:BuildSettings(parent)
else else
valText:SetText(math.floor(val + 0.5) .. suffix) valText:SetText(math.floor(val + 0.5) .. suffix)
end end
addon.Nameplates:RecreateAll() ApplySettingChange(configKey)
end) end)
y = y + 24 y = y + 24
widgets[configKey] = slider widgets[configKey] = slider
end end
-- ==============================================================
-- Helper: Dropdown (uses Blizzard's UIDropDownMenuTemplate)
-- ==============================================================
local function Dropdown(labelText, configKey, options)
local label = parent:CreateFontString(nil, "OVERLAY")
label:SetFont(FONT_NORMAL, FONT_SIZE)
label:SetPoint("TOPLEFT", parent, "TOPLEFT", MARGIN, -y)
label:SetTextColor(unpack(C.TEXT))
label:SetText(labelText)
y = y + 16
local dd = CreateFrame("Frame", "RelationshipsThreatPlatesDD_" .. configKey, parent, "UIDropDownMenuTemplate")
dd:SetPoint("TOPLEFT", parent, "TOPLEFT", MARGIN - 14, -y)
UIDropDownMenu_SetWidth(160, dd)
local function GetLabelFor(val)
for i = 1, table.getn(options) do
if options[i].value == val then return options[i].label end
end
return tostring(val)
end
UIDropDownMenu_Initialize(dd, function()
for i = 1, table.getn(options) do
local opt = options[i]
local info = {}
info.text = opt.label
info.value = opt.value
info.func = function()
RelationshipsThreatPlatesDB[configKey] = this.value
UIDropDownMenu_SetSelectedValue(dd, this.value)
UIDropDownMenu_SetText(GetLabelFor(this.value), dd)
ApplySettingChange(configKey)
end
info.checked = (RelationshipsThreatPlatesDB[configKey] == opt.value)
UIDropDownMenu_AddButton(info)
end
end)
UIDropDownMenu_SetSelectedValue(dd, RelationshipsThreatPlatesDB[configKey])
UIDropDownMenu_SetText(GetLabelFor(RelationshipsThreatPlatesDB[configKey]), dd)
y = y + 34
widgets[configKey] = dd
end
-- ==============================================================
-- Helper: small action button
-- ==============================================================
local function ActionButton(labelText, onClick)
local btn = CreateFrame("Button", nil, parent)
btn:SetWidth(W - MARGIN * 2)
btn:SetHeight(20)
btn:SetPoint("TOPLEFT", parent, "TOPLEFT", MARGIN, -y)
SafeSetBackdrop(btn, {
bgFile = C.SOLID,
edgeFile = C.SOLID,
tile = false, tileSize = 0,
edgeSize = 1,
})
btn:SetBackdropColor(unpack(C.BUTTON_BG))
btn:SetBackdropBorderColor(unpack(C.BORDER))
local t = btn:CreateFontString(nil, "OVERLAY")
t:SetFont(FONT_NORMAL, FONT_SIZE, "OUTLINE")
t:SetPoint("CENTER", btn, "CENTER", 0, 0)
t:SetTextColor(unpack(C.BUTTON_TEXT))
t:SetText(labelText)
btn:SetFontString(t)
btn:SetScript("OnEnter", function() this:SetBackdropColor(unpack(C.BUTTON_HOVER)) end)
btn:SetScript("OnLeave", function() this:SetBackdropColor(unpack(C.BUTTON_BG)) end)
btn:SetScript("OnClick", onClick)
y = y + 24
return btn
end
-- ============================================================== -- ==============================================================
-- GENERAL SECTION -- GENERAL SECTION
-- ============================================================== -- ==============================================================
SectionLabel("General") SectionLabel("General")
CheckBox("Enable Threat Plates", "enabled", "Show threat bars above nameplates") CheckBox("Enable Threat Plates", "enabled", "Show threat bars above nameplates")
CheckBox("Show Threat Text", "showText", "Display percentage in the bar") CheckBox("Show Threat Text", "showText", "Display value in the bar")
CheckBox("Tank Mode", "tankMode", "Show raw threat lead over 2nd place instead of %")
CheckBox("Snap to 100% when Targeted", "targetSnap100", "Force 100% while a mob is focused on you")
CheckBox("Smooth Colours", "smoothColors", "Gradient colour transition") CheckBox("Smooth Colours", "smoothColors", "Gradient colour transition")
-- ============================================================== -- ==============================================================
@@ -412,7 +518,7 @@ function addon.GUI:BuildSettings(parent)
SectionLabel("Appearance") SectionLabel("Appearance")
Slider("Bar Height", "barHeight", 2, 20, 1, "px") Slider("Bar Height", "barHeight", 2, 20, 1, "px")
Slider("Bar Width Scale", "barWidthScale", 0.3, 1.0, 0.05, "") Slider("Bar Width Scale", "barWidthScale", 0.3, 1.0, 0.05, "")
Slider("Bar Offset Y", "barOffsetY", 0, 20, 1, "px") Slider("Bar Offset Y", "barOffsetY", 0, 50, 1, "px")
Slider("Bar Offset X", "barOffsetX", -50, 50, 0.1, "px") Slider("Bar Offset X", "barOffsetX", -50, 50, 0.1, "px")
Slider("Bar Alpha", "barAlpha", 0.1, 1.0, 0.1, "") Slider("Bar Alpha", "barAlpha", 0.1, 1.0, 0.1, "")
Slider("Font Size", "fontSize", 6, 16, 1, "pt") Slider("Font Size", "fontSize", 6, 16, 1, "pt")
@@ -437,6 +543,95 @@ function addon.GUI:BuildSettings(parent)
CheckBox("Show on Neutral", "showOnNeutral", "Show threat bars on neutral units") CheckBox("Show on Neutral", "showOnNeutral", "Show threat bars on neutral units")
CheckBox("Show on Friendly", "showOnFriendly", "Show threat bars on friendly units") CheckBox("Show on Friendly", "showOnFriendly", "Show threat bars on friendly units")
-- ==============================================================
-- BEHAVIOR SECTION (2.6)
-- ==============================================================
SectionLabel("Behavior")
CheckBox("Combat Only", "combatOnly", "Only show bars while you are in combat")
Slider("Hide Below %", "hideBelowPct", 0, 90, 5, "%")
Dropdown("Text Format", "textFormat", {
{ label = "Percent (85%)", value = "percent" },
{ label = "Tank Lead (+123)", value = "tank" },
{ label = "Status (SAFE/PULL!)", value = "status" },
{ label = "Combo (85% +123)", value = "combo" },
})
Dropdown("Colour Preset", "colorPreset", {
{ label = "Kui (green -> red)", value = "kui" },
{ label = "Cool (blue -> red)", value = "cool" },
{ label = "Mono (grey -> red)", value = "mono" },
{ label = "Warband (cyan -> red)", value = "warband" },
})
-- ==============================================================
-- WARNINGS SECTION (2.6)
-- ==============================================================
SectionLabel("Warnings")
CheckBox("Flash Bar on Aggro", "flashOnAggro", "Pulse the bar when you have 100% aggro")
Slider("Flash Speed", "flashSpeed", 1, 10, 1, "")
CheckBox("Play Warning Sound", "warningSound", "Sound when threat crosses the threshold")
Slider("Warning Threshold", "warningThreshold", 40, 100, 5, "%")
Dropdown("Warning Sound", "warningSoundName", {
{ label = "Raid Warning", value = "RaidWarning" },
{ label = "Tell Message", value = "TellMessage" },
{ label = "Murloc Aggro", value = "MurlocAggroLocal" },
{ label = "Auction Open", value = "AuctionWindowOpen"},
{ label = "Level Up", value = "LEVELUP" },
})
ActionButton("Test Warning Sound", function()
local snd = RelationshipsThreatPlatesDB.warningSoundName or "RaidWarning"
pcall(PlaySound, snd)
end)
-- ==============================================================
-- WINDOW SECTION (2.6)
-- ==============================================================
SectionLabel("Config Window")
ActionButton("Reset Config Window Position", function()
RelationshipsThreatPlatesDB.guiPos = { point = "CENTER", relPoint = "CENTER", x = 0, y = 0 }
if RelationshipsThreatPlatesGUI then
RelationshipsThreatPlatesGUI:ClearAllPoints()
RelationshipsThreatPlatesGUI:SetPoint("CENTER", UIParent, "CENTER", 0, 0)
end
end)
-- ==============================================================
-- Reset button at bottom
-- ==============================================================
y = y + 10
local resetBtn = CreateFrame("Button", nil, parent)
resetBtn:SetWidth(W - MARGIN * 2)
resetBtn:SetHeight(22)
resetBtn:SetPoint("TOPLEFT", parent, "TOPLEFT", MARGIN, -y)
SafeSetBackdrop(resetBtn, {
bgFile = C.SOLID,
edgeFile = C.SOLID,
tile = false, tileSize = 0,
edgeSize = 1,
})
resetBtn:SetBackdropColor(unpack(C.BUTTON_BG))
resetBtn:SetBackdropBorderColor(unpack(C.BORDER))
local resetText = resetBtn:CreateFontString(nil, "OVERLAY")
resetText:SetFont(FONT_NORMAL, FONT_SIZE, "OUTLINE")
resetText:SetPoint("CENTER", resetBtn, "CENTER", 0, 0)
resetText:SetTextColor(unpack(C.BUTTON_TEXT))
resetText:SetText("Reset to Defaults")
resetBtn:SetFontString(resetText)
resetBtn:SetScript("OnEnter", function()
this:SetBackdropColor(unpack(C.BUTTON_HOVER))
end)
resetBtn:SetScript("OnLeave", function()
this:SetBackdropColor(unpack(C.BUTTON_BG))
end)
resetBtn:SetScript("OnClick", function()
SlashCmdList["THREATPLATES"]("reset")
if addon.Nameplates and addon.Nameplates.RecreateAll then
addon.Nameplates:RecreateAll()
end
end)
y = y + 22 + 8
-- Return total content height for scroll setup -- Return total content height for scroll setup
return y return y
end end
+45 -11
View File
@@ -69,6 +69,37 @@ function addon.Media.Brighten(factor, r, g, b, a)
a a
end end
-- ==================================================================
-- Colour presets for the threat gradient (2.6)
-- Each preset defines 4 stops: low (0%), medium (50%), high (80%), aggro (100%)
-- ==================================================================
addon.Media.COLOR_PRESETS = {
kui = {
low = { 0.33, 0.80, 0.33 },
medium = { 1.00, 0.85, 0.00 },
high = { 1.00, 0.50, 0.00 },
aggro = { 1.00, 0.10, 0.10 },
},
cool = {
low = { 0.20, 0.55, 1.00 },
medium = { 0.55, 0.35, 1.00 },
high = { 1.00, 0.35, 0.55 },
aggro = { 1.00, 0.10, 0.10 },
},
mono = {
low = { 0.55, 0.55, 0.55 },
medium = { 0.80, 0.80, 0.80 },
high = { 1.00, 1.00, 1.00 },
aggro = { 1.00, 0.20, 0.20 },
},
warband = {
low = { 0.20, 0.90, 0.90 },
medium = { 0.60, 0.90, 0.30 },
high = { 1.00, 0.80, 0.20 },
aggro = { 1.00, 0.10, 0.10 },
},
}
-- ================================================================== -- ==================================================================
-- Helper: get smooth interpolated threat colour -- Helper: get smooth interpolated threat colour
-- ================================================================== -- ==================================================================
@@ -76,25 +107,28 @@ function addon.Media.GetThreatColour(pct)
pct = tonumber(pct) or 0 pct = tonumber(pct) or 0
pct = math.min(100, math.max(0, pct)) pct = math.min(100, math.max(0, pct))
local c = addon.Media.COLOURS local presetName = (RelationshipsThreatPlatesDB and RelationshipsThreatPlatesDB.colorPreset) or "kui"
local p = addon.Media.COLOR_PRESETS[presetName] or addon.Media.COLOR_PRESETS.kui
local lo, md, hi, ag = p.low, p.medium, p.high, p.aggro
local r, g, b local r, g, b
if pct < 50 then if pct < 50 then
local t = pct / 50 local t = pct / 50
r = c.THREAT_LOW.r + (c.THREAT_MEDIUM.r - c.THREAT_LOW.r) * t r = lo[1] + (md[1] - lo[1]) * t
g = c.THREAT_LOW.g + (c.THREAT_MEDIUM.g - c.THREAT_LOW.g) * t g = lo[2] + (md[2] - lo[2]) * t
b = c.THREAT_LOW.b + (c.THREAT_MEDIUM.b - c.THREAT_LOW.b) * t b = lo[3] + (md[3] - lo[3]) * t
elseif pct < 80 then elseif pct < 80 then
local t = (pct - 50) / 30 local t = (pct - 50) / 30
r = c.THREAT_MEDIUM.r + (c.THREAT_HIGH.r - c.THREAT_MEDIUM.r) * t r = md[1] + (hi[1] - md[1]) * t
g = c.THREAT_MEDIUM.g + (c.THREAT_HIGH.g - c.THREAT_MEDIUM.g) * t g = md[2] + (hi[2] - md[2]) * t
b = c.THREAT_MEDIUM.b + (c.THREAT_HIGH.b - c.THREAT_MEDIUM.b) * t b = md[3] + (hi[3] - md[3]) * t
else else
local t = (pct - 80) / 20 local t = (pct - 80) / 20
t = math.min(1, math.max(0, t)) if t > 1 then t = 1 end
r = c.THREAT_HIGH.r + (c.THREAT_AGGRO.r - c.THREAT_HIGH.r) * t if t < 0 then t = 0 end
g = c.THREAT_HIGH.g + (c.THREAT_AGGRO.g - c.THREAT_HIGH.g) * t r = hi[1] + (ag[1] - hi[1]) * t
b = c.THREAT_HIGH.b + (c.THREAT_AGGRO.b - c.THREAT_HIGH.b) * t g = hi[2] + (ag[2] - hi[2]) * t
b = hi[3] + (ag[3] - hi[3]) * t
end end
return r, g, b return r, g, b
+285 -99
View File
@@ -390,21 +390,24 @@ function addon.Nameplates:ScanForNameplates()
local nameplate = children[i] local nameplate = children[i]
if nameplate and not knownPlates[nameplate] then if nameplate and not knownPlates[nameplate] then
-- Quick visibility filter - don't scan invisible frames -- 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 if nameplate:IsVisible() then
knownPlates[nameplate] = true
-- Check if this frame is a nameplate
local healthBar, nameText = self:IdentifyNameplate(nameplate) local healthBar, nameText = self:IdentifyNameplate(nameplate)
if healthBar then if healthBar then
knownPlates[nameplate] = true
self:AttachThreatBar(nameplate, healthBar, nameText) self:AttachThreatBar(nameplate, healthBar, nameText)
end end
else
-- Mark as known even if invisible; we'll handle
-- visibility in the update loop
knownPlates[nameplate] = true
end end
-- Invisible plates are intentionally left un-flagged so
-- the periodic scan will re-check them once they show.
end end
end end
end end
@@ -887,31 +890,64 @@ function addon.Nameplates:ApplyThreatToPlate(data, pct, situation)
-- Now we only check if there's actual threat data to show. The combat -- Now we only check if there's actual threat data to show. The combat
-- verification is done earlier in the pipeline (IsPlateInCombat, -- verification is done earlier in the pipeline (IsPlateInCombat,
-- GetThreatByName, etc.). -- GetThreatByName, etc.).
-- Combat-only display (2.6)
if RelationshipsThreatPlatesDB.combatOnly then
local inCombat = false
if UnitAffectingCombat then
local ok, r = pcall(UnitAffectingCombat, "player")
if ok then inCombat = r end
end
if not inCombat then
data.container:Hide()
if addon.Nameplates.SetFlashing then addon.Nameplates:SetFlashing(data, false) end
return
end
end
-- Hide below threshold (2.6)
local minShow = RelationshipsThreatPlatesDB.hideBelowPct or 0
if minShow > 0 and (pct or 0) < minShow then
data.container:Hide()
if addon.Nameplates.SetFlashing then addon.Nameplates:SetFlashing(data, false) end
return
end
if pct == 0 then if pct == 0 then
data.container:Hide() data.container:Hide()
if addon.Nameplates.SetFlashing then addon.Nameplates:SetFlashing(data, false) end
return return
end end
data.container:Show() data.container:Show()
-- Smooth the percentage -- 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 oldPct = data.lastPct or 0
local barPct = math.min(100, math.max(0, rawPct))
local smoothPct local smoothPct
if pct == 0 then -- Snap instantly when jumping to full aggro or dropping to 0
smoothPct = 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 else
smoothPct = oldPct + (pct - oldPct) * 0.3 smoothPct = oldPct + (barPct - oldPct) * 0.6
end end
data.lastPct = smoothPct data.lastPct = smoothPct
smoothPct = math.min(100, math.max(0, smoothPct)) smoothPct = math.min(100, math.max(0, smoothPct))
pct = math.min(100, math.max(0, pct))
-- Update bar value -- Update bar value (0-100 range)
data.bar:SetValue(smoothPct) data.bar:SetValue(smoothPct)
-- Update bar colour (smooth gradient) -- Update bar colour (smooth gradient) — driven by barPct so colours
local r, g, b = addon.Media.GetThreatColour(pct) -- 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) data.bar:SetStatusBarColor(r, g, b, RelationshipsThreatPlatesDB.barAlpha or 1)
-- Update statusbar texture vertex colour -- Update statusbar texture vertex colour
@@ -946,24 +982,63 @@ function addon.Nameplates:ApplyThreatToPlate(data, pct, situation)
end end
end end
-- Update text -- Update text — uses rawPct so values above 100% are shown as-is.
if data.text then if data.text then
local displayPct = math.floor(pct + 0.5) local fmt = RelationshipsThreatPlatesDB.textFormat or "percent"
data.text:SetText(displayPct .. "%") local pctStr = math.floor(rawPct + 0.5) .. "%"
local tankStr = nil
if data.plateName and (RelationshipsThreatPlatesDB.tankMode or fmt == "tank" or fmt == "combo") then
tankStr = addon.Threat:GetTankDisplayForMob(data.plateName)
end
local textStr
if RelationshipsThreatPlatesDB.tankMode and tankStr and tankStr ~= "--" then
textStr = tankStr
elseif fmt == "tank" and tankStr and tankStr ~= "--" then
textStr = tankStr
elseif fmt == "status" then
if rawPct >= 100 then textStr = "PULL!"
elseif rawPct >= 80 then textStr = "WARN"
elseif rawPct >= 50 then textStr = "HIGH"
else textStr = "SAFE" end
elseif fmt == "combo" and tankStr and tankStr ~= "--" then
textStr = pctStr .. " " .. tankStr
else
textStr = pctStr
end
data.text:SetText(textStr)
if pct >= 90 then if rawPct >= 100 then
data.text:SetTextColor(1, 0.2, 0.2, 1) data.text:SetTextColor(1, 0.2, 0.2, 1)
elseif pct >= 60 then elseif rawPct >= 60 then
data.text:SetTextColor(1, 0.9, 0.8, 1) data.text:SetTextColor(1, 0.9, 0.8, 1)
else else
data.text:SetTextColor(1, 1, 1, 1) data.text:SetTextColor(1, 1, 1, 1)
end end
end end
-- Update threat glow -- Warning sound (edge-triggered, 2.6)
do
local db = RelationshipsThreatPlatesDB
local prevWarn = data.lastWarnPct or 0
local threshold = db.warningThreshold or 80
if db.warningSound and threshold > 0
and rawPct >= threshold and prevWarn < threshold then
local snd = db.warningSoundName or "RaidWarning"
pcall(PlaySound, snd)
end
data.lastWarnPct = rawPct
-- Flash on aggro (2.6)
if addon.Nameplates.SetFlashing then
addon.Nameplates:SetFlashing(data, (db.flashOnAggro and rawPct >= 100) and true or false)
end
end
-- Update threat glow (drive by clamped barPct so it caps at max intensity)
if data.threatGlow then if data.threatGlow then
if pct >= 80 then if barPct >= 80 then
local intensity = (pct - 80) / 20 local intensity = (barPct - 80) / 20
if intensity > 1 then intensity = 1 end
data.threatGlow:SetVertexColor(0.9, 0, 0, 0.6 * intensity) data.threatGlow:SetVertexColor(0.9, 0, 0, 0.6 * intensity)
data.threatGlow:Show() data.threatGlow:Show()
else else
@@ -1030,7 +1105,7 @@ end
-- --
-- Returns: unit token string, or nil -- Returns: unit token string, or nil
-- ================================================================== -- ==================================================================
function addon.Nameplates:FindCombatUnitForPlate(data) function addon.Nameplates:FindCombatUnitForPlate(data, excludeUnits)
if not data or not data.plateName then return nil end if not data or not data.plateName then return nil end
if strlen(data.plateName) == 0 then return nil end if strlen(data.plateName) == 0 then return nil end
@@ -1048,25 +1123,67 @@ function addon.Nameplates:FindCombatUnitForPlate(data)
-- Get plate health percentage for matching -- Get plate health percentage for matching
local plateHealthPct = GetPlateHealthPercent(data) local plateHealthPct = GetPlateHealthPercent(data)
-- Same-name mob safety: if THIS plate's health looks idle
-- (near-full and not dropping), refuse to bind any combat
-- unit token. Binding a nearby attacker's unit token to an
-- idle same-name plate is the root cause of the "idle mob
-- shows attacker's threat bar" bug.
local plateLooksIdle = false
if plateHealthPct and plateHealthPct >= 99.5 then
if not data.previousHealth or data.previousHealth >= 99.5 then
plateLooksIdle = true
end
end
if plateLooksIdle then
return nil
end
-- Score each candidate: we want a unit that is: -- Score each candidate: we want a unit that is:
-- 1. In combat (mandatory — idle mobs must never match) -- 1. In combat (mandatory — idle mobs must never match)
-- 2. Health % close to the plate's health bar -- 2. Health % close to the plate's health bar
-- 3. Hostile and attackable -- 3. Hostile and attackable
-- 4. Not already assigned to a different plate this cycle
local bestUnit = nil local bestUnit = nil
local bestScore = -999999 local bestScore = -999999
for i = 1, table.getn(candidates) do for i = 1, table.getn(candidates) do
local c = candidates[i] local c = candidates[i]
-- MANDATORY: Unit must be in combat -- Skip candidates already claimed by another plate this cycle
if c.inCombat then local excluded = false
local score = 0 if excludeUnits then
for j = 1, table.getn(excludeUnits) do
if IsSameUnitToken(excludeUnits[j], c.unit) then
excluded = true
break
end
end
end
-- Health % matching (most important discriminator) -- MANDATORY: Unit must be in combat AND not already claimed.
if c.inCombat and not excluded then
local score = 0
local healthMatched = false
-- Health % matching (most important discriminator).
-- Require a reasonably close match — if the plate is at
-- 40% and the only combat candidate is at 100%, it is
-- almost certainly a DIFFERENT mob with the same name
-- and must NOT be bound to this plate.
if plateHealthPct and c.healthPct then if plateHealthPct and c.healthPct then
local diff = math.abs(plateHealthPct - c.healthPct) local diff = math.abs(plateHealthPct - c.healthPct)
-- Close health match = strong signal this is the right mob if diff <= 8 then
score = score + math.max(0, 500 - (diff * 15)) healthMatched = true
score = score + math.max(0, 500 - (diff * 15))
else
-- Too far apart — skip this candidate entirely.
score = -999999
end
else
-- No health info to compare. Fall back to previous
-- behaviour so freshly-shown plates can still bind
-- when their health bar hasn't been read yet.
healthMatched = true
end end
-- Hostile/attackable bonus -- Hostile/attackable bonus
@@ -1074,7 +1191,7 @@ function addon.Nameplates:FindCombatUnitForPlate(data)
score = score + 50 score = score + 50
end end
if score > bestScore then if score > bestScore and healthMatched then
bestScore = score bestScore = score
bestUnit = c.unit bestUnit = c.unit
end end
@@ -1113,6 +1230,23 @@ end
-- - Mobs attacking party members show threat even when player -- - Mobs attacking party members show threat even when player
-- isn't in combat -- isn't in combat
-- ================================================================== -- ==================================================================
-- ==================================================================
-- Fallback threat values when we know a mob is in combat but have
-- no ThreatEngine percentage. Distinguishes "mob is attacking the
-- player" (full aggro on us) from "mob is attacking a party member"
-- (we're not the aggro target, show a low visible bar).
-- ==================================================================
local function GetFallbackThreatForMob(name)
if name
and addon.ThreatEngine
and addon.ThreatEngine.IsMobAttackingPlayer
and addon.ThreatEngine:IsMobAttackingPlayer(name) then
return 100, 3
end
return 20, 0
end
function addon.Nameplates:UpdateAllPlates() function addon.Nameplates:UpdateAllPlates()
if not RelationshipsThreatPlatesDB or not RelationshipsThreatPlatesDB.enabled then if not RelationshipsThreatPlatesDB or not RelationshipsThreatPlatesDB.enabled then
for nameplate, data in pairs(self.plates) do for nameplate, data in pairs(self.plates) do
@@ -1155,6 +1289,16 @@ function addon.Nameplates:UpdateAllPlates()
end end
-- Step 4: Update threat display for each visible plate. -- Step 4: Update threat display for each visible plate.
-- Track combat unit tokens assigned this cycle so a single
-- combat mob can never be bound to multiple same-name plates
-- (which was causing idle same-name plates to display the
-- attacker's threat bar).
local assignedUnits = {}
for nameplate, data in pairs(self.plates) do
if data.unit then
assignedUnits[table.getn(assignedUnits) + 1] = data.unit
end
end
for nameplate, data in pairs(self.plates) do for nameplate, data in pairs(self.plates) do
if nameplate:IsVisible() then if nameplate:IsVisible() then
local isUniqueName = (platesPerName[data.plateName] or 1) <= 1 local isUniqueName = (platesPerName[data.plateName] or 1) <= 1
@@ -1163,6 +1307,21 @@ function addon.Nameplates:UpdateAllPlates()
local plateInCombat = IsPlateInCombat(data) local plateInCombat = IsPlateInCombat(data)
local hasThreatData = data.plateName and addon.ThreatEngine:HasDataForTarget(data.plateName) local hasThreatData = data.plateName and addon.ThreatEngine:HasDataForTarget(data.plateName)
-- v2.6.2: A mob is also "in combat with us" if the incoming
-- combat-log parser has seen it hit or miss the player /
-- a party member within the last few seconds. In Vanilla
-- 1.12 non-targeted mobs have no unit token, so this is
-- the ONLY reliable signal for "this specific mob is
-- attacking the group" when its health bar hasn't
-- dropped yet. We only apply this signal to unique-name
-- plates to avoid lighting up every same-name plate when
-- only one of them is actually attacking.
local mobAttackingGroup =
data.plateName
and addon.ThreatEngine.IsMobAttackingGroup
and addon.ThreatEngine:IsMobAttackingGroup(data.plateName)
or false
if data.unit and UnitExists(data.unit) then if data.unit and UnitExists(data.unit) then
-- We have a resolved unit token. Verify it's in combat. -- We have a resolved unit token. Verify it's in combat.
local ok_combat, mobInCombat = pcall(UnitAffectingCombat, data.unit) local ok_combat, mobInCombat = pcall(UnitAffectingCombat, data.unit)
@@ -1175,91 +1334,67 @@ function addon.Nameplates:UpdateAllPlates()
self:ApplyThreatToPlate(data, pct, situation or 0) self:ApplyThreatToPlate(data, pct, situation or 0)
end end
elseif isUniqueName and data.plateName then elseif isUniqueName and data.plateName then
-- UNIQUE-NAME MOB with no unit token: -- 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 -- CAUTION: "unique" here only means one plate of this
-- data is unambiguous. Use health-based combat detection. -- name is currently VISIBLE. The camera may have rotated
-- away from a same-name attacker who is still active.
-- Because ThreatEngine data (and IsMobAttackingGroup)
-- are keyed by NAME, trusting them for an idle-looking
-- plate would light up the wrong mob when its twin
-- attacker is off-screen. So we require one of:
-- (a) confirmed health drop on THIS plate, OR
-- (b) a resolved combat unit token whose health
-- matches THIS plate.
-- A name-only signal (mobAttackingGroup / ThreatEngine
-- data) is never enough on its own.
if plateInCombat then if plateInCombat then
-- Mob is in combat (health dropping or below 100%). -- This specific plate's health is dropping — it's
-- Record when we first detected combat for grace period. -- in combat. Safe to trust name-keyed threat data.
if not data.combatDetectedAt then if not data.combatDetectedAt then
data.combatDetectedAt = now data.combatDetectedAt = now
end end
-- Try ThreatEngine data first
local pct, sit = addon.Threat:GetThreatByName(data.plateName) local pct, sit = addon.Threat:GetThreatByName(data.plateName)
if pct > 0 then if pct > 0 then
self:ApplyThreatToPlate(data, pct, sit or 0) self:ApplyThreatToPlate(data, pct, sit or 0)
else 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 if groupInCombat then
self:ApplyThreatToPlate(data, 100, 3) do local _fp, _fs = GetFallbackThreatForMob(data.plateName); self:ApplyThreatToPlate(data, _fp, _fs) end
else else
data.container:Hide() data.container:Hide()
end end
end end
elseif hasThreatData and groupInCombat then elseif groupInCombat and (mobAttackingGroup or hasThreatData) then
-- Plate looks idle (full health) but ThreatEngine has -- Name-only signal says a mob of this name is
-- data and group is in combat. This can happen briefly -- attacking the group, but THIS plate shows no
-- after a mob is first hit (health bar hasn't updated) -- health drop. Verify by matching a real combat
-- or for a mob being attacked by someone else. -- unit token to this plate (health % match). If
-- Give a grace period. -- none matches, this plate is an idle bystander
if not data.combatDetectedAt then -- (e.g. the real attacker rotated off-screen).
data.combatDetectedAt = now local unit = self:FindCombatUnitForPlate(data, assignedUnits)
end if unit then
if now - data.combatDetectedAt < 0.5 then data.unit = unit
local pct, sit = addon.Threat:GetThreatByName(data.plateName) data.guid = addon:GetUnitID(unit)
if pct > 0 then assignedUnits[table.getn(assignedUnits) + 1] = unit
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 if not data.combatDetectedAt then
data.combatDetectedAt = now data.combatDetectedAt = now
end end
self:ApplyThreatToPlate(data, 100, 3) local pct, situation = addon.Threat:GetThreatPercent(unit)
if pct > 0 then
self:ApplyThreatToPlate(data, pct, situation or 0)
else
do local _fp, _fs = GetFallbackThreatForMob(data.plateName); self:ApplyThreatToPlate(data, _fp, _fs) end
end
else else
-- Can't confirm mob is attacking anyone. -- Can't verify this plate is the attacker.
-- It might be idle. Don't show bar. -- Do NOT show a bar based on name alone.
data.combatDetectedAt = nil data.combatDetectedAt = nil
data.container:Hide() data.container:Hide()
end end
else else
-- No combat detected, no threat data, group not in combat. -- No combat detected, no threat data, or group not in combat.
data.combatDetectedAt = nil data.combatDetectedAt = nil
data.container:Hide() data.container:Hide()
end end
@@ -1275,10 +1410,11 @@ function addon.Nameplates:UpdateAllPlates()
end end
-- Try to find a combat-verified unit with matching health. -- Try to find a combat-verified unit with matching health.
local unit = self:FindCombatUnitForPlate(data) local unit = self:FindCombatUnitForPlate(data, assignedUnits)
if unit then if unit then
data.unit = unit data.unit = unit
data.guid = addon:GetUnitID(unit) data.guid = addon:GetUnitID(unit)
assignedUnits[table.getn(assignedUnits) + 1] = unit
local pct, situation = addon.Threat:GetThreatPercent(unit) local pct, situation = addon.Threat:GetThreatPercent(unit)
self:ApplyThreatToPlate(data, pct, situation or 0) self:ApplyThreatToPlate(data, pct, situation or 0)
elseif hasThreatData then elseif hasThreatData then
@@ -1293,7 +1429,7 @@ function addon.Nameplates:UpdateAllPlates()
-- In combat but no threat data — mob is being -- In combat but no threat data — mob is being
-- attacked by someone we can't track. -- attacked by someone we can't track.
if groupInCombat then if groupInCombat then
self:ApplyThreatToPlate(data, 100, 3) do local _fp, _fs = GetFallbackThreatForMob(data.plateName); self:ApplyThreatToPlate(data, _fp, _fs) end
else else
data.container:Hide() data.container:Hide()
end end
@@ -1302,7 +1438,7 @@ function addon.Nameplates:UpdateAllPlates()
-- In combat (health dropping) but no ThreatEngine data. -- In combat (health dropping) but no ThreatEngine data.
-- Show default high-threat bar. -- Show default high-threat bar.
if groupInCombat then if groupInCombat then
self:ApplyThreatToPlate(data, 100, 3) do local _fp, _fs = GetFallbackThreatForMob(data.plateName); self:ApplyThreatToPlate(data, _fp, _fs) end
else else
data.container:Hide() data.container:Hide()
end end
@@ -1312,11 +1448,12 @@ function addon.Nameplates:UpdateAllPlates()
-- But check if a unit token reveals this specific mob -- But check if a unit token reveals this specific mob
-- is attacking a group member (body pull scenario). -- is attacking a group member (body pull scenario).
if groupInCombat then if groupInCombat then
local unit = self:FindCombatUnitForPlate(data) local unit = self:FindCombatUnitForPlate(data, assignedUnits)
if unit then if unit then
-- Found a combat-verified unit for this plate. -- Found a combat-verified unit for this plate.
data.unit = unit data.unit = unit
data.guid = addon:GetUnitID(unit) data.guid = addon:GetUnitID(unit)
assignedUnits[table.getn(assignedUnits) + 1] = unit
local pct, situation = addon.Threat:GetThreatPercent(unit) local pct, situation = addon.Threat:GetThreatPercent(unit)
if pct > 0 then if pct > 0 then
if not data.combatDetectedAt then if not data.combatDetectedAt then
@@ -1385,3 +1522,52 @@ function addon.Nameplates:RecreateAll()
knownPlates = {} knownPlates = {}
self:ScanForNameplates() self:ScanForNameplates()
end end
-- ==================================================================
-- Flash driver (2.6) — pulses bar alpha on aggro plates
-- ==================================================================
addon.Nameplates.flashSet = addon.Nameplates.flashSet or {}
if not addon.Nameplates.flashFrame then
local ff = CreateFrame("Frame")
addon.Nameplates.flashFrame = ff
ff:SetScript("OnUpdate", function()
local db = RelationshipsThreatPlatesDB
if not db then return end
local baseA = db.barAlpha or 1
if not db.flashOnAggro then
-- Restore any lingering flashers to base alpha
for d in pairs(addon.Nameplates.flashSet) do
if d and d.bar then d.bar:SetAlpha(baseA) end
addon.Nameplates.flashSet[d] = nil
end
return
end
local speed = db.flashSpeed or 4
local t = GetTime() * speed
-- fabs(sin) style pulse in [0.35, 1.0]
local s = math.sin(t)
if s < 0 then s = -s end
local a = 0.35 + 0.65 * s
for d in pairs(addon.Nameplates.flashSet) do
if d and d.bar and d.container and d.container:IsShown() then
d.bar:SetAlpha(a * baseA)
end
end
end)
end
function addon.Nameplates:SetFlashing(data, on)
if not data then return end
if on then
addon.Nameplates.flashSet[data] = true
else
if addon.Nameplates.flashSet[data] then
addon.Nameplates.flashSet[data] = nil
if data.bar then
data.bar:SetAlpha(RelationshipsThreatPlatesDB and RelationshipsThreatPlatesDB.barAlpha or 1)
end
end
end
end
+160 -56
View File
@@ -57,7 +57,11 @@ local partyNames = {}
local partyUnits = {} local partyUnits = {}
-- Update throttle (seconds) -- Update throttle (seconds)
local THROTTLE = 0.2 local THROTTLE = 0.1
-- Cache of per-mob tank-mode details, keyed by mob UID (name in vanilla).
-- Populated by UpdateUnitThreat via ThreatEngine, and read by the Nameplates
-- module when rendering the tank-mode text (raw threat lead).
local tankDetailsCache = {}
-- ================================================================== -- ==================================================================
-- Safe API call helpers -- Safe API call helpers
@@ -416,61 +420,37 @@ function addon.Threat:GetThreatPercent(unit)
-- If the mob is targeting an outsider -> show player's threat -- If the mob is targeting an outsider -> show player's threat
-- Otherwise -> show the best (highest) threat from any member -- Otherwise -> show the best (highest) threat from any member
-- The threat bar on a nameplate is always displayed from the
-- PLAYER's point of view: "how close am I to pulling aggro
-- off whoever currently has it?". Showing another group
-- member's percentage would incorrectly make every mob a
-- party member is tanking read as 100% — that is the bug
-- users describe as "snap causes all party-combat enemies
-- to show 100%". So: always return the player's own value
-- here. If the player has no threat on this mob, return 0
-- so the bar hides.
if mobTargetName == playerName then if mobTargetName == playerName then
-- Mob is attacking the player - show player's own threat -- Mob is attacking the player - show player's own threat
return playerPct, playerSit return playerPct, playerSit
elseif mobTargetName and partyNames[mobTargetName] then elseif mobTargetName and partyNames[mobTargetName] then
-- Mob is attacking a group member - show that member's threat -- Mob is attacking a group member. Show the player's
-- Look up that member's threat from the group scan -- own relative threat, not the party member's.
local memberUnit = partyNames[mobTargetName]
local memberPct = nil
local memberSit = nil
if hasUnitDetailedThreatSituation then
local _, status, _, rawPct = SafeCall5(UnitDetailedThreatSituation, memberUnit, unit)
if rawPct then
memberPct = math.min(100, math.max(0, rawPct))
memberSit = status or 0
end
end
if not memberPct and hasGetThreatPercent then
local pct = SafeCall2(GetThreatPercent, memberUnit, unit)
if pct then
memberPct = math.min(100, math.max(0, pct))
if hasUnitThreatSituation then
memberSit = SafeCall2(UnitThreatSituation, memberUnit, unit) or 0
else
if memberPct >= 100 then memberSit = 3
elseif memberPct >= 60 then memberSit = 2
elseif memberPct >= 30 then memberSit = 1
else memberSit = 0 end
end
end
end
if not memberPct and hasUnitThreatSituation then
memberSit = SafeCall2(UnitThreatSituation, memberUnit, unit) or 0
if memberSit == 0 then memberPct = 0
elseif memberSit == 1 then memberPct = 25
elseif memberSit == 2 then memberPct = 60
elseif memberSit == 3 then memberPct = 100 end
end
if memberPct then
return memberPct, memberSit or 0
end
-- Fallback: can't get specific member data, use best
return bestPct, bestSit
else
-- Mob targets an outsider, or we can't see its target.
-- Show player's threat if they have any, otherwise the
-- best group threat.
if playerPct > 0 then if playerPct > 0 then
return playerPct, playerSit return playerPct, playerSit
end end
return bestPct, bestSit -- Player has no threat yet, but a party member is in
-- the aggro seat. Show a low visible bar so the plate
-- is still displayed (bar hides completely at pct=0).
return 8, 0
else
-- Mob targets an outsider, or we can't see its target.
-- Show player's own threat only.
if playerPct > 0 then
return playerPct, playerSit
end
-- Fall through: let ThreatEngine / heuristic decide
-- below instead of hiding the bar outright.
end end
end end
@@ -596,13 +576,19 @@ function addon.Threat:GetThreatPercent(unit)
threatPct = 100 threatPct = 100
situation = 3 situation = 3
elseif partyNames[mobTargetName] then elseif partyNames[mobTargetName] then
-- Mob is targeting a PARTY/RAID member. -- Mob is targeting a PARTY/RAID member. The party member
-- That member has aggro - show high threat. -- has aggro, NOT the player. Showing 100% here would
-- This is the KEY fix: previously showed only 60%/30% -- incorrectly light every mob in party combat up as full
-- for mobs targeting group members. Now shows 100% -- aggro on the player. Show a low estimate instead — the
-- because that group member IS the one being attacked. -- player is not in the aggro seat, but the bar must still
threatPct = 100 -- be visible so users know the mob is engaged.
situation = 3 if UnitIsUnit("target", unit) then
threatPct = 20
situation = 0
else
threatPct = 8
situation = 0
end
else else
-- Mob is targeting someone outside the party -- Mob is targeting someone outside the party
-- (another player, a pet, etc.) -- (another player, a pet, etc.)
@@ -647,12 +633,60 @@ function addon.Threat:UpdateUnitThreat(unit)
mobTargetName = mt mobTargetName = mt
end end
-- ------------------------------------------------------------
-- TARGET-SNAP override: if the mob is currently targeting the
-- player, force display to 100% (aggro). Some mobs pick focused
-- / random targets regardless of threat, so the plate should
-- immediately snap to 100% while targeted, and fall back to the
-- real threat % when the mob switches targets.
-- ------------------------------------------------------------
local targetSnapped = false
if RelationshipsThreatPlatesDB and RelationshipsThreatPlatesDB.targetSnap100
and mobTargetName and playerName and mobTargetName == playerName then
local ok_pc, pc = pcall(UnitAffectingCombat, "player")
if ok_pc and pc then
-- Player is being targeted, so they have aggro. Force
-- pct to at least 100 without clobbering a real value
-- above 100 (which happens when the player is top and
-- well ahead of the second-highest threat holder).
if not pct or pct < 100 then
pct = 100
end
situation = 3
targetSnapped = true
end
end
-- ------------------------------------------------------------
-- Compute raw tank-mode details (player threat vs 2nd highest).
-- Uses ThreatEngine's combat-log-parsed absolute values when
-- available. This is what the nameplate renders in Tank Mode.
-- ------------------------------------------------------------
local ok_mn, mobName = pcall(UnitName, unit)
if ok_mn and mobName then
local ePct, eSit, ePlayerThreat, eTopThreat, eHasGroup, eTopPlayer, eSecondThreat =
addon.ThreatEngine:GetThreatOnTarget(mobName)
tankDetailsCache[uid] = {
playerThreat = ePlayerThreat or 0,
topThreat = eTopThreat or 0,
secondThreat = eSecondThreat or 0,
topPlayer = eTopPlayer,
hasAggro = (eTopPlayer == playerName) or targetSnapped,
hasGroupData = eHasGroup or false,
targetSnapped = targetSnapped,
mobTargetName = mobTargetName,
lastUpdate = GetTime(),
}
end
threatCache[uid] = { threatCache[uid] = {
pct = pct, pct = pct,
situation = situation, situation = situation,
unit = unit, unit = unit,
lastUpdate = GetTime(), lastUpdate = GetTime(),
mobTargetName = mobTargetName, mobTargetName = mobTargetName,
targetSnapped = targetSnapped,
} }
-- Also store in group threat cache for cross-reference -- Also store in group threat cache for cross-reference
@@ -681,6 +715,76 @@ function addon.Threat:GetCachedThreat(uid)
return 0, 0 return 0, 0
end end
-- ==================================================================
-- Tank-mode details for a mob (by UID / name in vanilla).
-- Returns a table (or nil) with:
-- playerThreat, topThreat, secondThreat, topPlayer,
-- hasAggro, hasGroupData, targetSnapped, mobTargetName
-- ==================================================================
function addon.Threat:GetTankDetails(uid)
if not uid then return nil end
local d = tankDetailsCache[uid]
if not d then return nil end
if GetTime() - d.lastUpdate > 5 then return nil end
return d
end
-- ==================================================================
-- Format a raw threat value as a compact signed string.
-- Examples: 12345 -> "+12.3K", -845 -> "-845", 0 -> "0"
-- ==================================================================
function addon.Threat:FormatThreatLead(v)
if not v then return "--" end
local a = math.abs(v)
local sign = ""
if v > 0 then sign = "+" elseif v < 0 then sign = "-" end
if a >= 100000 then
return sign .. string.format("%.0fK", a / 1000)
elseif a >= 10000 then
return sign .. string.format("%.1fK", a / 1000)
elseif a >= 1000 then
return sign .. string.format("%.2fK", a / 1000)
else
return sign .. string.format("%d", a)
end
end
-- ==================================================================
-- Return the display string for tank mode given a plate's mob name.
-- Uses the ThreatEngine directly (works even without a unit token,
-- e.g. for unique-name plates).
-- Returns: string like "+1.20K" or "-450", or "--" if unknown.
-- ==================================================================
function addon.Threat:GetTankDisplayForMob(mobName)
if not mobName then return "--" end
local d = tankDetailsCache[mobName]
if not d or (GetTime() - d.lastUpdate > 5) then
-- Query ThreatEngine on demand (no cache hit).
local _, _, playerThreat, topThreat, _, topPlayer, secondThreat =
addon.ThreatEngine:GetThreatOnTarget(mobName)
if not playerThreat or playerThreat <= 0 then
return "--"
end
local lead
if topPlayer == playerName then
lead = playerThreat - (secondThreat or 0)
else
lead = playerThreat - (topThreat or 0)
end
return self:FormatThreatLead(lead)
end
if d.playerThreat <= 0 then return "--" end
local lead
if d.hasAggro then
lead = d.playerThreat - (d.secondThreat or 0)
else
lead = d.playerThreat - (d.topThreat or 0)
end
return self:FormatThreatLead(lead)
end
-- ================================================================== -- ==================================================================
-- Get threat percentage by mob NAME (no unit token required) -- Get threat percentage by mob NAME (no unit token required)
-- This is the fallback for when FindUnitByName couldn't resolve -- This is the fallback for when FindUnitByName couldn't resolve
+215 -7
View File
@@ -94,6 +94,25 @@ local playerInCombat = false
-- members when the player is NOT in combat themselves. -- members when the player is NOT in combat themselves.
local groupInCombat = false local groupInCombat = false
-- ============================================================
-- Multi-mob incoming-attack tracking (v2.6.2)
--
-- Vanilla WoW 1.12 exposes NO unit token for a mob that isn't
-- currently targeted / mouseovered / party-targeted. That meant
-- earlier versions only lit the threat bar on the ONE enemy the
-- player had targeted. To show bars for EVERY enemy in combat with
-- the group, we parse the incoming-combat chat events (mob->player
-- and mob->party) and remember which mob names have hit or missed
-- a group member recently. UpdateAllPlates then treats any plate
-- whose mob name is in this table as "in combat" and displays a
-- threat bar even when its health bar hasn't dropped yet and no
-- unit token is available.
-- ============================================================
local mobsAttackingGroup = {}
local mobsAttackingPlayer = {}
local MOB_ATTACK_TTL = 6.0 -- seconds a mob stays flagged as attacking
local INCOMING_PARSERS = {}
-- ============================================================ -- ============================================================
-- Safe API helpers -- Safe API helpers
-- ============================================================ -- ============================================================
@@ -414,6 +433,67 @@ local function BuildAllParsers()
" party damage, " .. table.getn(PARTY_HEAL_PARSERS) .. " party damage, " .. table.getn(PARTY_HEAL_PARSERS) ..
" party heal, " .. table.getn(PARTY_PERIODIC_PARSERS) .. " party heal, " .. table.getn(PARTY_PERIODIC_PARSERS) ..
" party periodic parsers built") " party periodic parsers built")
-- ========================================
-- INCOMING (mob -> player/party) parsers (v2.6.2)
-- Used to detect that a specific mob NAME is actively engaged
-- with the group even though we have no unit token for it.
-- We only care about the first capture (the mob / source name).
-- Any OTHERSELF or OTHEROTHER GlobalString whose first %s is the
-- attacker qualifies. Some of these constants may not exist on
-- every Turtle/OctoWoW build; missing ones are silently skipped.
-- ========================================
local incomingSources = {
-- Melee mob -> player
"COMBATHITOTHERSELF", "COMBATHITCRITOTHERSELF",
"COMBATHITSCHOOLOTHERSELF", "COMBATHITCRITSCHOOLOTHERSELF",
"MISSEDOTHERSELF",
"VSDODGEOTHERSELF", "VSPARRYOTHERSELF", "VSBLOCKOTHERSELF",
"VSABSORBOTHERSELF", "VSIMMUNEOTHERSELF", "VSRESISTOTHERSELF",
"VSDEFLECTOTHERSELF", "VSEVADEOTHERSELF",
-- Spell mob -> player
"SPELLLOGOTHERSELF", "SPELLLOGSCHOOLOTHERSELF",
"SPELLLOGCRITOTHERSELF", "SPELLLOGCRITSCHOOLOTHERSELF",
"SPELLMISSOTHERSELF", "SPELLDODGEDOTHERSELF",
"SPELLPARRIEDOTHERSELF", "SPELLBLOCKEDOTHERSELF",
"SPELLRESISTOTHERSELF", "SPELLIMMUNEOTHERSELF",
"SPELLEVADEDOTHERSELF", "SPELLDEFLECTEDOTHERSELF",
"SPELLREFLECTOTHERSELF",
-- Periodic (mob DoT ticking on player)
"PERIODICAURADAMAGEOTHERSELF",
-- Melee mob -> other (party member)
"COMBATHITOTHEROTHER", "COMBATHITCRITOTHEROTHER",
"COMBATHITSCHOOLOTHEROTHER", "COMBATHITCRITSCHOOLOTHEROTHER",
"MISSEDOTHEROTHER",
"VSDODGEOTHEROTHER", "VSPARRYOTHEROTHER", "VSBLOCKOTHEROTHER",
"VSABSORBOTHEROTHER", "VSIMMUNEOTHEROTHER", "VSRESISTOTHEROTHER",
"VSDEFLECTOTHEROTHER", "VSEVADEOTHEROTHER",
-- Spell mob -> other (party member)
"SPELLLOGOTHEROTHER", "SPELLLOGSCHOOLOTHEROTHER",
"SPELLLOGCRITOTHEROTHER", "SPELLLOGCRITSCHOOLOTHEROTHER",
"SPELLMISSOTHEROTHER", "SPELLDODGEDOTHEROTHER",
"SPELLPARRIEDOTHEROTHER", "SPELLBLOCKEDOTHEROTHER",
"SPELLRESISTOTHEROTHER", "SPELLIMMUNEOTHEROTHER",
-- Periodic (mob DoT ticking on party member)
"PERIODICAURADAMAGEOTHEROTHER",
}
for i = 1, table.getn(incomingSources) do
local gname = incomingSources[i]
local fs = getglobal(gname)
if fs then
local r = FormatToRegex(fs)
if r then
local toPlayer = false
if string.find(gname, "OTHERSELF") or string.find(gname, "TOSELF") then
toPlayer = true
end
INCOMING_PARSERS[table.getn(INCOMING_PARSERS) + 1] =
{ regex = r, globalVar = gname, toPlayer = toPlayer }
end
end
end
addon:ui_print("Threat engine: " .. table.getn(INCOMING_PARSERS) ..
" incoming (mob->group) parsers built")
end end
-- ============================================================ -- ============================================================
@@ -1137,6 +1217,79 @@ end
-- Event handler for combat log events -- Event handler for combat log events
-- ============================================================ -- ============================================================
-- Mark a mob name as actively attacking someone in the group.
-- Filters out anything that is obviously a player (self or a cached
-- party/raid member class). Unknown names are assumed to be mobs.
local function MarkMobAttackingGroup(name)
if not name or name == "" then return end
if playerName and name == playerName then return end
if partyClassCache and partyClassCache[name] then return end
mobsAttackingGroup[name] = GetTime()
end
-- Same as MarkMobAttackingGroup, but flags the mob as specifically
-- attacking the player. Used to distinguish "attacking me" from
-- "attacking a party member" in the nameplate fallback branches.
local function MarkMobAttackingPlayer(name)
if not name or name == "" then return end
if playerName and name == playerName then return end
if partyClassCache and partyClassCache[name] then return end
mobsAttackingPlayer[name] = GetTime()
end
-- Parse an incoming combat-log message and, if it matches any known
-- mob->group pattern, flag the first capture (the source mob name).
local function ParseIncomingMessage(msg)
if not msg or not INCOMING_PARSERS then return end
for i = 1, table.getn(INCOMING_PARSERS) do
local p = INCOMING_PARSERS[i]
local _, _, c1 = string.find(msg, p.regex)
if c1 then
MarkMobAttackingGroup(c1)
if p.toPlayer then
MarkMobAttackingPlayer(c1)
end
return
end
end
end
-- Public API: is this mob name currently flagged as attacking the group?
function addon.ThreatEngine:IsMobAttackingGroup(mobName)
if not mobName then return false end
local t = mobsAttackingGroup[mobName]
if not t then return false end
if GetTime() - t > MOB_ATTACK_TTL then
mobsAttackingGroup[mobName] = nil
return false
end
return true
end
-- Public API: is this mob name currently flagged as specifically
-- attacking the player (not just any group member)?
function addon.ThreatEngine:IsMobAttackingPlayer(mobName)
if not mobName then return false end
local t = mobsAttackingPlayer[mobName]
if not t then return false end
if GetTime() - t > MOB_ATTACK_TTL then
mobsAttackingPlayer[mobName] = nil
return false
end
return true
end
-- Public API: return the raw table (name -> lastSeen). Caller must not
-- mutate it. Used mainly by debug commands.
function addon.ThreatEngine:GetMobsAttackingGroup()
return mobsAttackingGroup
end
-- Called by the event frame for any CREATURE_VS_* combat log event.
function addon.ThreatEngine:OnIncomingCombat(ev, msg)
ParseIncomingMessage(msg)
end
function addon.ThreatEngine:OnCombatEvent(ev, msg) function addon.ThreatEngine:OnCombatEvent(ev, msg)
if not msg then return end if not msg then return end
if not playerName then return end if not playerName then return end
@@ -1268,34 +1421,61 @@ end
-- ============================================================ -- ============================================================
function addon.ThreatEngine:GetThreatOnTarget(mobName) function addon.ThreatEngine:GetThreatOnTarget(mobName)
if not mobName then return 0, 0, 0, 0, false, nil end if not mobName then return 0, 0, 0, 0, false, nil, 0 end
-- Check if we have threat data for this mob -- Check if we have threat data for this mob
local mobThreat = threatData[mobName] local mobThreat = threatData[mobName]
if not mobThreat then if not mobThreat then
return 0, 0, 0, 0, false, nil return 0, 0, 0, 0, false, nil, 0
end end
-- Find player's threat and top threat -- Find player's threat, top threat and second-highest threat
local playerThreat = mobThreat[playerName] or 0 local playerThreat = mobThreat[playerName] or 0
local topThreat = 0 local topThreat = 0
local topPlayer = nil local topPlayer = nil
local secondThreat = 0
local sourceCount = 0 local sourceCount = 0
for source, value in pairs(mobThreat) do for source, value in pairs(mobThreat) do
sourceCount = sourceCount + 1 sourceCount = sourceCount + 1
if value > topThreat then if value > topThreat then
secondThreat = topThreat
topThreat = value topThreat = value
topPlayer = source topPlayer = source
elseif value > secondThreat then
secondThreat = value
end end
end end
if topThreat <= 0 then if topThreat <= 0 then
return 0, 0, 0, 0, false, nil return 0, 0, 0, 0, false, nil, 0
end end
-- Calculate the player's percentage relative to top threat -- Calculate the player's threat percentage.
local pct = (playerThreat / topThreat) * 100 --
-- Two-mode display so the number matches user intuition:
-- * If the player IS the current top-threat holder, show how far
-- ahead they are of the runner-up: pct = playerThreat/second * 100.
-- A tank comfortably tanking a mob will read something like 120%
-- or 200% — anything > 100 means "you are above the next player".
-- * If the player is NOT top, show their share of the top holder's
-- threat: pct = playerThreat/top * 100. This is 0-100 and answers
-- "how close am I to pulling aggro off the current top holder".
--
-- This lets the plate show > 100% for the actual threat leader
-- instead of clamping everyone with aggro to a flat 100%.
local pct
if topPlayer == playerName then
if secondThreat > 0 then
pct = (playerThreat / secondThreat) * 100
else
pct = 100
end
if pct < 100 then pct = 100 end -- safety: top holder never below 100
else
pct = (playerThreat / topThreat) * 100
if pct > 100 then pct = 100 end -- non-top can't exceed the leader
end
-- Determine threat situation based on the player's threat -- Determine threat situation based on the player's threat
local sit = 0 local sit = 0
@@ -1314,7 +1494,7 @@ function addon.ThreatEngine:GetThreatOnTarget(mobName)
-- (via addon communication or party combat log parsing). -- (via addon communication or party combat log parsing).
local hasGroupData = (sourceCount > 1) local hasGroupData = (sourceCount > 1)
return pct, sit, playerThreat, topThreat, hasGroupData, topPlayer return pct, sit, playerThreat, topThreat, hasGroupData, topPlayer, secondThreat
end end
-- ============================================================ -- ============================================================
@@ -1518,6 +1698,8 @@ end
function addon.ThreatEngine:ClearAll() function addon.ThreatEngine:ClearAll()
threatData = {} threatData = {}
mobsAttackingGroup = {}
mobsAttackingPlayer = {}
end end
-- ============================================================ -- ============================================================
@@ -1782,6 +1964,20 @@ function addon.ThreatEngine:Initialize()
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE") combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE") combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE")
-- Incoming combat: mob attacking player / party. Used by v2.6.2
-- multi-mob threat display to flag mobs that are engaged with
-- the group but for which we have no unit token.
combatFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS")
combatFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES")
combatFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_PARTY_HITS")
combatFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_PARTY_MISSES")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_SELF_MISSES")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_PARTY_MISSES")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_VS_SELF_DAMAGE")
combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_VS_PARTY_DAMAGE")
combatFrame:SetScript("OnEvent", function() combatFrame:SetScript("OnEvent", function()
-- Vanilla 1.12: event name is in global 'event', args in global arg1..arg9 -- Vanilla 1.12: event name is in global 'event', args in global arg1..arg9
-- IMPORTANT: we must NOT shadow 'event' with a local or parameter -- IMPORTANT: we must NOT shadow 'event' with a local or parameter
@@ -1811,6 +2007,18 @@ function addon.ThreatEngine:Initialize()
elseif event == "CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS" elseif event == "CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS"
or event == "CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS" then or event == "CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS" then
addon.ThreatEngine:OnPartyPeriodicEvent(event, arg1) addon.ThreatEngine:OnPartyPeriodicEvent(event, arg1)
-- Incoming combat: mob attacking player / party (v2.6.2)
elseif event == "CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS"
or event == "CHAT_MSG_COMBAT_CREATURE_VS_SELF_MISSES"
or event == "CHAT_MSG_COMBAT_CREATURE_VS_PARTY_HITS"
or event == "CHAT_MSG_COMBAT_CREATURE_VS_PARTY_MISSES"
or event == "CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE"
or event == "CHAT_MSG_SPELL_CREATURE_VS_SELF_MISSES"
or event == "CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE"
or event == "CHAT_MSG_SPELL_CREATURE_VS_PARTY_MISSES"
or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_VS_SELF_DAMAGE"
or event == "CHAT_MSG_SPELL_PERIODIC_CREATURE_VS_PARTY_DAMAGE" then
addon.ThreatEngine:OnIncomingCombat(event, arg1)
end end
-- Handle non-combat events -- Handle non-combat events