From 12c8c55159db4045a4690d2c46d924fc996b9a6d Mon Sep 17 00:00:00 2001 From: rucky Date: Wed, 25 Mar 2026 00:57:35 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B0=83=E6=95=B4dps=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E5=AF=B9=E4=BB=87=E6=81=A8=E7=9A=84=E4=BC=B0=E7=AE=97=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=20=E4=BC=98=E5=8C=96dps=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/mcp.json | 13 + .cursor/rules/mcp-messenger.mdc | 27 + Core.lua | 6 +- DataStore.lua | 7 + Locale.lua | 32 ++ Modules/ThreatEstimate.lua | 135 ++++- Nanami-DPS.toc | 3 + NanamiPlates-ThreatAPI.md | 234 ++++++++ NanamiPlates-ThreatIntegration.md | 221 ++++++++ Options.lua | 10 + Parser.lua | 184 ++----- ParserVanilla.lua | 48 ++ ThreatCoefficients.lua | 264 +++++++++ ThreatDisplay.lua | 384 +++++++++++++ ThreatEngine.lua | 863 ++++++++++++++++++++++++++++++ Window.lua | 188 ++++++- 16 files changed, 2454 insertions(+), 165 deletions(-) create mode 100644 .cursor/mcp.json create mode 100644 .cursor/rules/mcp-messenger.mdc create mode 100644 NanamiPlates-ThreatAPI.md create mode 100644 NanamiPlates-ThreatIntegration.md create mode 100644 ThreatCoefficients.lua create mode 100644 ThreatDisplay.lua create mode 100644 ThreatEngine.lua diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..e2705f4 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "CursorMCP对话插件": { + "command": "node", + "args": [ + "c:\\Users\\rucky\\.cursor\\extensions\\local.xw-cursor-message-8.8.8\\dist\\mcp-server.mjs" + ], + "env": { + "MESSENGER_DATA_DIR": "C:\\Users\\rucky\\.moyu-message\\c330d32acecf" + } + } + } +} \ No newline at end of file diff --git a/.cursor/rules/mcp-messenger.mdc b/.cursor/rules/mcp-messenger.mdc new file mode 100644 index 0000000..9745167 --- /dev/null +++ b/.cursor/rules/mcp-messenger.mdc @@ -0,0 +1,27 @@ +--- +description: "自动调用 Messenger MCP 检查待发送消息" +globs: "**/*" +alwaysApply: true +--- + +# MCP 工具调用规则 + +1. 每轮对话回复**完整输出后**,最后一步必须调用 `check_messages`,适用于 Agent/Plan/Debug/Ask 所有模式,无一例外 +2. `check_messages` 会持续等待直到用户发来新消息;收到后处理并回复,然后再次调用 `check_messages`,形成永续循环:回复 → check_messages → 收到消息 → 回复 → check_messages +3. 严禁在回合开始时或回复未完成时调用 `check_messages`;唯一豁免:用户明确要求结束对话 +4. Plan 模式写完方案后、Debug 模式分析完问题后、Ask 模式回答完问题后,均不得直接结束对话,必须调用 `check_messages` 等待用户反馈 +5. 需要用户做选择或确认时(选方案、选框架、确认操作等),必须调用 `ask_question`;严禁使用 Cursor 内置的 ask_user_question +6. `ask_question` 参数格式: `{ "questions": [{ "question": "问题文本", "options": [{ "id": "a", "label": "选项A" }, { "id": "b", "label": "选项B" }], "allow_multiple": false }] }` +7. 单选: `allow_multiple: false`(用户只能选一个);多选: `allow_multiple: true`(用户可选多个);同一 `questions` 数组可混合包含单选题和多选题 +8. 用户在每道题中均可额外输入自定义补充文本(Other 输入框),无需为此添加额外选项 +9. `ask_question` 收到用户回答后,处理完毕仍需调用 `check_messages` 继续监听 +10. 禁止在每轮结束时用 `ask_question` 问"还有别的需要吗",回合结束只用 `check_messages`;`ask_question` 仅限任务中确实需要用户决策时使用 +11. `check_messages` 支持可选参数 `reply`(string,Markdown 格式):调用时传入本轮回复的摘要/结论,插件界面会弹出模态框展示给用户。格式: `{ "reply": "## 结论\n修改完成..." }` +12. 当会话结束前,建议在调用 `check_messages` 时附带 `reply` 参数,将关键结论推送到插件界面;如果本轮无需特别总结,`reply` 可省略 +13. 禁止调用subagent并发任务 +14. 如果 `check_messages` 或 `ask_question` 因客户端取消、连接抖动或内部异常而意外返回/报错,且用户未明确要求结束对话,严禁把这类内部提示直接输出给用户,必须立刻再次调用对应工具继续等待 +15. 执行多步任务时,每完成一个关键步骤后,必须调用 `send_progress` 推送进度摘要到远程控制台。格式: `{ "progress": "## 步骤1完成\n已修改xxx文件..." }`。`send_progress` 立即返回,不会等待消息,调用后继续执行下一步 +16. `send_progress` 的 `progress` 参数应包含:已完成的操作、当前方案、下一步计划。使用简洁的 Markdown 格式 + +--- +**回合结束(强制)**: 仅当回复**完整输出完毕后**,最后一步必须调用 CursorMCP对话插件 MCP 的 `check_messages` 工具(使用 CallMcpTool,server 为 CursorMCP对话插件 对应的 identifier,toolName 为 "check_messages",arguments 为 {})。严禁在回合开始或回复完成前调用。`ask_question` 仅用于任务中需要用户做选择时(如选框架、选方案)。 diff --git a/Core.lua b/Core.lua index 234b8d9..4758249 100644 --- a/Core.lua +++ b/Core.lua @@ -18,9 +18,13 @@ local defaultConfig = { mergePets = true, visible = true, locked = false, - maxSegments = 10, + maxSegments = 20, backdropAlpha = 0.92, showClassIcons = true, + paused = false, + pausedAlpha = 0.35, + otWarning = false, + nameplateThreat = false, } function NanamiDPS:RegisterModule(name, mod) diff --git a/DataStore.lua b/DataStore.lua index 52d33cf..ae41965 100644 --- a/DataStore.lua +++ b/DataStore.lua @@ -196,9 +196,16 @@ function DataStore:AddThreat(source, amount) d[source] = { _sum = 0 } end d[source]._sum = d[source]._sum + amount + if d[source]._sum < 0 then d[source]._sum = 0 end end end +function DataStore:GetPlayerThreat(source) + if not source then return 0 end + local d = self.current.data.threat[source] + return d and d._sum or 0 +end + function DataStore:UpdateActivity(source, timestamp) if not source then return end local segs = { self.current, self.total } diff --git a/Locale.lua b/Locale.lua index 248ec49..257cdc7 100644 --- a/Locale.lua +++ b/Locale.lua @@ -107,6 +107,22 @@ L["Enter Whisper Target"] = "Enter player name" L["Owner"] = "Owner" L["Threat Note"] = "Includes spell-specific threat modifiers" L["Drag to Resize"] = "Drag to resize" +L["Threat"] = "Threat" +L["TPS"] = "TPS" +L["Has Aggro"] = "Has Aggro" +L["Tank"] = "Tank" +L["Tank Threat"] = "Tank Threat" +L["OT Threshold"] = "OT Threshold" +L["OT Buffer"] = "OT Buffer" +L["Threat Source API"] = "Data source: Server API (precise)" +L["Threat Source Local"] = "Data source: Local estimation" +L["OT Warning Critical"] = "!! PULLING AGGRO !!" +L["OT Warning Danger"] = "Threat High - Slow Down!" +L["Pause"] = "Pause" +L["Resume"] = "Resume" +L["Paused"] = "Paused" +L["OT Warning"] = "OT Aggro Warning" +L["Nameplate Threat"] = "Nameplate Threat %" if locale == "zhCN" or locale == "zhTW" then L["Damage Done"] = "造成伤害" @@ -213,4 +229,20 @@ if locale == "zhCN" or locale == "zhTW" then L["Owner"] = "主人" L["Threat Note"] = "已计入技能仇恨系数" L["Drag to Resize"] = "拖拽调整大小" + L["Threat"] = "仇恨值" + L["TPS"] = "每秒仇恨" + L["Has Aggro"] = "持有仇恨" + L["Tank"] = "坦克" + L["Tank Threat"] = "坦克仇恨" + L["OT Threshold"] = "OT阈值" + L["OT Buffer"] = "OT缓冲" + L["Threat Source API"] = "数据来源: 服务器API (精确)" + L["Threat Source Local"] = "数据来源: 本地估算" + L["OT Warning Critical"] = "!! 即将夺取仇恨 !!" + L["OT Warning Danger"] = "仇恨过高 - 减速输出!" + L["Pause"] = "暂停统计" + L["Resume"] = "恢复统计" + L["Paused"] = "已暂停" + L["OT Warning"] = "仇恨超标警报" + L["Nameplate Threat"] = "姓名板仇恨百分比" end diff --git a/Modules/ThreatEstimate.lua b/Modules/ThreatEstimate.lua index 61b12b2..2b33cb5 100644 --- a/Modules/ThreatEstimate.lua +++ b/Modules/ThreatEstimate.lua @@ -8,6 +8,9 @@ function ThreatEstimate:GetName() return L["Threat (Est.)"] end +------------------------------------------------------------------------------- +-- Resolve pet owner for display +------------------------------------------------------------------------------- local function ResolvePetOwner(name) local stored = DataStore:GetClass(name) if stored and not NanamiDPS.validClasses[stored] and stored ~= "__other__" then @@ -19,10 +22,84 @@ local function ResolvePetOwner(name) return nil, nil end +------------------------------------------------------------------------------- +-- Color helpers for OT danger level +------------------------------------------------------------------------------- +local function GetThreatColor(pct) + if pct >= 80 then + local f = math.min((pct - 80) / 20, 1.0) + return 1.0, 0.2 * (1 - f), 0 + elseif pct >= 50 then + local f = (pct - 50) / 30 + return 1.0, 1.0 - 0.6 * f, 0 + else + local f = pct / 50 + return 0.2 + 0.8 * f, 1.0, 0.2 * (1 - f) + end +end + +------------------------------------------------------------------------------- +-- GetBars: Primary bar data provider for the module system +-- Prefers ThreatEngine live data (Track 1 API or Track 2 local), +-- falls back to legacy DataStore accumulation if engine has no data. +------------------------------------------------------------------------------- function ThreatEstimate:GetBars(segment) + local bars = {} + local TE = NanamiDPS.ThreatEngine + local TC = NanamiDPS.ThreatCoefficients + + local targetKey = TE and TE:GetActiveTargetKey() or nil + local threatList = targetKey and TE:GetThreatList(targetKey) or nil + + if threatList and table.getn(threatList) > 0 then + local otStatus = TE:GetOTStatus(targetKey) + local tankThreat = otStatus and otStatus.tankThreat or 0 + + for _, entry in pairs(threatList) do + local class = DataStore:GetClass(entry.name) + local r, g, b = NanamiDPS.GetClassColor(class) + local displayName = entry.name + local ownerName, ownerClass = ResolvePetOwner(entry.name) + + if ownerName and ownerClass then + r, g, b = NanamiDPS.GetClassColor(ownerClass) + r, g, b = r * 0.7, g * 0.7, b * 0.7 + displayName = entry.name .. " <" .. ownerName .. ">" + elseif not NanamiDPS.validClasses[class] and class ~= nil then + r, g, b = NanamiDPS.str2rgb(entry.name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + local suffix = "" + if entry.isTanking then + suffix = " [T]" + end + + local tpsStr = "" + if entry.tps and entry.tps > 0 then + tpsStr = " (" .. NanamiDPS.formatNumber(entry.tps) .. " TPS)" + end + + table.insert(bars, { + id = entry.name, + name = displayName .. suffix, + value = entry.threat, + valueText = NanamiDPS.formatNumber(entry.threat) .. tpsStr, + class = ownerClass or class, + r = r, g = g, b = b, + percent = entry.relativePercent or 0, + isTanking = entry.isTanking, + threatPct = entry.perc or 0, + }) + end + + return bars + end + if not segment or not segment.data or not segment.data.threat then return {} end - local bars = {} for name, entry in pairs(segment.data.threat) do local class = DataStore:GetClass(name) local r, g, b = NanamiDPS.GetClassColor(class) @@ -30,7 +107,7 @@ function ThreatEstimate:GetBars(segment) local ownerName, ownerClass = ResolvePetOwner(name) if NanamiDPS.validClasses[class] then - -- player: use class color as-is + -- use class color as-is elseif ownerName and ownerClass then r, g, b = NanamiDPS.GetClassColor(ownerClass) r, g, b = r * 0.7, g * 0.7, b * 0.7 @@ -61,7 +138,58 @@ function ThreatEstimate:GetBars(segment) return bars end +------------------------------------------------------------------------------- +-- Tooltip: show detailed threat breakdown +------------------------------------------------------------------------------- function ThreatEstimate:GetTooltip(playerName, segment, tooltip) + local TE = NanamiDPS.ThreatEngine + local targetKey = TE and TE:GetActiveTargetKey() or nil + + if targetKey then + local td = TE.targets[targetKey] + if td and td.players[playerName] then + local pd = td.players[playerName] + tooltip:AddLine("|cffffd100" .. playerName) + tooltip:AddDoubleLine("|cffffffff" .. L["Threat"], "|cffffffff" .. NanamiDPS.formatNumber(pd.threat)) + if pd.tps and pd.tps > 0 then + tooltip:AddDoubleLine("|cffffffff" .. L["TPS"], "|cffffffff" .. NanamiDPS.formatNumber(pd.tps)) + end + if pd.isTanking then + tooltip:AddLine("|cff00ff00" .. L["Has Aggro"]) + end + + local otStatus = TE:GetOTStatus(targetKey) + if otStatus and otStatus.tankName then + tooltip:AddLine(" ") + tooltip:AddDoubleLine("|cffffffff" .. L["Tank"], "|cffffffff" .. otStatus.tankName) + tooltip:AddDoubleLine("|cffffffff" .. L["Tank Threat"], "|cffffffff" .. NanamiDPS.formatNumber(otStatus.tankThreat)) + + local threshold = pd.isMelee and "110%" or "130%" + tooltip:AddDoubleLine("|cffffffff" .. L["OT Threshold"], "|cffffffff" .. threshold) + + if not pd.isTanking then + local myOT = otStatus.tankThreat * (pd.isMelee and 1.1 or 1.3) + local buffer = myOT - pd.threat + local pct = myOT > 0 and (pd.threat / myOT * 100) or 0 + local pr, pg, pb = GetThreatColor(pct) + tooltip:AddDoubleLine( + "|cffffffff" .. L["OT Buffer"], + string.format("|cff%02x%02x%02x%s (%.0f%%)", + pr * 255, pg * 255, pb * 255, + NanamiDPS.formatNumber(buffer), pct)) + end + end + + tooltip:AddLine(" ") + if td.source == "api" then + tooltip:AddLine("|cff00ff00" .. L["Threat Source API"]) + else + tooltip:AddLine("|cffaaaaaa" .. L["Threat Source Local"]) + end + return + end + end + if not segment or not segment.data.threat[playerName] then return end local entry = segment.data.threat[playerName] local ownerName, ownerClass = ResolvePetOwner(playerName) @@ -95,6 +223,9 @@ function ThreatEstimate:GetTooltip(playerName, segment, tooltip) end end +------------------------------------------------------------------------------- +-- Report +------------------------------------------------------------------------------- function ThreatEstimate:GetReportLines(segment, count) local bars = self:GetBars(segment) local lines = {} diff --git a/Nanami-DPS.toc b/Nanami-DPS.toc index c1827e6..83d0d6d 100644 --- a/Nanami-DPS.toc +++ b/Nanami-DPS.toc @@ -11,6 +11,8 @@ Core.lua Locale.lua Utils.lua DataStore.lua +ThreatCoefficients.lua +ThreatEngine.lua Parser.lua ParserVanilla.lua Modules\DamageDone.lua @@ -27,6 +29,7 @@ Modules\Activity.lua Modules\EnemyDamageDone.lua Modules\DamageBySpell.lua Modules\HealingBySpell.lua +ThreatDisplay.lua BarDisplay.lua Tooltip.lua Window.lua diff --git a/NanamiPlates-ThreatAPI.md b/NanamiPlates-ThreatAPI.md new file mode 100644 index 0000000..52e28f7 --- /dev/null +++ b/NanamiPlates-ThreatAPI.md @@ -0,0 +1,234 @@ +# Nanami-Plates 姓名板仇恨查询 API 文档 + +## 快速开始 + +```lua +-- 检查 Nanami-DPS 是否加载 +local TE = NanamiDPS and NanamiDPS.ThreatEngine +if not TE then return end +``` + +--- + +## API 1: QueryUnitThreat(unitID) + +查询指定单位的仇恨信息(以当前玩家视角)。 + +### 签名 + +```lua +local data = NanamiDPS.ThreatEngine:QueryUnitThreat(unitID) +``` + +### 参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `unitID` | `string` | WoW 单位 ID,如 `"target"`, `"mouseover"`, `"nameplate1"` 等 | + +### 返回值 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `pct` | `number` | 当前玩家的仇恨百分比 (0-100,相对于最高仇恨者) | +| `threat` | `number` | 当前玩家的绝对仇恨值 | +| `tankName` | `string` | 当前坦克(最高仇恨者)名称 | +| `tankThreat` | `number` | 坦克的绝对仇恨值 | +| `isTanking` | `boolean` | 当前玩家是否持有仇恨 | +| `isMelee` | `boolean` | 当前玩家是否在近战范围 | +| `source` | `string` | 数据来源: `"api"` (服务端精确) / `"local"` (本地估算) | +| `secondName` | `string` | 仇恨第二名的名称 | +| `secondPct` | `number` | 第二名的仇恨百分比 | +| `secondThreat` | `number` | 第二名的绝对仇恨值 | + +如果非战斗状态、单位不存在或无仇恨数据,返回 `nil`。 + +### 用法示例 + +```lua +local function GetNameplateThreatColor(unitID) + local TE = NanamiDPS and NanamiDPS.ThreatEngine + if not TE then return nil end + + local data = TE:QueryUnitThreat(unitID) + if not data then return nil end + + local pct = data.pct + if pct >= 80 then + return 1, 0.2, 0 -- 红色: 即将 OT + elseif pct >= 50 then + return 1, 1, 0 -- 黄色: 警戒 + else + return 0.2, 1, 0.2 -- 绿色: 安全 + end +end +``` + +--- + +## API 2: QueryNameThreat(targetKey, playerName) + +查询指定玩家在指定目标上的仇恨值。用于需要查看任意玩家(而非自己)仇恨的场景。 + +### 签名 + +```lua +local threat, isTanking, pct = NanamiDPS.ThreatEngine:QueryNameThreat(targetKey, playerName) +``` + +### 参数 + +| 参数 | 类型 | 说明 | +|------|------|------| +| `targetKey` | `string` | 目标唯一键,由 `GetTargetKey(unitID)` 生成 | +| `playerName` | `string` | 玩家名称 | + +### 返回值 + +| 返回 | 类型 | 说明 | +|------|------|------| +| `threat` | `number` | 该玩家的绝对仇恨值 | +| `isTanking` | `boolean` | 该玩家是否持有仇恨 | +| `pct` | `number` | 百分比 (0-100) | + +--- + +## API 3: GetTargetKey(unitID) + +生成目标的唯一标识键。 + +```lua +local key = NanamiDPS.ThreatEngine.GetTargetKey("target") +``` + +键格式: +- `"G:"` — 当 UnitGUID 可用时(最准确) +- `"I:"` — 当目标有团队标记时 +- `"V::"` — 通过名称+最大生命值虚拟标识 + +--- + +## API 4: GetThreatList(targetKey) + +获取指定目标的完整仇恨排行榜。 + +```lua +local list = NanamiDPS.ThreatEngine:GetThreatList(targetKey) +``` + +返回按仇恨降序排列的数组,每项: + +```lua +{ + name = "PlayerName", + threat = 12345, + tps = 500.5, + perc = 85.3, + isTanking = false, + isMelee = true, + relativePercent = 85.3, +} +``` + +--- + +## API 5: GetOTStatus(targetKey) + +获取当前玩家的 OT 风险分析。 + +```lua +local status = NanamiDPS.ThreatEngine:GetOTStatus(targetKey) +``` + +返回: + +```lua +{ + safe = false, -- 是否安全 + pct = 92.5, -- OT 进度百分比 + threshold = 1.3, -- OT 阈值倍数 (1.1 近战 / 1.3 远程) + otPoint = 65000, -- 触发 OT 的绝对仇恨值 + buffer = 3750, -- 距离 OT 的缓冲值 + myThreat = 61250, -- 当前玩家仇恨 + tankThreat = 50000, -- 坦克仇恨 + tankName = "TankPlayer", + isMelee = false, +} +``` + +--- + +## 回调注册 + +```lua +-- 仇恨数据更新时触发 +NanamiDPS:RegisterCallback("threat_update", "MyAddon", function() + -- 在此刷新所有姓名板的仇恨显示 +end) +``` + +触发频率: 约每 0.5 秒一次(API 报文到达或本地计算完成时)。 + +--- + +## 姓名板集成完整示例 + +```lua +-- 在姓名板的 OnUpdate 或 threat_update 回调中: + +local function RefreshNameplateThreat(frame, unitID) + local TE = NanamiDPS and NanamiDPS.ThreatEngine + if not TE or not TE.inCombat then + frame.threatText:SetText("") + return + end + + local data = TE:QueryUnitThreat(unitID) + if not data then + frame.threatText:SetText("") + return + end + + -- 显示自己的仇恨百分比 + local pct = data.pct + local r, g, b = 0.2, 1.0, 0.2 + if pct >= 80 then + r, g, b = 1.0, 0.2, 0.0 + elseif pct >= 50 then + r, g, b = 1.0, 1.0, 0.0 + end + + frame.threatText:SetTextColor(r, g, b) + frame.threatText:SetText(string.format("%.0f%%", pct)) + + -- Tank Mode: 显示第二名信息 + if data.isTanking and data.secondName then + frame.secondText:SetText(data.secondName .. " " .. + string.format("%.0f%%", data.secondPct)) + if data.secondPct >= 80 then + frame.secondText:SetTextColor(1, 0.2, 0) + else + frame.secondText:SetTextColor(0.7, 0.7, 0.7) + end + else + frame.secondText:SetText("") + end + + -- 数据来源指示 + if data.source == "api" then + frame.sourceIndicator:SetVertexColor(0, 1, 0) -- 绿色 = 精确 + else + frame.sourceIndicator:SetVertexColor(0.5, 0.5, 0.5) -- 灰色 = 估算 + end +end +``` + +--- + +## 注意事项 + +1. **检查 nil**: 所有 API 在非战斗或无数据时返回 nil/0,调用方必须检查 +2. **性能**: 避免在每帧调用查询 API,建议跟随 `threat_update` 回调或自行节流(0.3-0.5秒) +3. **数据来源**: `source == "api"` 表示服务端精确数据,`source == "local"` 表示本地估算 +4. **依赖**: 需要 Nanami-DPS >= 1.0.0(含 ThreatEngine) +5. **兼容性**: WoW 1.12.x Lua 5.0 — 使用 `table.getn` 而非 `#`,使用 `mod()` 而非 `%` diff --git a/NanamiPlates-ThreatIntegration.md b/NanamiPlates-ThreatIntegration.md new file mode 100644 index 0000000..92962eb --- /dev/null +++ b/NanamiPlates-ThreatIntegration.md @@ -0,0 +1,221 @@ +# Nanami-Plates 仇恨系统对接需求文档 + +## 概述 + +Nanami-DPS 已完成仇恨监控系统的全面重做,采用自适应双轨混合架构(TWThreat API 服务端直读 + 本地战斗日志推演)。本文档定义 Nanami-Plates 姓名板插件需要对接的数据接口、渲染要求和交互规范。 + +--- + +## 一、数据接口 + +### 1.1 全局引用 + +```lua +local TE = NanamiDPS.ThreatEngine -- 仇恨引擎实例 +local TC = NanamiDPS.ThreatCoefficients -- 系数常量 +``` + +### 1.2 核心 API 函数 + +| 函数签名 | 返回值 | 说明 | +|----------|--------|------| +| `TE:GetActiveTargetKey()` | `string` 或 `nil` | 获取当前激活目标的唯一键(优先API目标键,回退到本地目标) | +| `TE:GetCurrentTargetKey()` | `string` 或 `nil` | 获取玩家当前 target 的唯一键 | +| `TE.GetTargetKey(unitID)` | `string` 或 `nil` | 根据 unitID 计算目标唯一键(格式: `G:GUID` / `I:RaidIcon` / `V:Name:MaxHP`) | +| `TE:GetThreatList(targetKey)` | `table` | 返回按仇恨降序排列的列表,每项包含 `{name, threat, tps, perc, isTanking, isMelee, relativePercent}` | +| `TE:GetOTStatus(targetKey)` | `table` | 返回当前玩家的 OT 分析: `{safe, pct, threshold, otPoint, buffer, myThreat, tankThreat, tankName, isMelee}` | +| `TE:IsAPIActiveForTarget(targetKey)` | `boolean` | 判断该目标是否使用服务端 API 数据(精确模式) | +| `TE.IsEliteOrBoss(unitID)` | `boolean` | 判断目标是否为精英/Boss | +| `TE.IsInGroup()` | `boolean` | 判断玩家是否在小队/团队中 | + +### 1.3 状态字段(只读) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `TE.inCombat` | `boolean` | 当前是否在战斗中 | +| `TE.apiActive` | `boolean` | API 直读引擎是否激活 | +| `TE.playerName` | `string` | 当前玩家名称 | +| `TE.playerClass` | `string` | 当前玩家职业 token(大写) | +| `TE.targets` | `table` | 三维数据表,结构见下文 | + +### 1.4 三维数据结构 + +``` +TE.targets[targetKey] = { + players = { + [playerName] = { + threat = number, -- 绝对仇恨值 + tps = number, -- 每秒仇恨制造率 + isTanking = boolean, -- 是否持有仇恨 + isMelee = boolean, -- 是否在近战范围 + perc = number, -- 相对百分比 (0-100) + history = table, -- TPS 计算用历史数据 + lastThreatTime = number, -- 最后一次仇恨更新时间 + }, + ... + }, + tankName = string, -- 当前坦克名称 + tankThreat = number, -- 坦克仇恨值 + lastUpdate = number, -- 最后更新时间戳 + source = string, -- "api" | "api_tm" | "local" +} +``` + +### 1.5 回调注册 + +```lua +-- 仇恨数据更新时触发(API 报文到达或本地计算完成) +NanamiDPS:RegisterCallback("threat_update", "NanamiPlates", function() + -- 在此刷新姓名板仇恨显示 +end) +``` + +### 1.6 OT 阈值常量 + +```lua +TC.OT_MELEE_THRESHOLD = 1.10 -- 近战 110% +TC.OT_RANGED_THRESHOLD = 1.30 -- 远程 130% +``` + +--- + +## 二、姓名板渲染要求 + +### 2.1 仇恨百分比指示器 + +**位置**: 锚定在每个敌方姓名板的名称文字正上方或正下方 +**格式**: `XX%` 数字文本,字号 10-12 +**更新频率**: 跟随 `threat_update` 回调,约 0.5 秒一次 + +### 2.2 颜色渐变规则 + +根据玩家在当前目标上的仇恨百分比(相对于 OT 阈值)分级着色: + +| 百分比区间 | 颜色 | 含义 | +|-----------|------|------| +| 0% - 49% | 绿色 `(0.2, 1.0, 0.2)` | 安全 | +| 50% - 79% | 黄色 `(1.0, 1.0, 0.0)` | 警戒 | +| 80% - 100%+ | 红色 `(1.0, 0.2, 0.0)` | 危险,即将 OT | + +### 2.3 仇恨条背景色 + +为已持有仇恨(`isTanking = true`)的目标姓名板生命条添加边框高亮: +- 坦克视角: 正在坦克的怪物边框为**绿色** +- DPS/治疗视角: 自己正在被攻击的怪物边框为**红色** + +### 2.4 数据来源标识(可选) + +在仇恨百分比旁边显示小图标区分数据来源: +- API 数据: 小圆点 (绿色) — 表示服务端精确数据 +- 本地推算: 小圆点 (灰色) — 表示基于战斗日志估算 + +--- + +## 三、Tank Mode 姓名板增强 + +当玩家为坦克角色(防御姿态/巨熊形态)时: + +### 3.1 多目标仇恨概览 + +在每个姓名板上显示**仇恨排名第二**的玩家名称和百分比,格式: + +``` +[怪物名称] +██████████████ 100% ← 仇恨条(自己) +紧随: Mage_A 85% ← 第二名信息 +``` + +### 3.2 松动目标高亮 + +如果某个怪物上的第二名仇恨超过坦克仇恨的 80%(接近 110% OT 线),该姓名板整体加红色闪烁边框,提示坦克需要立即补仇恨技能。 + +### 3.3 快速目标切换(SuperWoW 依赖) + +如果检测到 SuperWoW/SuperAPI 可用: +- 姓名板上的仇恨区域可点击 +- 点击后调用 `TargetUnit` 或 `SetTarget` 切换到该怪物 +- 方便坦克无需 TAB 键即可快速切换目标 + +--- + +## 四、Healer Mode 姓名板增强 + +当玩家为治疗角色时: + +### 4.1 主坦锁定 + +允许设置一个"主坦克焦点"(通过 `/nanami mt [name]` 或右键团队框架) + +### 4.2 未稳固目标标记 + +在姓名板上对**主坦克仇恨不是最高**的怪物添加特殊标记(橙色感叹号),提示治疗者该怪物仇恨不稳定,可能随时转向治疗者。 + +--- + +## 五、性能要求 + +| 项目 | 要求 | +|------|------| +| 姓名板遍历频率 | 最大 0.5 秒一次(跟随 `threat_update` 回调) | +| 内存开销 | 单个姓名板附加元素不超过 3 个 FontString + 1 个 Texture | +| 不可见姓名板 | 必须隐藏对应的仇恨指示器,避免孤立 UI 元素 | +| 战斗外 | 所有仇恨指示器隐藏,不做任何查询 | +| 数据清理 | 离开战斗后清除所有姓名板附加元素 | + +--- + +## 六、对接代码示例 + +```lua +-- Nanami-Plates 中的对接示例 + +local TE = NanamiDPS and NanamiDPS.ThreatEngine +local TC = NanamiDPS and NanamiDPS.ThreatCoefficients + +-- 获取某个 nameplate 对应怪物的玩家仇恨百分比 +local function GetMyThreatPct(unitID) + if not TE or not TE.inCombat then return nil end + + local targetKey = TE.GetTargetKey(unitID) + if not targetKey then return nil end + + local td = TE.targets[targetKey] + if not td then return nil end + + local pd = td.players[TE.playerName] + if not pd then return nil end + + return pd.perc, pd.isTanking, pd.isMelee +end + +-- 获取该怪物仇恨第二名 +local function GetSecondThreat(unitID) + if not TE or not TE.inCombat then return nil end + + local targetKey = TE.GetTargetKey(unitID) + if not targetKey then return nil end + + local list = TE:GetThreatList(targetKey) + if list and list[2] then + return list[2].name, list[2].perc, list[2].threat + end + return nil +end + +-- 注册回调 +if NanamiDPS then + NanamiDPS:RegisterCallback("threat_update", "NanamiPlates_Threat", function() + -- 遍历所有可见姓名板,刷新仇恨数据 + NanamiPlates:RefreshAllThreatIndicators() + end) +end +``` + +--- + +## 七、版本兼容性 + +- **必须依赖**: Nanami-DPS >= 1.0.0(含 ThreatEngine 模块) +- **可选依赖**: SuperWoW / SuperAPI(用于姓名板高级交互) +- **WoW 客户端**: 1.12.x (Turtle WoW) +- **Lua 环境**: 不支持现代 Lua 特性(使用 `table.getn` 代替 `#`,`pairs` 代替 `next`) diff --git a/Options.lua b/Options.lua index 4611e19..37aaa79 100644 --- a/Options.lua +++ b/Options.lua @@ -546,6 +546,16 @@ function Options:CreatePanel() CreateStyledCheckbox(page2, L["Merge Pets"], 8, y, function() return cfg.mergePets end, function(v) cfg.mergePets = v end) + y = y - 26 + + CreateStyledCheckbox(page2, L["OT Warning"], 8, y, + function() return cfg.otWarning end, + function(v) cfg.otWarning = v end) + y = y - 26 + + CreateStyledCheckbox(page2, L["Nameplate Threat"], 8, y, + function() return cfg.nameplateThreat end, + function(v) cfg.nameplateThreat = v end) y = y - 36 CreateSectionHeader(page2, L["Max Segments"], y) diff --git a/Parser.lua b/Parser.lua index dd9649b..9233bb5 100644 --- a/Parser.lua +++ b/Parser.lua @@ -16,163 +16,22 @@ Parser.validUnits = validUnits Parser.validPets = validPets ----------------------------------------------------------------------- --- Spell threat coefficients (vanilla / classic values, max rank) --- Reference: classic-warrior wiki, warcrafttavern.com --- bonus = fixed threat added ON TOP of damage --- mult = multiplier applied to the DAMAGE portion (default 1.0) +-- Threat calculation now delegated to ThreatEngine + ThreatCoefficients. +-- The engine handles stance/buff scanning, talent multipliers, and the +-- complete spell coefficient database. These wrappers remain for +-- backward compatibility with ProcessDamage/ProcessHealing. ----------------------------------------------------------------------- -local spellThreatData = { - ["Heroic Strike"] = { bonus = 173 }, - ["Shield Slam"] = { bonus = 254 }, - ["Revenge"] = { bonus = 270, mult = 2.25 }, - ["Shield Bash"] = { bonus = 156, mult = 1.5 }, - ["Cleave"] = { bonus = 100 }, - ["Execute"] = { mult = 1.25 }, - ["Hamstring"] = { bonus = 135, mult = 1.25 }, - ["Thunder Clap"] = { mult = 2.5 }, - ["Overpower"] = { mult = 0.75 }, - ["Disarm"] = { bonus = 99 }, - ["Sunder Armor"] = { bonus = 261 }, - ["Maul"] = { mult = 1.75 }, - ["Swipe"] = { mult = 1.75 }, - ["Faerie Fire (Feral)"] = { bonus = 108 }, - ["Mind Blast"] = { mult = 2.0 }, - ["Holy Nova"] = { mult = 0 }, - ["Earth Shock"] = { mult = 2.0 }, - ["Searing Pain"] = { mult = 2.0 }, - ["Distracting Shot"] = { bonus = 600 }, - ["Scorpid Poison"] = { bonus = 5 }, - ["Intimidation"] = { bonus = 580 }, - ["Life Tap"] = { mult = 0 }, - ["Counterspell"] = { bonus = 300 }, - ["Mocking Blow"] = { bonus = 250 }, -} - -do - local cnAliases = { - ["\232\139\177\229\139\135\230\137\147\229\135\187"] = "Heroic Strike", - ["\231\155\190\231\137\140\231\140\155\229\135\187"] = "Shield Slam", - ["\229\164\141\228\187\135"] = "Revenge", - ["\231\155\190\229\135\187"] = "Shield Bash", - ["\233\161\186\229\138\136\230\150\169"] = "Cleave", - ["\230\150\169\230\157\128"] = "Execute", - ["\230\150\173\231\173\139"] = "Hamstring", - ["\233\155\183\233\156\134\228\184\128\229\135\187"] = "Thunder Clap", - ["\229\142\139\229\136\182"] = "Overpower", - ["\231\188\180\230\162\176"] = "Disarm", - ["\231\160\180\231\148\178\230\148\187\229\135\187"] = "Sunder Armor", - ["\230\167\152\229\135\187"] = "Maul", - ["\230\140\165\229\135\187"] = "Swipe", - ["\231\178\190\231\129\181\228\185\139\231\129\171(\233\135\142\233\135\145)"] = "Faerie Fire (Feral)", - ["\229\191\131\231\129\181\233\156\135\231\136\134"] = "Mind Blast", - ["\231\165\158\229\156\163\230\150\176\230\152\159"] = "Holy Nova", - ["\229\156\176\233\156\135\230\156\175"] = "Earth Shock", - ["\231\129\188\231\131\173\228\185\139\231\151\155"] = "Searing Pain", - ["\230\137\176\228\185\177\229\176\132\229\135\187"] = "Distracting Shot", - ["\232\157\157\230\175\146"] = "Scorpid Poison", - ["\232\131\129\232\191\171"] = "Intimidation", - ["\231\148\159\229\145\189\229\136\134\230\181\129"] = "Life Tap", - ["\229\143\141\229\136\182\233\173\148\230\179\149"] = "Counterspell", - ["\229\152\178\229\188\132\230\137\147\229\135\187"] = "Mocking Blow", - } - for cn, en in pairs(cnAliases) do - if spellThreatData[en] then - spellThreatData[cn] = spellThreatData[en] - end - end -end - -local classThreatMod = { - ROGUE = 0.8, -} - ------------------------------------------------------------------------ --- Stance / form / buff threat modifier detection --- Scans UnitBuff textures to detect known threat-altering states. --- Cached per-unit for 2 seconds to avoid excessive buff scanning. ------------------------------------------------------------------------ -local stanceScanPatterns = { - { pat = "DefensiveStance", mod = 1.3 }, - { pat = "OffensiveStance", mod = 0.8 }, - { pat = "Racial_Avatar", mod = 0.8 }, - { pat = "BerserkStance", mod = 0.8 }, - { pat = "BearForm", mod = 1.3 }, - { pat = "CatForm", mod = 0.8 }, -} - -local buffScanPatterns = { - { pat = "SealOfSalvation", mod = 0.7 }, -} - -local function ScanUnitThreatMod(unit) - if not unit or not UnitExists(unit) then return 1.0 end - local stanceMod = 1.0 - local buffMod = 1.0 - for i = 1, 32 do - local texture = UnitBuff(unit, i) - if not texture then break end - for _, s in ipairs(stanceScanPatterns) do - if string.find(texture, s.pat) then - stanceMod = s.mod - end - end - for _, b in ipairs(buffScanPatterns) do - if string.find(texture, b.pat) then - buffMod = buffMod * b.mod - end - end - end - return stanceMod * buffMod -end - -local stanceModCache = {} -local STANCE_CACHE_TTL = 2 - -function Parser:GetUnitThreatMod(name) - local now = GetTime() - local cached = stanceModCache[name] - if cached and (now - cached.time) < STANCE_CACHE_TTL then - return cached.mod - end - local unit = self:UnitByName(name) - local mod = ScanUnitThreatMod(unit) - stanceModCache[name] = { mod = mod, time = now } - return mod -end function Parser:CalculateSpellThreat(source, spell, damage) if not damage or damage <= 0 then return 0 end - - local threat = damage - local coeff = spell and spellThreatData[spell] - - if coeff then - threat = damage * (coeff.mult or 1.0) + (coeff.bonus or 0) - end - local class = DataStore:GetClass(source) - if class and classThreatMod[class] then - threat = threat * classThreatMod[class] - end - - threat = threat * self:GetUnitThreatMod(source) - - return math.max(threat, 0) + return NanamiDPS.ThreatEngine:CalculateLocalThreat(source, spell, damage, false, class) end function Parser:CalculateHealThreat(source, amount) if not amount or amount <= 0 then return 0 end - - local threat = amount * 0.5 - local class = DataStore:GetClass(source) - if class and classThreatMod[class] then - threat = threat * classThreatMod[class] - end - - threat = threat * self:GetUnitThreatMod(source) - - return threat + return NanamiDPS.ThreatEngine:CalculateLocalThreat(source, nil, amount, true, class) end local unit_cache = {} @@ -257,6 +116,7 @@ function Parser:ResolveSource(source) end function Parser:ProcessDamage(source, spell, target, amount, school) + if NanamiDPS.config and NanamiDPS.config.paused then return end if type(source) ~= "string" or not tonumber(amount) then return end source = NanamiDPS.trim(source) amount = tonumber(amount) @@ -279,7 +139,22 @@ function Parser:ProcessDamage(source, spell, target, amount, school) DataStore:AddDamageTaken(target, finalSpell, resolvedSource, amount, school) end - DataStore:AddThreat(source, self:CalculateSpellThreat(source, spell, amount)) + local threatAmount = self:CalculateSpellThreat(source, spell, amount) + DataStore:AddThreat(source, threatAmount) + + local TE = NanamiDPS.ThreatEngine + local TC = NanamiDPS.ThreatCoefficients + if TE then + local targetKey = TE:GetCurrentTargetKey() + if targetKey and not TE:IsAPIActiveForTarget(targetKey) then + TE:AddLocalThreat(targetKey, source, threatAmount) + + if spell and TC and TC.tauntSpells and TC.tauntSpells[spell] then + TE:HandleTaunt(source, targetKey) + end + end + end + DataStore:UpdateActivity(resolvedSource, GetTime()) if targetType == "PLAYER" then @@ -297,6 +172,7 @@ function Parser:ProcessDamage(source, spell, target, amount, school) end function Parser:ProcessHealing(source, spell, target, amount, school) + if NanamiDPS.config and NanamiDPS.config.paused then return end if type(source) ~= "string" or not tonumber(amount) then return end source = NanamiDPS.trim(source) amount = tonumber(amount) @@ -318,7 +194,17 @@ function Parser:ProcessHealing(source, spell, target, amount, school) DataStore:AddHealing(resolvedSource, finalSpell, target, amount, effective) - DataStore:AddThreat(source, self:CalculateHealThreat(source, amount)) + local healThreat = self:CalculateHealThreat(source, effective) + DataStore:AddThreat(source, healThreat) + + local TE = NanamiDPS.ThreatEngine + if TE then + local targetKey = TE:GetCurrentTargetKey() + if targetKey and not TE:IsAPIActiveForTarget(targetKey) then + TE:AddLocalThreat(targetKey, source, healThreat) + end + end + DataStore:UpdateActivity(resolvedSource, GetTime()) local targetType = self:ScanName(target) diff --git a/ParserVanilla.lua b/ParserVanilla.lua index 785fa96..3e42c46 100644 --- a/ParserVanilla.lua +++ b/ParserVanilla.lua @@ -385,6 +385,24 @@ for pat in pairs(dispel_parser) do sanitize(pat) end +----------------------------------------------------------------------- +-- Aura gain patterns for threat-clearing abilities +----------------------------------------------------------------------- +local auraGainPatterns = {} +if AURAADDEDSELFHELPFUL then + table.insert(auraGainPatterns, { pat = sanitize(AURAADDEDSELFHELPFUL), self = true }) +end +if AURAADDEDOTHERHELPFUL then + table.insert(auraGainPatterns, { pat = sanitize(AURAADDEDOTHERHELPFUL), self = false }) +end + +Parser:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS") +Parser:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS") +Parser:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS") +Parser:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_SELF") +Parser:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_OTHER") +Parser:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_PARTY") + ----------------------------------------------------------------------- -- Main event handler ----------------------------------------------------------------------- @@ -415,6 +433,36 @@ Parser:SetScript("OnEvent", function() return end + -- Threat-clearing aura detection (Feign Death, Vanish, etc.) + local TC = NanamiDPS.ThreatCoefficients + local TE = NanamiDPS.ThreatEngine + if TC and TE and TC.threatWipeEvents then + for auraName, wipeType in pairs(TC.threatWipeEvents) do + if string.find(arg1, auraName, 1, true) then + local who = nil + if string.find(arg1, player, 1, true) or + (AURAADDEDSELFHELPFUL and string.find(arg1, "You gain", 1, true)) then + who = player + else + for aIdx, aPat in pairs(auraGainPatterns) do + if not aPat.self then + local _, _, aName = string.find(arg1, aPat.pat) + if aName then who = aName; break end + end + end + end + if who then + TE:WipePlayerThreat(who, wipeType) + local DS = NanamiDPS.DataStore + if DS then + DS:AddThreat(who, -(DS:GetPlayerThreat(who) or 0)) + end + end + return + end + end + end + -- Strip absorb/resist suffixes if absorb then arg1 = string.gsub(arg1, absorb, empty) end if resist then arg1 = string.gsub(arg1, resist, empty) end diff --git a/ThreatCoefficients.lua b/ThreatCoefficients.lua new file mode 100644 index 0000000..89ba023 --- /dev/null +++ b/ThreatCoefficients.lua @@ -0,0 +1,264 @@ +local NanamiDPS = NanamiDPS + +------------------------------------------------------------------------------- +-- ThreatCoefficients: Complete spell threat database for WoW 1.12 + Turtle WoW +-- +-- Each entry: bonus = flat threat added on top of damage +-- mult = multiplier applied to damage (default 1.0) +-- zero = true if the spell generates no threat at all +-- +-- Global modifiers (stances, buffs, talents) are applied separately by the +-- engine; this table only holds per-spell values. +------------------------------------------------------------------------------- + +local TC = {} +NanamiDPS.ThreatCoefficients = TC + +------------------------------------------------------------------------------- +-- Warrior +------------------------------------------------------------------------------- +TC.spells = { + -- Warrior: flat bonus + damage + ["Heroic Strike"] = { bonus = 145 }, + ["Shield Slam"] = { bonus = 250 }, + ["Revenge"] = { bonus = 315 }, + ["Shield Bash"] = { bonus = 180 }, + ["Cleave"] = { bonus = 100 }, + ["Execute"] = { mult = 1.25 }, + ["Hamstring"] = { bonus = 145, mult = 1.25 }, + ["Thunder Clap"] = { bonus = 130 }, + ["Sunder Armor"] = { bonus = 261 }, + ["Overpower"] = { mult = 0.75 }, + ["Disarm"] = { bonus = 104 }, + ["Mocking Blow"] = { bonus = 250 }, + ["Battle Shout"] = { bonus = 55 }, + ["Demoralizing Shout"] = { bonus = 43 }, + + -- Druid: multiplicative model + ["Maul"] = { mult = 1.75 }, + ["Swipe"] = { mult = 1.75 }, + ["Faerie Fire (Feral)"] = { bonus = 108 }, + + -- Paladin: Holy damage + Righteous Fury amplified elsewhere + ["Holy Strike"] = { mult = 1.5 }, + ["Consecration"] = { mult = 1.0 }, + ["Holy Shield"] = { mult = 1.0 }, + ["Judgement"] = { mult = 1.0 }, + ["Seal of Righteousness"] = { mult = 1.0 }, + ["Exorcism"] = { mult = 1.0 }, + ["Hammer of Wrath"] = { mult = 1.0 }, + + -- Shaman: Turtle WoW enhanced + ["Earth Shock"] = { mult = 1.5 }, + ["Flame Shock"] = { mult = 1.0 }, + ["Frost Shock"] = { mult = 1.0 }, + ["Stormstrike"] = { mult = 1.0 }, + + -- Priest + ["Mind Blast"] = { mult = 2.0 }, + ["Holy Nova"] = { zero = true }, + + -- Warlock + ["Searing Pain"] = { mult = 2.0 }, + ["Life Tap"] = { zero = true }, + + -- Hunter + ["Distracting Shot"] = { bonus = 600 }, + ["Intimidation"] = { bonus = 580 }, + ["Scorpid Poison"] = { bonus = 5 }, + + -- Rogue + ["Feint"] = { bonus = -800 }, + + -- Mage + ["Counterspell"] = { bonus = 300 }, + + -- Pet abilities + ["Growl"] = { bonus = 415 }, + ["Thunderstomp"] = { bonus = 100 }, + ["Torment"] = { bonus = 300 }, + ["Suffering"] = { bonus = 300 }, + ["Anguish"] = { bonus = 300 }, +} + +------------------------------------------------------------------------------- +-- Chinese localization aliases +------------------------------------------------------------------------------- +local cnAliases = { + ["\232\139\177\229\139\135\230\137\147\229\135\187"] = "Heroic Strike", + ["\231\155\190\231\137\140\231\140\155\229\135\187"] = "Shield Slam", + ["\229\164\141\228\187\135"] = "Revenge", + ["\231\155\190\229\135\187"] = "Shield Bash", + ["\233\161\186\229\138\136\230\150\169"] = "Cleave", + ["\230\150\169\230\157\128"] = "Execute", + ["\230\150\173\231\173\139"] = "Hamstring", + ["\233\155\183\233\156\134\228\184\128\229\135\187"] = "Thunder Clap", + ["\231\160\180\231\148\178\230\148\187\229\135\187"] = "Sunder Armor", + ["\229\142\139\229\136\182"] = "Overpower", + ["\231\188\180\230\162\176"] = "Disarm", + ["\229\152\178\229\188\132\230\137\147\229\135\187"] = "Mocking Blow", + ["\230\136\152\230\150\151\230\128\146\229\144\188"] = "Battle Shout", + ["\230\140\171\229\191\151\230\128\146\229\144\188"] = "Demoralizing Shout", + ["\230\167\152\229\135\187"] = "Maul", + ["\230\140\165\229\135\187"] = "Swipe", + ["\231\178\190\231\129\181\228\185\139\231\129\171(\233\135\142\233\135\145)"] = "Faerie Fire (Feral)", + ["\231\165\158\229\156\163\230\137\147\229\135\187"] = "Holy Strike", + ["\229\165\137\231\140\174"] = "Consecration", + ["\231\165\158\229\156\163\228\185\139\231\155\190"] = "Holy Shield", + ["\229\174\161\229\136\164"] = "Judgement", + ["\230\173\163\228\185\137\229\156\163\229\141\176"] = "Seal of Righteousness", + ["\233\169\177\233\173\148\230\234\175\175"] = "Exorcism", + ["\230\132\164\230\128\146\228\185\139\233\148\164"] = "Hammer of Wrath", + ["\229\156\176\233\156\135\230\156\175"] = "Earth Shock", + ["\231\129\171\231\132\176\229\134\178\229\135\187"] = "Flame Shock", + ["\229\134\176\233\156\156\229\134\178\229\135\187"] = "Frost Shock", + ["\233\163\142\230\154\180\230\137\147\229\135\187"] = "Stormstrike", + ["\229\191\131\231\129\181\233\156\135\231\136\134"] = "Mind Blast", + ["\231\165\158\229\156\163\230\150\176\230\152\159"] = "Holy Nova", + ["\231\129\188\231\131\173\228\185\139\231\151\155"] = "Searing Pain", + ["\231\148\159\229\145\189\229\136\134\230\181\129"] = "Life Tap", + ["\230\137\176\228\185\177\229\176\132\229\135\187"] = "Distracting Shot", + ["\232\157\157\230\175\146"] = "Scorpid Poison", + ["\232\131\129\232\191\171"] = "Intimidation", + ["\229\129\183\230\148\187"] = "Feint", + ["\229\143\141\229\136\182\233\173\148\230\179\149"] = "Counterspell", + ["\228\189\142\229\144\188"] = "Growl", + ["\233\155\183\233\156\134\232\184\143\232\184\143"] = "Thunderstomp", + ["\230\138\152\231\163\168"] = "Torment", + ["\229\143\151\233\154\190"] = "Suffering", +} + +for cn, en in pairs(cnAliases) do + if TC.spells[en] then + TC.spells[cn] = TC.spells[en] + end +end + +------------------------------------------------------------------------------- +-- Stance / Form global multipliers (detected via buff texture scanning) +------------------------------------------------------------------------------- +TC.stancePatterns = { + { pat = "DefensiveStance", mod = 1.3 }, + { pat = "OffensiveStance", mod = 0.8 }, + { pat = "BerserkStance", mod = 0.8 }, + { pat = "Racial_Avatar", mod = 0.8 }, + { pat = "BearForm", mod = 1.3 }, + { pat = "CatForm", mod = 0.8 }, +} + +------------------------------------------------------------------------------- +-- Buff-based threat reduction (e.g. Salvation) +------------------------------------------------------------------------------- +TC.buffPatterns = { + { pat = "SealOfSalvation", mod = 0.7 }, +} + +------------------------------------------------------------------------------- +-- Talent-based multipliers: detected via GetTalentInfo at combat start +-- { tab, index, perPoint, base } +-- Final = base + (rank * perPoint) +-- Warrior Defiance: Protection tab, talent #9 => +5% per point (vanilla) +-- Turtle WoW Defiance is stronger (+9.67% per point to reach 1.45x at 3/3) +-- Druid Feral Instinct: Feral tab, talent #3 => +5% per point +------------------------------------------------------------------------------- +TC.talentMultipliers = { + WARRIOR = { + { tab = 3, index = 9, perPoint = 0.15, base = 0, label = "Defiance" }, + }, + DRUID = { + { tab = 2, index = 3, perPoint = 0.05, base = 0, label = "Feral Instinct" }, + }, + SHAMAN = { + { tab = 2, index = 9, perPoint = 0.05, base = 0, label = "Spirit Shield" }, + }, +} + +------------------------------------------------------------------------------- +-- Class-level passive multiplier (always active, no stance check needed) +------------------------------------------------------------------------------- +TC.classPassiveMod = { + ROGUE = 0.71, +} + +------------------------------------------------------------------------------- +-- Righteous Fury detection for Paladin (buff texture) +-- Base = 1.6x holy damage threat; talented (3/3 Improved RF) = 1.9x +------------------------------------------------------------------------------- +TC.righteousFury = { + texture = "Spell_Holy_SealOfFury", + baseMod = 1.6, + talentTab = 2, + talentIndex = 7, + perPoint = 0.1, +} + +------------------------------------------------------------------------------- +-- Shaman Rockbiter: global threat modifier when weapon has Rockbiter enchant +-- Detected via GetWeaponEnchantInfo or buff texture +------------------------------------------------------------------------------- +TC.rockbiter = { + texture = "Spell_Nature_RockBiter", + globalMod = 1.3, +} + +------------------------------------------------------------------------------- +-- Threat-clearing events: buff/debuff names that reset threat +------------------------------------------------------------------------------- +TC.threatWipeEvents = { + ["Feign Death"] = "FULL_WIPE", + ["Vanish"] = "FULL_WIPE", + ["Invisibility"] = "FULL_WIPE", + ["Soulshatter"] = "HALF_WIPE", + ["Fade"] = "TEMP_REDUCE", +} + +TC.threatWipeEventsCN = { + ["\229\129\135\230\173\187"] = "FULL_WIPE", + ["\230\182\136\229\164\177"] = "FULL_WIPE", + ["\233\154\144\232\186\171"] = "FULL_WIPE", + ["\231\129\181\233\173\130\231\162\142\232\163\130"] = "HALF_WIPE", + ["\230\184\144\233\154\144"] = "TEMP_REDUCE", +} + +for cn, v in pairs(TC.threatWipeEventsCN) do + TC.threatWipeEvents[cn] = v +end + +------------------------------------------------------------------------------- +-- Taunt spells: trigger threat-copy mechanic in ThreatEngine +------------------------------------------------------------------------------- +TC.tauntSpells = { + ["Growl"] = true, + ["Taunt"] = true, + ["Mocking Blow"] = true, + ["Challenging Shout"] = true, + ["Challenging Roar"] = true, + ["Hand of Reckoning"] = true, + ["Earthshaker Slam"] = true, + ["\228\189\142\229\144\188"] = true, + ["\229\152\178\232\174\189"] = true, + ["\229\152\178\229\188\132\230\137\147\229\135\187"] = true, + ["\230\140\145\230\136\152\230\128\146\229\144\188"] = true, + ["\230\140\145\230\136\152\229\146\134\229\147\174"] = true, +} + +------------------------------------------------------------------------------- +-- Healing threat coefficient +------------------------------------------------------------------------------- +TC.HEAL_THREAT_COEFF = 0.5 + +------------------------------------------------------------------------------- +-- Rage gain threat (per point of rage from active abilities) +------------------------------------------------------------------------------- +TC.RAGE_GAIN_THREAT = 5.0 + +------------------------------------------------------------------------------- +-- Mana gain threat (per point of mana from active abilities) +------------------------------------------------------------------------------- +TC.MANA_GAIN_THREAT = 0.5 + +------------------------------------------------------------------------------- +-- OT threshold multipliers +------------------------------------------------------------------------------- +TC.OT_MELEE_THRESHOLD = 1.10 +TC.OT_RANGED_THRESHOLD = 1.30 diff --git a/ThreatDisplay.lua b/ThreatDisplay.lua new file mode 100644 index 0000000..eb26144 --- /dev/null +++ b/ThreatDisplay.lua @@ -0,0 +1,384 @@ +local NanamiDPS = NanamiDPS +local TE = NanamiDPS.ThreatEngine +local TC = NanamiDPS.ThreatCoefficients +local L = NanamiDPS.L + +------------------------------------------------------------------------------- +-- ThreatDisplay: Visual warning system, nameplate anchoring, role views +-- +-- 1. Full-screen edge glow when approaching OT threshold +-- 2. Sound warning at critical threat levels +-- 3. Nameplate threat indicators (SuperAPI dependent) +-- 4. Tank Mode / Healer Mode smart views +------------------------------------------------------------------------------- + +local TD = CreateFrame("Frame", "NanamiDPSThreatDisplay", UIParent) +NanamiDPS.ThreatDisplay = TD + +local _floor = math.floor +local _min = math.min +local _max = math.max + +TD.warningState = "SAFE" +TD.lastWarnSound = 0 +TD.lastGlowUpdate = 0 +TD.nameplateFrames = {} + +------------------------------------------------------------------------------- +-- Full-screen glow overlay +------------------------------------------------------------------------------- +local glowFrame = CreateFrame("Frame", "NanamiDPS_OTGlow", UIParent) +glowFrame:SetFrameStrata("FULLSCREEN_DIALOG") +glowFrame:SetAllPoints(UIParent) +glowFrame:EnableMouse(false) +glowFrame:Hide() + +local glowTop = glowFrame:CreateTexture(nil, "OVERLAY") +glowTop:SetTexture(1, 0, 0) +glowTop:SetPoint("TOPLEFT", glowFrame, "TOPLEFT") +glowTop:SetPoint("TOPRIGHT", glowFrame, "TOPRIGHT") +glowTop:SetHeight(40) + +local glowBottom = glowFrame:CreateTexture(nil, "OVERLAY") +glowBottom:SetTexture(1, 0, 0) +glowBottom:SetPoint("BOTTOMLEFT", glowFrame, "BOTTOMLEFT") +glowBottom:SetPoint("BOTTOMRIGHT", glowFrame, "BOTTOMRIGHT") +glowBottom:SetHeight(40) + +local glowLeft = glowFrame:CreateTexture(nil, "OVERLAY") +glowLeft:SetTexture(1, 0, 0) +glowLeft:SetPoint("TOPLEFT", glowFrame, "TOPLEFT") +glowLeft:SetPoint("BOTTOMLEFT", glowFrame, "BOTTOMLEFT") +glowLeft:SetWidth(30) + +local glowRight = glowFrame:CreateTexture(nil, "OVERLAY") +glowRight:SetTexture(1, 0, 0) +glowRight:SetPoint("TOPRIGHT", glowFrame, "TOPRIGHT") +glowRight:SetPoint("BOTTOMRIGHT", glowFrame, "BOTTOMRIGHT") +glowRight:SetWidth(30) + +local glowAlpha = 0 +local glowDir = 1 +local GLOW_SPEED = 2.5 +local GLOW_MAX = 0.55 +local GLOW_MIN = 0.05 + +glowFrame:SetScript("OnUpdate", function() + local dt = arg1 or 0.016 + glowAlpha = glowAlpha + glowDir * GLOW_SPEED * dt + if glowAlpha >= GLOW_MAX then + glowAlpha = GLOW_MAX + glowDir = -1 + elseif glowAlpha <= GLOW_MIN then + glowAlpha = GLOW_MIN + glowDir = 1 + end + glowTop:SetAlpha(glowAlpha) + glowBottom:SetAlpha(glowAlpha) + glowLeft:SetAlpha(glowAlpha * 0.7) + glowRight:SetAlpha(glowAlpha * 0.7) +end) + +function TD:ShowGlow() + glowAlpha = GLOW_MIN + glowDir = 1 + glowFrame:Show() +end + +function TD:HideGlow() + glowFrame:Hide() +end + +------------------------------------------------------------------------------- +-- Warning text overlay (center screen) +------------------------------------------------------------------------------- +local warnFrame = CreateFrame("Frame", "NanamiDPS_OTWarn", UIParent) +warnFrame:SetFrameStrata("HIGH") +warnFrame:SetWidth(300) +warnFrame:SetHeight(50) +warnFrame:SetPoint("TOP", UIParent, "TOP", 0, -180) +warnFrame:EnableMouse(false) +warnFrame:Hide() + +local warnText = warnFrame:CreateFontString(nil, "OVERLAY", "GameFontNormalLarge") +warnText:SetAllPoints() +warnText:SetFont(STANDARD_TEXT_FONT, 22, "OUTLINE") +warnText:SetTextColor(1, 0.2, 0) + +local warnFadeTimer = 0 + +warnFrame:SetScript("OnUpdate", function() + local dt = arg1 or 0.016 + warnFadeTimer = warnFadeTimer - dt + if warnFadeTimer <= 0 then + warnFrame:Hide() + elseif warnFadeTimer < 1 then + warnFrame:SetAlpha(warnFadeTimer) + end +end) + +function TD:FlashWarning(text) + warnText:SetText(text) + warnFrame:SetAlpha(1) + warnFadeTimer = 3.0 + warnFrame:Show() +end + +------------------------------------------------------------------------------- +-- Main update loop: assess OT danger and trigger warnings +------------------------------------------------------------------------------- +local WARNING_SOUND = "Sound\\Doodad\\BellTollAlliance.wav" +local WARN_COOLDOWN = 3.0 + +function TD:Update() + if not TE or not TE.inCombat then + self:HideGlow() + self.warningState = "SAFE" + return + end + + local config = NanamiDPS.config + if not config then return end + if not config.otWarning then + self:HideGlow() + self.warningState = "SAFE" + return + end + + local targetKey = TE:GetActiveTargetKey() + if not targetKey then + self:HideGlow() + self.warningState = "SAFE" + return + end + + local otStatus = TE:GetOTStatus(targetKey) + if not otStatus or not otStatus.tankName then + self:HideGlow() + self.warningState = "SAFE" + return + end + + local pct = otStatus.pct or 0 + local now = GetTime() + + if pct >= 90 then + if self.warningState ~= "CRITICAL" then + self.warningState = "CRITICAL" + if (now - self.lastWarnSound) >= WARN_COOLDOWN then + PlaySoundFile(WARNING_SOUND) + self.lastWarnSound = now + end + self:FlashWarning(L["OT Warning Critical"]) + end + self:ShowGlow() + elseif pct >= 70 then + if self.warningState ~= "DANGER" then + self.warningState = "DANGER" + self:FlashWarning(L["OT Warning Danger"]) + end + self:HideGlow() + elseif pct >= 50 then + self.warningState = "CAUTION" + self:HideGlow() + else + self.warningState = "SAFE" + self:HideGlow() + end +end + +------------------------------------------------------------------------------- +-- Nameplate threat indicators (SuperAPI / pfUI integration) +-- Attaches small threat % text to enemy nameplates when available. +------------------------------------------------------------------------------- +local NP_UPDATE_INTERVAL = 0.5 +TD.lastNPUpdate = 0 + +function TD:UpdateNameplates() + if not TE or not TE.inCombat then + self:HideAllNameplateIndicators() + return + end + + local config = NanamiDPS.config + if not config or not config.nameplateThreat then return end + + local now = GetTime() + if (now - self.lastNPUpdate) < NP_UPDATE_INTERVAL then return end + self.lastNPUpdate = now + + local worldFrame = WorldFrame + if not worldFrame then return end + + if not worldFrame.GetChildren then return end + local children = { worldFrame:GetChildren() } + for i = 1, table.getn(children) do + local child = children[i] + if child and child:IsVisible() and child.GetName and child:GetName() == nil then + self:ProcessNameplate(child) + end + end +end + +function TD:HideAllNameplateIndicators() + for name, indicator in pairs(self.nameplateFrames) do + if indicator and indicator.SetText then + indicator:SetText("") + end + end +end + +function TD:ProcessNameplate(frame) + if not frame or not frame:IsVisible() then return end + + local regions = { frame:GetRegions() } + local nameRegion = nil + for _, region in pairs(regions) do + if region:GetObjectType() == "FontString" then + local text = region:GetText() + if text and text ~= "" then + nameRegion = region + break + end + end + end + + if not nameRegion then return end + + local npName = nameRegion:GetText() + if not npName then return end + + local indicator = self.nameplateFrames[npName] + if not indicator then + indicator = frame:CreateFontString(nil, "OVERLAY") + indicator:SetFont(STANDARD_TEXT_FONT, 10, "OUTLINE") + indicator:SetPoint("TOP", nameRegion, "BOTTOM", 0, -2) + self.nameplateFrames[npName] = indicator + end + + local playerName = TE.playerName + local targetKey = nil + + for key, td in pairs(TE.targets) do + if td.players[playerName] then + targetKey = key + break + end + end + + if not targetKey then + indicator:SetText("") + return + end + + local td = TE.targets[targetKey] + if not td then + indicator:SetText("") + return + end + + local pd = td.players[playerName] + if not pd then + indicator:SetText("") + return + end + + local pct = pd.perc or 0 + local r, g, b = 0.2, 1, 0.2 + if pct >= 80 then + r, g, b = 1, 0.2, 0 + elseif pct >= 50 then + r, g, b = 1, 1, 0 + end + + indicator:SetTextColor(r, g, b) + indicator:SetText(string.format("%.0f%%", pct)) +end + +------------------------------------------------------------------------------- +-- Tank Mode: Show which mobs have loose threat +-- Returns a summary table for UI consumption +------------------------------------------------------------------------------- +function TD:GetTankModeSummary() + if not TE then return {} end + + local summary = {} + local myName = TE.playerName + + for targetKey, td in pairs(TE.targets) do + if td.tankName and td.tankName ~= myName then + local myData = td.players[myName] + local tankData = td.players[td.tankName] + + if myData and tankData then + table.insert(summary, { + targetKey = targetKey, + tankName = td.tankName, + myThreat = myData.threat, + tankThreat = tankData.threat, + gap = tankData.threat - myData.threat, + pct = tankData.threat > 0 and (myData.threat / tankData.threat * 100) or 0, + }) + end + end + end + + table.sort(summary, function(a, b) return a.pct > b.pct end) + return summary +end + +------------------------------------------------------------------------------- +-- Healer Mode: Show which mobs don't have solid tank threat +------------------------------------------------------------------------------- +function TD:GetHealerModeSummary(masterTankName) + if not TE or not masterTankName then return {} end + + local summary = {} + + for targetKey, td in pairs(TE.targets) do + local tankData = td.players[masterTankName] + if not tankData or not td.tankName or td.tankName ~= masterTankName then + local topThreat = 0 + local topName = "" + for name, pd in pairs(td.players) do + if pd.threat > topThreat then + topThreat = pd.threat + topName = name + end + end + + table.insert(summary, { + targetKey = targetKey, + topThreatName = topName, + topThreat = topThreat, + tankThreat = tankData and tankData.threat or 0, + isLoose = td.tankName ~= masterTankName, + }) + end + end + + table.sort(summary, function(a, b) return a.tankThreat < b.tankThreat end) + return summary +end + +------------------------------------------------------------------------------- +-- Hook into ThreatEngine update cycle +------------------------------------------------------------------------------- +NanamiDPS:RegisterCallback("threat_update", "ThreatDisplay", function() + TD:Update() +end) + +TD:SetScript("OnUpdate", function() + if not TE or not TE.inCombat then + if TD.nameplateFrames then + TD:HideAllNameplateIndicators() + end + return + end + local config = NanamiDPS.config + if not config or not config.nameplateThreat then return end + local now = GetTime() + if (now - (TD.lastNPUpdate or 0)) >= NP_UPDATE_INTERVAL then + TD:UpdateNameplates() + end +end) diff --git a/ThreatEngine.lua b/ThreatEngine.lua new file mode 100644 index 0000000..cf48ddc --- /dev/null +++ b/ThreatEngine.lua @@ -0,0 +1,863 @@ +local NanamiDPS = NanamiDPS +local TC = NanamiDPS.ThreatCoefficients + +------------------------------------------------------------------------------- +-- ThreatEngine: Adaptive dual-track hybrid threat monitoring +-- +-- Track 1 (Primary): TWThreat server API — direct authoritative data for +-- Elite/Boss targets when in party/raid. Completely bypasses combat +-- log parsing for the queried target. +-- +-- Track 2 (Fallback): Local heuristic combat log estimation — used for +-- normal mobs, solo play, or environments without SuperWoW. +-- +-- Data model: 3D table [targetKey][playerName] = { threat, tps, ... } +-- targetKey = GUID (TWThreat) or RaidIcon/virtualID (fallback) +------------------------------------------------------------------------------- + +local TE = CreateFrame("Frame", "NanamiDPSThreatEngine", UIParent) +NanamiDPS.ThreatEngine = TE + +local _find = string.find +local _sub = string.sub +local _len = string.len +local _lower = string.lower +local _floor = math.floor +local _max = math.max +local _pairs = pairs +local _tonumber = tonumber + +------------------------------------------------------------------------------- +-- Constants +------------------------------------------------------------------------------- +local QUERY_INTERVAL_DEFAULT = 0.5 +local QUERY_PREFIX = "TWT_UDTSv4" +local QUERY_PREFIX_TM = "TWT_UDTSv4_TM" +local API_MARKER = "TWTv4=" +local TM_MARKER = "TMTv1=" +local ADDON_PREFIX = "TWT" +local HISTORY_WINDOW = 10 +local TPS_WINDOW = 5 + +------------------------------------------------------------------------------- +-- State +------------------------------------------------------------------------------- +TE.targets = {} +TE.apiActive = false +TE.apiTargetKey = nil +TE.inCombat = false +TE.tankMode = false + +TE.playerName = UnitName("player") +local _, playerClassToken = UnitClass("player") +TE.playerClass = playerClassToken + +TE.talentCache = {} +TE.stanceModCache = {} + +TE.queryTimer = 0 +TE.queryInterval = QUERY_INTERVAL_DEFAULT + +TE.lastUpdateTime = 0 + +------------------------------------------------------------------------------- +-- String split utility (1.12 compatible) +------------------------------------------------------------------------------- +local function strsplit(sep, str) + local t = {} + local pos = 1 + while true do + local s, e = _find(str, sep, pos, true) + if not s then + local piece = _sub(str, pos) + if piece ~= "" then t[table.getn(t) + 1] = piece end + break + end + local piece = _sub(str, pos, s - 1) + t[table.getn(t) + 1] = piece + pos = e + 1 + end + return t +end + +------------------------------------------------------------------------------- +-- Target key helpers +------------------------------------------------------------------------------- +local function GetTargetKey(unit) + if not unit or not UnitExists(unit) then return nil end + if UnitGUID then + local guid = UnitGUID(unit) + if guid then return "G:" .. guid end + end + local icon = GetRaidTargetIndex and GetRaidTargetIndex(unit) + if icon then return "I:" .. icon end + local name = UnitName(unit) + local hp = UnitHealth(unit) + local hpMax = UnitHealthMax(unit) + if name then return "V:" .. name .. ":" .. (hpMax or 0) end + return nil +end + +local function IsEliteOrBoss(unit) + if not unit or not UnitExists(unit) then return false end + local classification = UnitClassification and UnitClassification(unit) + if classification == "worldboss" or classification == "rareelite" or classification == "elite" then + return true + end + local level = UnitLevel(unit) + if level == -1 then return true end + return false +end + +local function IsInGroup() + return (GetNumRaidMembers() > 0) or (GetNumPartyMembers() > 0) +end + +local function GetChannel() + if GetNumRaidMembers() > 0 then return "RAID" end + if GetNumPartyMembers() > 0 then return "PARTY" end + return nil +end + +------------------------------------------------------------------------------- +-- Talent scanning: cached at combat start +------------------------------------------------------------------------------- +function TE:ScanTalents() + self.talentCache = {} + local class = self.playerClass + local talentDefs = TC.talentMultipliers[class] + if not talentDefs then return end + + for _, def in _pairs(talentDefs) do + local _, _, _, _, rank = GetTalentInfo(def.tab, def.index) + rank = rank or 0 + self.talentCache[def.label] = { + rank = rank, + mod = def.base + rank * def.perPoint, + } + end +end + +function TE:GetTalentMod() + local total = 0 + for _, data in _pairs(self.talentCache) do + total = total + data.mod + end + return 1.0 + total +end + +------------------------------------------------------------------------------- +-- Stance / buff scanning for threat modifier +------------------------------------------------------------------------------- +local STANCE_CACHE_TTL = 2 + +function TE:ScanUnitThreatMod(unit) + if not unit or not UnitExists(unit) then return 1.0, 1.0, false, false end + local stanceMod = 1.0 + local buffMod = 1.0 + local hasRF = false + local hasRockbiter = false + + for i = 1, 32 do + local texture = UnitBuff(unit, i) + if not texture then break end + for _, s in _pairs(TC.stancePatterns) do + if _find(texture, s.pat) then + stanceMod = s.mod + end + end + for _, b in _pairs(TC.buffPatterns) do + if _find(texture, b.pat) then + buffMod = buffMod * b.mod + end + end + if TC.righteousFury and _find(texture, TC.righteousFury.texture) then + hasRF = true + end + if TC.rockbiter and _find(texture, TC.rockbiter.texture) then + hasRockbiter = true + end + end + + return stanceMod, buffMod, hasRF, hasRockbiter +end + +function TE:GetUnitThreatMod(name, unit) + local now = GetTime() + local cached = self.stanceModCache[name] + if cached and (now - cached.time) < STANCE_CACHE_TTL then + return cached.stanceMod, cached.buffMod, cached.hasRF, cached.hasRockbiter + end + + if not unit then + unit = NanamiDPS.Parser and NanamiDPS.Parser:UnitByName(name) or nil + end + + local sm, bm, rf, rb = self:ScanUnitThreatMod(unit) + self.stanceModCache[name] = { + stanceMod = sm, buffMod = bm, hasRF = rf, hasRockbiter = rb, + time = now, + } + return sm, bm, rf, rb +end + +------------------------------------------------------------------------------- +-- Track 2: Local heuristic threat calculation +------------------------------------------------------------------------------- +function TE:CalculateLocalThreat(source, spell, damage, isHeal, sourceClass) + if not damage or damage <= 0 then return 0 end + + local threat = damage + local coeff = spell and TC.spells[spell] + + if isHeal then + threat = damage * TC.HEAL_THREAT_COEFF + elseif coeff then + if coeff.zero then return 0 end + threat = damage * (coeff.mult or 1.0) + (coeff.bonus or 0) + end + + local class = sourceClass + if class and TC.classPassiveMod[class] then + threat = threat * TC.classPassiveMod[class] + end + + local stanceMod, buffMod, hasRF, hasRockbiter = self:GetUnitThreatMod(source) + threat = threat * stanceMod * buffMod + + if hasRF and class == "PALADIN" then + local rfMod = TC.righteousFury.baseMod + local talentDef = TC.righteousFury + if talentDef.talentTab then + local _, _, _, _, rank = GetTalentInfo(talentDef.talentTab, talentDef.talentIndex) + rank = rank or 0 + rfMod = rfMod + rank * talentDef.perPoint + end + threat = threat * rfMod + end + + if hasRockbiter and class == "SHAMAN" then + threat = threat * TC.rockbiter.globalMod + end + + if class == "WARRIOR" or class == "DRUID" or class == "SHAMAN" then + local talentMod = self:GetTalentMod() + if talentMod > 1.0 then + threat = threat * talentMod + end + end + + return _max(threat, 0) +end + +------------------------------------------------------------------------------- +-- 3D data model operations +------------------------------------------------------------------------------- +local function EnsureTarget(targetKey) + if not TE.targets[targetKey] then + TE.targets[targetKey] = { + players = {}, + tankName = nil, + tankThreat = 0, + lastUpdate = GetTime(), + source = "local", + } + end + return TE.targets[targetKey] +end + +local function EnsurePlayer(targetData, playerName) + if not targetData.players[playerName] then + targetData.players[playerName] = { + threat = 0, + tps = 0, + isTanking = false, + isMelee = false, + perc = 0, + history = {}, + lastThreatTime = 0, + } + end + return targetData.players[playerName] +end + +function TE:AddLocalThreat(targetKey, source, amount) + if not targetKey or not source or not amount then return end + + local td = EnsureTarget(targetKey) + if td.source == "api" then return end + + local pd = EnsurePlayer(td, source) + pd.threat = pd.threat + amount + pd.lastThreatTime = GetTime() + + local now = GetTime() + table.insert(pd.history, { time = now, threat = pd.threat }) + local cutoff = now - TPS_WINDOW + while table.getn(pd.history) > 0 and pd.history[1].time < cutoff do + table.remove(pd.history, 1) + end + + if table.getn(pd.history) >= 2 then + local first = pd.history[1] + local last = pd.history[table.getn(pd.history)] + local dt = last.time - first.time + if dt > 0 then + pd.tps = (last.threat - first.threat) / dt + end + end + + td.lastUpdate = now + self:RecalcTankStatus(targetKey) +end + +------------------------------------------------------------------------------- +-- Taunt handling: copy highest threat to the taunter (Growl, Taunt, etc.) +------------------------------------------------------------------------------- +function TE:HandleTaunt(taunterName, targetKey) + if not targetKey then return end + local td = self.targets[targetKey] + if not td then return end + + local maxThreat = 0 + for name, pd in _pairs(td.players) do + if name ~= taunterName and pd.threat > maxThreat then + maxThreat = pd.threat + end + end + + if maxThreat > 0 then + local pd = EnsurePlayer(td, taunterName) + if pd.threat < maxThreat then + pd.threat = maxThreat + pd.isTanking = true + pd.lastThreatTime = GetTime() + end + end + + self:RecalcTankStatus(targetKey) +end + +function TE:WipePlayerThreat(source, wipeType) + for targetKey, td in _pairs(self.targets) do + local pd = td.players[source] + if pd then + if wipeType == "FULL_WIPE" then + pd.threat = 0 + pd.tps = 0 + pd.history = {} + elseif wipeType == "HALF_WIPE" then + pd.threat = pd.threat * 0.5 + elseif wipeType == "TEMP_REDUCE" then + pd.threat = pd.threat * 0.5 + end + end + end +end + +function TE:RecalcTankStatus(targetKey) + local td = self.targets[targetKey] + if not td then return end + + local maxThreat = 0 + local tankName = nil + for name, pd in _pairs(td.players) do + if pd.threat > maxThreat then + maxThreat = pd.threat + tankName = name + end + end + + td.tankName = tankName + td.tankThreat = maxThreat + + for name, pd in _pairs(td.players) do + pd.isTanking = (name == tankName) + pd.perc = maxThreat > 0 and (pd.threat / maxThreat * 100) or 0 + end +end + +------------------------------------------------------------------------------- +-- Track 1: TWThreat API — packet handling +------------------------------------------------------------------------------- +function TE:HandleAPIPacket(rawData) + local threatData = rawData + local tmData = nil + + local hashPos = _find(rawData, "#", 1, true) + if hashPos then + threatData = _sub(rawData, 1, hashPos - 1) + tmData = _sub(rawData, hashPos + 1) + end + + local apiStart = _find(threatData, API_MARKER, 1, true) + if not apiStart then return end + + local playersStr = _sub(threatData, apiStart + _len(API_MARKER)) + local entries = strsplit(";", playersStr) + + local targetKey = self.apiTargetKey or "api_target" + local td = EnsureTarget(targetKey) + td.source = "api" + td.lastUpdate = GetTime() + td.tankName = nil + td.tankThreat = 0 + + local oldPlayers = td.players + td.players = {} + + for _, entry in _pairs(entries) do + local fields = strsplit(":", entry) + if fields[1] and fields[2] and fields[3] and fields[4] and fields[5] then + local name = fields[1] + local isTank = fields[2] == "1" + local threat = _tonumber(fields[3]) or 0 + local perc = _tonumber(fields[4]) or 0 + local isMelee = fields[5] == "1" + + local prevHistory = oldPlayers[name] and oldPlayers[name].history or {} + + local pd = EnsurePlayer(td, name) + pd.threat = threat + pd.isTanking = isTank + pd.perc = perc + pd.isMelee = isMelee + pd.lastThreatTime = GetTime() + + table.insert(prevHistory, { time = GetTime(), threat = threat }) + local cutoff = GetTime() - TPS_WINDOW + while table.getn(prevHistory) > 0 and prevHistory[1].time < cutoff do + table.remove(prevHistory, 1) + end + pd.history = prevHistory + + if table.getn(pd.history) >= 2 then + local first = pd.history[1] + local last = pd.history[table.getn(pd.history)] + local dt = last.time - first.time + if dt > 0 then + pd.tps = (last.threat - first.threat) / dt + end + end + + if isTank then + td.tankName = name + td.tankThreat = threat + end + end + end + + if tmData then + self:HandleTankModePacket(tmData) + end + + self:FireUpdate() +end + +function TE:HandleTankModePacket(raw) + local tmStart = _find(raw, TM_MARKER, 1, true) + if not tmStart then return end + + local dataStr = _sub(raw, tmStart + _len(TM_MARKER)) + local entries = strsplit(";", dataStr) + + for _, entry in _pairs(entries) do + local fields = strsplit(":", entry) + if fields[1] and fields[2] and fields[3] and fields[4] then + local creature = fields[1] + local guid = fields[2] + local name = fields[3] + local perc = _tonumber(fields[4]) or 0 + + local targetKey = "G:" .. guid + local td = EnsureTarget(targetKey) + td.source = "api_tm" + td.lastUpdate = GetTime() + + local pd = EnsurePlayer(td, name) + pd.perc = perc + pd.isTanking = true + td.tankName = name + end + end +end + +------------------------------------------------------------------------------- +-- API query ticker +------------------------------------------------------------------------------- +function TE:SendQuery() + if not self.inCombat then return end + if not IsInGroup() then return end + + local unit = "target" + if not UnitExists(unit) or UnitIsPlayer(unit) or not UnitAffectingCombat(unit) then + return + end + + self.apiTargetKey = GetTargetKey(unit) + + local channel = GetChannel() + if not channel then return end + + local prefix = self.tankMode and QUERY_PREFIX_TM or QUERY_PREFIX + local limit = 10 + SendAddonMessage(prefix, "limit=" .. limit, channel) +end + +------------------------------------------------------------------------------- +-- Query result: OT analysis for current player +------------------------------------------------------------------------------- +function TE:GetOTStatus(targetKey) + local td = self.targets[targetKey] + if not td then + return { safe = true, pct = 0, threshold = 0, buffer = 0, tankName = nil } + end + + local myData = td.players[self.playerName] + if not myData then + return { safe = true, pct = 0, threshold = 0, buffer = 0, tankName = td.tankName } + end + + local tankThreat = td.tankThreat + if tankThreat <= 0 then + return { safe = true, pct = 0, threshold = 0, buffer = 0, tankName = td.tankName } + end + + local threshold = myData.isMelee and TC.OT_MELEE_THRESHOLD or TC.OT_RANGED_THRESHOLD + local otPoint = tankThreat * threshold + local myThreat = myData.threat + local pct = myThreat / otPoint * 100 + local buffer = otPoint - myThreat + + return { + safe = myThreat < otPoint, + pct = pct, + threshold = threshold, + otPoint = otPoint, + buffer = buffer, + myThreat = myThreat, + tankThreat = tankThreat, + tankName = td.tankName, + isMelee = myData.isMelee, + } +end + +------------------------------------------------------------------------------- +-- Get sorted threat list for display +------------------------------------------------------------------------------- +function TE:GetThreatList(targetKey) + local td = self.targets[targetKey] + if not td then return {} end + + local list = {} + for name, pd in _pairs(td.players) do + table.insert(list, { + name = name, + threat = pd.threat, + tps = pd.tps, + perc = pd.perc, + isTanking = pd.isTanking, + isMelee = pd.isMelee, + }) + end + + table.sort(list, function(a, b) return a.threat > b.threat end) + + local top = list[1] and list[1].threat or 0 + for _, entry in _pairs(list) do + entry.relativePercent = top > 0 and (entry.threat / top * 100) or 0 + end + + return list +end + +function TE:GetActiveTargetKey() + if self.apiActive and self.apiTargetKey then + return self.apiTargetKey + end + return GetTargetKey("target") +end + +------------------------------------------------------------------------------- +-- Cleanup stale data +------------------------------------------------------------------------------- +function TE:PurgeStaleTargets() + local now = GetTime() + local staleThreshold = 30 + local keysToRemove = {} + for key, td in _pairs(self.targets) do + if (now - td.lastUpdate) > staleThreshold then + keysToRemove[table.getn(keysToRemove) + 1] = key + end + end + for _, key in _pairs(keysToRemove) do + self.targets[key] = nil + end +end + +------------------------------------------------------------------------------- +-- Refresh callback +------------------------------------------------------------------------------- +function TE:FireUpdate() + NanamiDPS:FireCallback("threat_update") + self.lastUpdateTime = GetTime() +end + +------------------------------------------------------------------------------- +-- Event registration & main loop +------------------------------------------------------------------------------- +TE:RegisterEvent("CHAT_MSG_ADDON") +TE:RegisterEvent("PLAYER_REGEN_DISABLED") +TE:RegisterEvent("PLAYER_REGEN_ENABLED") +TE:RegisterEvent("PLAYER_TARGET_CHANGED") + +TE:SetScript("OnEvent", function() + if event == "CHAT_MSG_ADDON" then + if arg2 and _find(arg2, API_MARKER, 1, true) then + TE:HandleAPIPacket(arg2) + TE.apiActive = true + end + return + end + + if event == "PLAYER_REGEN_DISABLED" then + TE.inCombat = true + TE:ScanTalents() + TE.stanceModCache = {} + return + end + + if event == "PLAYER_REGEN_ENABLED" then + TE.inCombat = false + TE.apiActive = false + TE.apiTargetKey = nil + return + end + + if event == "PLAYER_TARGET_CHANGED" then + TE.apiTargetKey = GetTargetKey("target") + if TE.inCombat and UnitExists("target") and not UnitIsPlayer("target") then + local td = TE.targets[TE.apiTargetKey] + if not td or td.source ~= "api" then + TE.apiActive = false + end + end + return + end +end) + +------------------------------------------------------------------------------- +-- Threat-clearing buff detection via direct buff scanning +-- More reliable than combat log text matching for abilities like +-- Feign Death, Vanish, etc. +------------------------------------------------------------------------------- +TE.threatWipeBuffs = { + { pat = "Ability_Rogue_FeignDeath", wipe = "FULL_WIPE" }, + { pat = "FeignDeath", wipe = "FULL_WIPE" }, + { pat = "Ability_Vanish", wipe = "FULL_WIPE" }, + { pat = "Spell_Shadow_Possession", wipe = "TEMP_REDUCE" }, + { pat = "Spell_Holy_FadeAway", wipe = "TEMP_REDUCE" }, +} +TE.lastWipeCheck = 0 +TE.wipeActive = {} + +function TE:CheckThreatWipeBuffs() + local now = GetTime() + if (now - self.lastWipeCheck) < 0.3 then return end + self.lastWipeCheck = now + + local unit = "player" + if not UnitExists(unit) then return end + + for _, def in _pairs(self.threatWipeBuffs) do + local found = false + for i = 1, 32 do + local tex = UnitBuff(unit, i) + if not tex then break end + if _find(tex, def.pat) then + found = true + break + end + end + + if found and not self.wipeActive[def.pat] then + self.wipeActive[def.pat] = true + self:WipePlayerThreat(self.playerName, def.wipe) + + local DS = NanamiDPS and NanamiDPS.DataStore + if DS then + local current = DS:GetPlayerThreat(self.playerName) + if current > 0 then + DS:AddThreat(self.playerName, -current) + end + end + elseif not found and self.wipeActive[def.pat] then + self.wipeActive[def.pat] = nil + end + end +end + +------------------------------------------------------------------------------- +-- targettarget heuristic: if the mob is attacking entity X, then X must +-- have the highest threat. This corrects for pet Growl and other taunt +-- effects that don't produce combat log damage. +------------------------------------------------------------------------------- +TE.lastToTCheck = 0 +local TOT_CHECK_INTERVAL = 0.5 + +function TE:CheckTargetOfTarget() + local now = GetTime() + if (now - self.lastToTCheck) < TOT_CHECK_INTERVAL then return end + self.lastToTCheck = now + + if not UnitExists("target") or UnitIsPlayer("target") then return end + if not UnitExists("targettarget") then return end + + local targetKey = self:GetCurrentTargetKey() + if not targetKey then return end + if self:IsAPIActiveForTarget(targetKey) then return end + + local td = self.targets[targetKey] + if not td then return end + + local totName = UnitName("targettarget") + if not totName then return end + + local totData = td.players[totName] + if not totData then + for pName, _ in _pairs(td.players) do + if _find(pName, totName, 1, true) then + totData = td.players[pName] + totName = pName + break + end + end + end + + if not totData then + totData = EnsurePlayer(td, totName) + end + + local maxOther = 0 + for name, pd in _pairs(td.players) do + if name ~= totName and pd.threat > maxOther then + maxOther = pd.threat + end + end + + if totData.threat < maxOther then + totData.threat = maxOther + totData.isTanking = true + totData.lastThreatTime = now + self:RecalcTankStatus(targetKey) + elseif not totData.isTanking then + totData.isTanking = true + self:RecalcTankStatus(targetKey) + end +end + +TE:SetScript("OnUpdate", function() + if not TE.inCombat then return end + + local now = GetTime() + + TE:CheckThreatWipeBuffs() + TE:CheckTargetOfTarget() + + if (now - TE.queryTimer) >= TE.queryInterval then + TE.queryTimer = now + TE:SendQuery() + end + + if (now - TE.lastUpdateTime) >= 0.25 then + TE:FireUpdate() + end + + if mod(_floor(now), 10) == 0 and _floor(now) ~= _floor(TE.queryTimer - TE.queryInterval) then + TE:PurgeStaleTargets() + end +end) + +------------------------------------------------------------------------------- +-- Public helpers for Parser integration +------------------------------------------------------------------------------- +function TE:IsAPIActiveForTarget(targetKey) + if not targetKey then return false end + local td = self.targets[targetKey] + return td and td.source == "api" +end + +function TE:GetCurrentTargetKey() + return GetTargetKey("target") +end + +TE.GetTargetKey = GetTargetKey +TE.IsEliteOrBoss = IsEliteOrBoss +TE.IsInGroup = IsInGroup + +------------------------------------------------------------------------------- +-- Public API: NanamiDPS.ThreatEngine:QueryUnitThreat(unitID) +-- +-- Returns a table with the caller's threat data for the given unit, or nil. +-- Intended for external addons (e.g., nameplate addons) to query threat info. +-- +-- @param unitID string WoW unit ID (e.g., "target", "mouseover", etc.) +-- @return table or nil { pct, threat, tankName, tankThreat, isTanking, +-- isMelee, source, secondName, secondPct } +------------------------------------------------------------------------------- +function TE:QueryUnitThreat(unitID) + if not unitID or not UnitExists(unitID) then return nil end + if not self.inCombat then return nil end + + local targetKey = GetTargetKey(unitID) + if not targetKey then return nil end + + local td = self.targets[targetKey] + if not td then return nil end + + local myData = td.players[self.playerName] + local myThreat = myData and myData.threat or 0 + local myPct = myData and myData.perc or 0 + + local secondName, secondThreat, secondPct = nil, 0, 0 + local sorted = {} + for name, pd in _pairs(td.players) do + table.insert(sorted, { name = name, threat = pd.threat }) + end + table.sort(sorted, function(a, b) return a.threat > b.threat end) + if sorted[2] then + secondName = sorted[2].name + secondThreat = sorted[2].threat + secondPct = td.tankThreat > 0 and (secondThreat / td.tankThreat * 100) or 0 + end + + return { + pct = myPct, + threat = myThreat, + tankName = td.tankName, + tankThreat = td.tankThreat, + isTanking = myData and myData.isTanking or false, + isMelee = myData and myData.isMelee or false, + source = td.source, + secondName = secondName, + secondPct = secondPct, + secondThreat = secondThreat, + } +end + +------------------------------------------------------------------------------- +-- Public API: NanamiDPS.ThreatEngine:QueryNameThreat(targetKey, playerName) +-- +-- Query a specific player's threat on a specific target. +-- @param targetKey string Target key from GetTargetKey() +-- @param playerName string Player name +-- @return number, boolean, number threat, isTanking, pct +------------------------------------------------------------------------------- +function TE:QueryNameThreat(targetKey, playerName) + if not targetKey or not playerName then return 0, false, 0 end + local td = self.targets[targetKey] + if not td then return 0, false, 0 end + local pd = td.players[playerName] + if not pd then return 0, false, 0 end + return pd.threat, pd.isTanking, pd.perc +end diff --git a/Window.lua b/Window.lua index 9dab9a0..0db3706 100644 --- a/Window.lua +++ b/Window.lua @@ -17,7 +17,32 @@ local SNAP_THRESHOLD = 12 ----------------------------------------------------------------------- local MAX_DROPDOWN_VISIBLE = 8 +local dropdownOverlay = CreateFrame("Button", "NanamiDPS_DropdownOverlay", UIParent) +dropdownOverlay:SetFrameStrata("FULLSCREEN") +dropdownOverlay:SetAllPoints(UIParent) +dropdownOverlay:EnableMouse(true) +dropdownOverlay:Hide() +dropdownOverlay.activeDropdown = nil + +dropdownOverlay:SetScript("OnClick", function() + if dropdownOverlay.activeDropdown then + dropdownOverlay.activeDropdown:Hide() + end + dropdownOverlay.activeDropdown = nil + dropdownOverlay:Hide() +end) + +local function HideActiveDropdown() + if dropdownOverlay.activeDropdown then + dropdownOverlay.activeDropdown:Hide() + end + dropdownOverlay.activeDropdown = nil + dropdownOverlay:Hide() +end + local function ShowDropdown(parent, items, onClick, anchorPoint, relTo, relPoint, xOff, yOff) + HideActiveDropdown() + if parent.dropdown and parent.dropdown:IsShown() then parent.dropdown:Hide() return @@ -75,7 +100,7 @@ local function ShowDropdown(parent, items, onClick, anchorPoint, relTo, relPoint btn:SetScript("OnClick", function() if this.itemData then dd.onClickFn(this.itemData) - dd:Hide() + HideActiveDropdown() end end) @@ -144,6 +169,8 @@ local function ShowDropdown(parent, items, onClick, anchorPoint, relTo, relPoint dd.scrollDownIndicator:Hide() end + dropdownOverlay.activeDropdown = dd + dropdownOverlay:Show() dd:Show() end @@ -384,11 +411,60 @@ function Window:Create(wid) end) resetBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) + -- Pause button + local pauseBtn = CreateFrame("Button", nil, frame.titleBar) + pauseBtn:SetWidth(16) + pauseBtn:SetHeight(16) + pauseBtn:SetPoint("RIGHT", resetBtn, "LEFT", -1, 0) + local pauseIcon = SFrames:CreateFontString(pauseBtn, 9, "CENTER") + pauseIcon:SetAllPoints() + pauseIcon:SetText("||") + if A and A.text then + pauseIcon:SetTextColor(A.text[1], A.text[2], A.text[3]) + end + frame.pauseBtn = pauseBtn + + local function UpdatePauseVisual() + if cfg.paused then + if A and A.accent then + pauseIcon:SetTextColor(A.accent[1], A.accent[2], A.accent[3]) + else + pauseIcon:SetTextColor(1, 0.5, 0.5) + end + pauseIcon:SetText("|cffff4444>||") + else + if A and A.text then + pauseIcon:SetTextColor(A.text[1], A.text[2], A.text[3]) + else + pauseIcon:SetTextColor(1, 1, 1) + end + pauseIcon:SetText("||") + end + end + UpdatePauseVisual() + + frame.updatePauseVisual = UpdatePauseVisual + + pauseBtn:SetScript("OnClick", function() + cfg.paused = not cfg.paused + Window:UpdatePausedAlpha() + end) + pauseBtn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + if cfg.paused then + GameTooltip:AddLine(L["Resume"]) + else + GameTooltip:AddLine(L["Pause"]) + end + GameTooltip:Show() + end) + pauseBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) + -- Report button local reportBtn = CreateFrame("Button", nil, frame.titleBar) reportBtn:SetWidth(16) reportBtn:SetHeight(16) - reportBtn:SetPoint("RIGHT", resetBtn, "LEFT", -1, 0) + reportBtn:SetPoint("RIGHT", pauseBtn, "LEFT", -1, 0) local reportIcon = SFrames:CreateFontString(reportBtn, 9, "CENTER") reportIcon:SetAllPoints() reportIcon:SetText("R") @@ -464,6 +540,7 @@ function Window:Create(wid) ShowDropdown(frame.btnSegment, items, function(item) frame.segmentIndex = item.index frame.btnSegment.text:SetText(item.text) + Window:SavePosition(frame) Window:RefreshWindow(frame) end, "TOPLEFT", frame.btnSegment, "BOTTOMLEFT", 0, -2) end) @@ -503,6 +580,7 @@ function Window:Create(wid) frame.activeModuleName = item.moduleName frame.btnMode.text:SetText(item.text) frame.scroll = 0 + Window:SavePosition(frame) Window:RefreshWindow(frame) end, "TOPLEFT", frame.btnMode, "BOTTOMLEFT", 0, -2) end) @@ -514,6 +592,32 @@ function Window:Create(wid) frame.content:SetPoint("TOPLEFT", frame.selectorBar, "BOTTOMLEFT", 0, -1) frame.content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -4, 4) + -- Paused overlay text (created after content frame) + frame.pausedOverlay = frame.content:CreateFontString(nil, "OVERLAY") + frame.pausedOverlay:SetFont(STANDARD_TEXT_FONT, 14, "OUTLINE") + frame.pausedOverlay:SetPoint("CENTER", frame.content, "CENTER", 0, 0) + frame.pausedOverlay:SetText("|cffff6666" .. L["Paused"]) + frame.pausedOverlay:SetAlpha(0.7) + if cfg.paused then + frame.pausedOverlay:Show() + else + frame.pausedOverlay:Hide() + end + + --------------------------------------------------------------- + -- Pause alpha: restore on hover, reduce on leave + --------------------------------------------------------------- + frame:SetScript("OnEnter", function() + if cfg.paused then + this:SetAlpha(cfg.backdropAlpha or 0.92) + end + end) + frame:SetScript("OnLeave", function() + if cfg.paused and not MouseIsOver(this) then + this:SetAlpha(cfg.pausedAlpha or 0.35) + end + end) + --------------------------------------------------------------- -- Scroll wheel --------------------------------------------------------------- @@ -672,6 +776,8 @@ function Window:SavePosition(frame) point = point, relativePoint = relPoint, xOfs = xOfs, yOfs = yOfs, w = fw, h = fh, + moduleName = frame.activeModuleName, + segmentIndex = frame.segmentIndex, } frame:ClearAllPoints() @@ -705,6 +811,28 @@ function Window:LoadPosition(frame) frame:ClearAllPoints() frame:SetPoint("RIGHT", UIParent, "RIGHT", -80, -50) end + + if pos then + if pos.moduleName and NanamiDPS.modules[pos.moduleName] then + frame.activeModuleName = pos.moduleName + local mod = NanamiDPS.modules[pos.moduleName] + if mod and mod.GetName and frame.btnMode then + frame.btnMode.text:SetText(mod:GetName()) + end + end + if pos.segmentIndex ~= nil then + frame.segmentIndex = pos.segmentIndex + if frame.btnSegment then + local segList = DataStore:GetSegmentList() + for _, s in ipairs(segList) do + if s.index == pos.segmentIndex then + frame.btnSegment.text:SetText(s.name) + break + end + end + end + end + end end ----------------------------------------------------------------------- @@ -762,6 +890,28 @@ function Window:RefreshWindow(frame, force) BarDisplay:LayoutBars(frame) end +function Window:UpdatePausedAlpha() + local cfg = NanamiDPS.config or {} + for _, win in pairs(activeWindows) do + if win and win:IsShown() then + if cfg.paused then + win:SetAlpha(cfg.pausedAlpha or 0.35) + if win.pausedOverlay then + win.pausedOverlay:Show() + end + else + win:SetAlpha(1.0) + if win.pausedOverlay then + win.pausedOverlay:Hide() + end + end + if win.updatePauseVisual then + win.updatePauseVisual() + end + end + end +end + function Window:RefreshAll(force) for _, win in pairs(activeWindows) do if win and win:IsShown() then @@ -774,26 +924,35 @@ end -- Initialization ----------------------------------------------------------------------- NanamiDPS:RegisterCallback("INIT", "Window", function() - local win = Window:Create(1) - activeWindows[1] = win - NanamiDPS.windows = activeWindows - NanamiDPS:RegisterCallback("refresh", "WindowRefresh", function() for _, w in pairs(activeWindows) do if w then w.needsRefresh = true end end end) - Window:LoadPosition(win) - local cfg = NanamiDPS.config or {} - if cfg.visible ~= false then - win:Show() - else - win:Hide() + local savedWindowCount = 1 + if NanamiDPS_DB and NanamiDPS_DB.windowPositions then + for wid, _ in pairs(NanamiDPS_DB.windowPositions) do + if type(wid) == "number" and wid > savedWindowCount then + savedWindowCount = wid + end + end end - Window:RefreshWindow(win, true) + for i = 1, savedWindowCount do + local win = Window:Create(i) + activeWindows[i] = win + Window:LoadPosition(win) + if cfg.visible ~= false then + win:Show() + else + win:Hide() + end + Window:RefreshWindow(win, true) + end + + NanamiDPS.windows = activeWindows end) ----------------------------------------------------------------------- @@ -828,6 +987,9 @@ function Window:DestroyWindow(wid) if activeWindows[wid] then activeWindows[wid]:Hide() activeWindows[wid] = nil + if NanamiDPS_DB and NanamiDPS_DB.windowPositions then + NanamiDPS_DB.windowPositions[wid] = nil + end end end