From 28ef2ed7c9e609728c6854884e1716e28ee01f66 Mon Sep 17 00:00:00 2001 From: Relationship <139162359+Relationship-OctoWoW@users.noreply.github.com> Date: Sun, 12 Jul 2026 14:57:27 +0100 Subject: [PATCH] Add files via upload --- RelationshipsThreatPlates.toc | 4 +- RelationshipsThreatPlates_Core.lua | 15 +- RelationshipsThreatPlates_GUI.lua | 127 +++++++- RelationshipsThreatPlates_Media.lua | 56 +++- RelationshipsThreatPlates_Nameplates.lua | 330 +++++++++++++++------ RelationshipsThreatPlates_Threat.lua | 13 +- RelationshipsThreatPlates_ThreatEngine.lua | 181 +++++++++++ 7 files changed, 621 insertions(+), 105 deletions(-) diff --git a/RelationshipsThreatPlates.toc b/RelationshipsThreatPlates.toc index 1900028..b4ff863 100644 --- a/RelationshipsThreatPlates.toc +++ b/RelationshipsThreatPlates.toc @@ -1,8 +1,8 @@ ## Interface: 11200 ## Title: RelationshipsThreatPlates - OctoWoW ## Notes: OctoWoW-compatible RelationshipsThreatPlates -## Author: Packaged By Relationship -## Version: 1.3 +## Author: Relationship +## Version: 1.4 ## SavedVariables: RelationshipsThreatPlatesDB ## X-Website: https://github.com/Relationship-OctoWoW/RelationshipsThreatPlates ## X-GitHub: https://github.com/Relationship-OctoWoW/RelationshipsThreatPlates diff --git a/RelationshipsThreatPlates_Core.lua b/RelationshipsThreatPlates_Core.lua index 86cd225..18bef95 100644 --- a/RelationshipsThreatPlates_Core.lua +++ b/RelationshipsThreatPlates_Core.lua @@ -12,7 +12,7 @@ addon.MAJOR = 1 addon.MINOR = 1 -- Version string -TP_VERSION = "2.5.0" +TP_VERSION = "2.6.1" TP_ADDON_NAME = "RelationshipsThreatPlates" -- ================================================================== @@ -40,6 +40,19 @@ local DEFAULT_CONFIG = { glowEnabled = false, glowSize = 3, 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 = { point = "CENTER", relPoint = "CENTER", diff --git a/RelationshipsThreatPlates_GUI.lua b/RelationshipsThreatPlates_GUI.lua index 390aae5..382bc96 100644 --- a/RelationshipsThreatPlates_GUI.lua +++ b/RelationshipsThreatPlates_GUI.lua @@ -35,7 +35,7 @@ local C = { -- Layout local GUI_WIDTH = 320 -local GUI_HEIGHT = 480 +local GUI_HEIGHT = 520 local HEADER_H = 30 local MARGIN = 14 local FONT_NORMAL = "Fonts\\FRIZQT__.TTF" @@ -428,6 +428,80 @@ function addon.GUI:BuildSettings(parent) widgets[configKey] = slider 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 -- ============================================================== @@ -469,6 +543,57 @@ function addon.GUI:BuildSettings(parent) CheckBox("Show on Neutral", "showOnNeutral", "Show threat bars on neutral 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 -- ============================================================== diff --git a/RelationshipsThreatPlates_Media.lua b/RelationshipsThreatPlates_Media.lua index 350f65f..0cda5b8 100644 --- a/RelationshipsThreatPlates_Media.lua +++ b/RelationshipsThreatPlates_Media.lua @@ -69,6 +69,37 @@ function addon.Media.Brighten(factor, r, g, b, a) a 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 -- ================================================================== @@ -76,25 +107,28 @@ function addon.Media.GetThreatColour(pct) pct = tonumber(pct) or 0 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 if pct < 50 then local t = pct / 50 - r = c.THREAT_LOW.r + (c.THREAT_MEDIUM.r - c.THREAT_LOW.r) * t - g = c.THREAT_LOW.g + (c.THREAT_MEDIUM.g - c.THREAT_LOW.g) * t - b = c.THREAT_LOW.b + (c.THREAT_MEDIUM.b - c.THREAT_LOW.b) * t + r = lo[1] + (md[1] - lo[1]) * t + g = lo[2] + (md[2] - lo[2]) * t + b = lo[3] + (md[3] - lo[3]) * t elseif pct < 80 then local t = (pct - 50) / 30 - r = c.THREAT_MEDIUM.r + (c.THREAT_HIGH.r - c.THREAT_MEDIUM.r) * t - g = c.THREAT_MEDIUM.g + (c.THREAT_HIGH.g - c.THREAT_MEDIUM.g) * t - b = c.THREAT_MEDIUM.b + (c.THREAT_HIGH.b - c.THREAT_MEDIUM.b) * t + r = md[1] + (hi[1] - md[1]) * t + g = md[2] + (hi[2] - md[2]) * t + b = md[3] + (hi[3] - md[3]) * t else local t = (pct - 80) / 20 - t = math.min(1, math.max(0, t)) - r = c.THREAT_HIGH.r + (c.THREAT_AGGRO.r - c.THREAT_HIGH.r) * t - g = c.THREAT_HIGH.g + (c.THREAT_AGGRO.g - c.THREAT_HIGH.g) * t - b = c.THREAT_HIGH.b + (c.THREAT_AGGRO.b - c.THREAT_HIGH.b) * t + if t > 1 then t = 1 end + if t < 0 then t = 0 end + r = hi[1] + (ag[1] - hi[1]) * t + g = hi[2] + (ag[2] - hi[2]) * t + b = hi[3] + (ag[3] - hi[3]) * t end return r, g, b diff --git a/RelationshipsThreatPlates_Nameplates.lua b/RelationshipsThreatPlates_Nameplates.lua index 90aeb4d..7e229d8 100644 --- a/RelationshipsThreatPlates_Nameplates.lua +++ b/RelationshipsThreatPlates_Nameplates.lua @@ -890,8 +890,31 @@ function addon.Nameplates:ApplyThreatToPlate(data, pct, situation) -- Now we only check if there's actual threat data to show. The combat -- verification is done earlier in the pipeline (IsPlateInCombat, -- 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 data.container:Hide() + if addon.Nameplates.SetFlashing then addon.Nameplates:SetFlashing(data, false) end return end @@ -961,24 +984,30 @@ function addon.Nameplates:ApplyThreatToPlate(data, pct, situation) -- Update text — uses rawPct so values above 100% are shown as-is. if data.text then + local fmt = RelationshipsThreatPlatesDB.textFormat or "percent" + 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 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 + 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 = math.floor(rawPct + 0.5) .. "%" + textStr = pctStr 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) @@ -987,6 +1016,24 @@ function addon.Nameplates:ApplyThreatToPlate(data, pct, situation) end end + -- 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 barPct >= 80 then @@ -1058,7 +1105,7 @@ end -- -- 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 strlen(data.plateName) == 0 then return nil end @@ -1076,25 +1123,67 @@ function addon.Nameplates:FindCombatUnitForPlate(data) -- Get plate health percentage for matching 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: -- 1. In combat (mandatory — idle mobs must never match) -- 2. Health % close to the plate's health bar -- 3. Hostile and attackable + -- 4. Not already assigned to a different plate this cycle 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 + -- Skip candidates already claimed by another plate this cycle + local excluded = false + 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 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)) + if diff <= 8 then + 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 -- Hostile/attackable bonus @@ -1102,7 +1191,7 @@ function addon.Nameplates:FindCombatUnitForPlate(data) score = score + 50 end - if score > bestScore then + if score > bestScore and healthMatched then bestScore = score bestUnit = c.unit end @@ -1141,6 +1230,23 @@ end -- - Mobs attacking party members show threat even when player -- 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() if not RelationshipsThreatPlatesDB or not RelationshipsThreatPlatesDB.enabled then for nameplate, data in pairs(self.plates) do @@ -1183,6 +1289,16 @@ function addon.Nameplates:UpdateAllPlates() end -- 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 if nameplate:IsVisible() then local isUniqueName = (platesPerName[data.plateName] or 1) <= 1 @@ -1191,6 +1307,21 @@ function addon.Nameplates:UpdateAllPlates() local plateInCombat = IsPlateInCombat(data) 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 -- We have a resolved unit token. Verify it's in combat. local ok_combat, mobInCombat = pcall(UnitAffectingCombat, data.unit) @@ -1203,91 +1334,67 @@ function addon.Nameplates:UpdateAllPlates() 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. + -- UNIQUE-NAME MOB with no unit token. + -- + -- CAUTION: "unique" here only means one plate of this + -- 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 - -- Mob is in combat (health dropping or below 100%). - -- Record when we first detected combat for grace period. + -- This specific plate's health is dropping — it's + -- in combat. Safe to trust name-keyed threat data. 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 + do local _fp, _fs = GetFallbackThreatForMob(data.plateName); self:ApplyThreatToPlate(data, _fp, _fs) end 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. + elseif groupInCombat and (mobAttackingGroup or hasThreatData) then + -- Name-only signal says a mob of this name is + -- attacking the group, but THIS plate shows no + -- health drop. Verify by matching a real combat + -- unit token to this plate (health % match). If + -- none matches, this plate is an idle bystander + -- (e.g. the real attacker rotated off-screen). + local unit = self:FindCombatUnitForPlate(data, assignedUnits) + if unit then + data.unit = unit + data.guid = addon:GetUnitID(unit) + assignedUnits[table.getn(assignedUnits) + 1] = unit 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 + 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 - -- Can't confirm mob is attacking anyone. - -- It might be idle. Don't show bar. + -- Can't verify this plate is the attacker. + -- Do NOT show a bar based on name alone. data.combatDetectedAt = nil data.container:Hide() end 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.container:Hide() end @@ -1303,10 +1410,11 @@ function addon.Nameplates:UpdateAllPlates() end -- Try to find a combat-verified unit with matching health. - local unit = self:FindCombatUnitForPlate(data) + local unit = self:FindCombatUnitForPlate(data, assignedUnits) if unit then data.unit = unit data.guid = addon:GetUnitID(unit) + assignedUnits[table.getn(assignedUnits) + 1] = unit local pct, situation = addon.Threat:GetThreatPercent(unit) self:ApplyThreatToPlate(data, pct, situation or 0) elseif hasThreatData then @@ -1321,7 +1429,7 @@ function addon.Nameplates:UpdateAllPlates() -- 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 + do local _fp, _fs = GetFallbackThreatForMob(data.plateName); self:ApplyThreatToPlate(data, _fp, _fs) end else data.container:Hide() end @@ -1330,7 +1438,7 @@ function addon.Nameplates:UpdateAllPlates() -- 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 + do local _fp, _fs = GetFallbackThreatForMob(data.plateName); self:ApplyThreatToPlate(data, _fp, _fs) end else data.container:Hide() end @@ -1340,11 +1448,12 @@ function addon.Nameplates:UpdateAllPlates() -- 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) + local unit = self:FindCombatUnitForPlate(data, assignedUnits) if unit then -- Found a combat-verified unit for this plate. data.unit = unit data.guid = addon:GetUnitID(unit) + assignedUnits[table.getn(assignedUnits) + 1] = unit local pct, situation = addon.Threat:GetThreatPercent(unit) if pct > 0 then if not data.combatDetectedAt then @@ -1413,3 +1522,52 @@ function addon.Nameplates:RecreateAll() knownPlates = {} self:ScanForNameplates() 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 diff --git a/RelationshipsThreatPlates_Threat.lua b/RelationshipsThreatPlates_Threat.lua index 0852d1e..d5def62 100644 --- a/RelationshipsThreatPlates_Threat.lua +++ b/RelationshipsThreatPlates_Threat.lua @@ -439,14 +439,18 @@ function addon.Threat:GetThreatPercent(unit) if playerPct > 0 then return playerPct, playerSit end - return 0, 0 + -- 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 - return 0, 0 + -- Fall through: let ThreatEngine / heuristic decide + -- below instead of hiding the bar outright. end end @@ -576,12 +580,13 @@ function addon.Threat:GetThreatPercent(unit) -- has aggro, NOT the player. Showing 100% here would -- incorrectly light every mob in party combat up as full -- aggro on the player. Show a low estimate instead — the - -- player is not in the aggro seat. + -- player is not in the aggro seat, but the bar must still + -- be visible so users know the mob is engaged. if UnitIsUnit("target", unit) then threatPct = 20 situation = 0 else - threatPct = 0 + threatPct = 8 situation = 0 end else diff --git a/RelationshipsThreatPlates_ThreatEngine.lua b/RelationshipsThreatPlates_ThreatEngine.lua index ef36777..9bcbaf1 100644 --- a/RelationshipsThreatPlates_ThreatEngine.lua +++ b/RelationshipsThreatPlates_ThreatEngine.lua @@ -94,6 +94,25 @@ local playerInCombat = false -- members when the player is NOT in combat themselves. 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 -- ============================================================ @@ -414,6 +433,67 @@ local function BuildAllParsers() " party damage, " .. table.getn(PARTY_HEAL_PARSERS) .. " party heal, " .. table.getn(PARTY_PERIODIC_PARSERS) .. " 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 -- ============================================================ @@ -1137,6 +1217,79 @@ end -- 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) if not msg then return end if not playerName then return end @@ -1545,6 +1698,8 @@ end function addon.ThreatEngine:ClearAll() threatData = {} + mobsAttackingGroup = {} + mobsAttackingPlayer = {} end -- ============================================================ @@ -1809,6 +1964,20 @@ function addon.ThreatEngine:Initialize() combatFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_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() -- 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 @@ -1838,6 +2007,18 @@ function addon.ThreatEngine:Initialize() elseif event == "CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS" or event == "CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS" then 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 -- Handle non-combat events