调整dps插件对仇恨的估算方式
优化dps插件
This commit is contained in:
13
.cursor/mcp.json
Normal file
13
.cursor/mcp.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
.cursor/rules/mcp-messenger.mdc
Normal file
27
.cursor/rules/mcp-messenger.mdc
Normal file
@@ -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` 仅用于任务中需要用户做选择时(如选框架、选方案)。
|
||||||
6
Core.lua
6
Core.lua
@@ -18,9 +18,13 @@ local defaultConfig = {
|
|||||||
mergePets = true,
|
mergePets = true,
|
||||||
visible = true,
|
visible = true,
|
||||||
locked = false,
|
locked = false,
|
||||||
maxSegments = 10,
|
maxSegments = 20,
|
||||||
backdropAlpha = 0.92,
|
backdropAlpha = 0.92,
|
||||||
showClassIcons = true,
|
showClassIcons = true,
|
||||||
|
paused = false,
|
||||||
|
pausedAlpha = 0.35,
|
||||||
|
otWarning = false,
|
||||||
|
nameplateThreat = false,
|
||||||
}
|
}
|
||||||
|
|
||||||
function NanamiDPS:RegisterModule(name, mod)
|
function NanamiDPS:RegisterModule(name, mod)
|
||||||
|
|||||||
@@ -196,9 +196,16 @@ function DataStore:AddThreat(source, amount)
|
|||||||
d[source] = { _sum = 0 }
|
d[source] = { _sum = 0 }
|
||||||
end
|
end
|
||||||
d[source]._sum = d[source]._sum + amount
|
d[source]._sum = d[source]._sum + amount
|
||||||
|
if d[source]._sum < 0 then d[source]._sum = 0 end
|
||||||
end
|
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)
|
function DataStore:UpdateActivity(source, timestamp)
|
||||||
if not source then return end
|
if not source then return end
|
||||||
local segs = { self.current, self.total }
|
local segs = { self.current, self.total }
|
||||||
|
|||||||
32
Locale.lua
32
Locale.lua
@@ -107,6 +107,22 @@ L["Enter Whisper Target"] = "Enter player name"
|
|||||||
L["Owner"] = "Owner"
|
L["Owner"] = "Owner"
|
||||||
L["Threat Note"] = "Includes spell-specific threat modifiers"
|
L["Threat Note"] = "Includes spell-specific threat modifiers"
|
||||||
L["Drag to Resize"] = "Drag to resize"
|
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
|
if locale == "zhCN" or locale == "zhTW" then
|
||||||
L["Damage Done"] = "造成伤害"
|
L["Damage Done"] = "造成伤害"
|
||||||
@@ -213,4 +229,20 @@ if locale == "zhCN" or locale == "zhTW" then
|
|||||||
L["Owner"] = "主人"
|
L["Owner"] = "主人"
|
||||||
L["Threat Note"] = "已计入技能仇恨系数"
|
L["Threat Note"] = "已计入技能仇恨系数"
|
||||||
L["Drag to Resize"] = "拖拽调整大小"
|
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
|
end
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ function ThreatEstimate:GetName()
|
|||||||
return L["Threat (Est.)"]
|
return L["Threat (Est.)"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
-- Resolve pet owner for display
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
local function ResolvePetOwner(name)
|
local function ResolvePetOwner(name)
|
||||||
local stored = DataStore:GetClass(name)
|
local stored = DataStore:GetClass(name)
|
||||||
if stored and not NanamiDPS.validClasses[stored] and stored ~= "__other__" then
|
if stored and not NanamiDPS.validClasses[stored] and stored ~= "__other__" then
|
||||||
@@ -19,10 +22,84 @@ local function ResolvePetOwner(name)
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
end
|
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)
|
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
|
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
|
for name, entry in pairs(segment.data.threat) do
|
||||||
local class = DataStore:GetClass(name)
|
local class = DataStore:GetClass(name)
|
||||||
local r, g, b = NanamiDPS.GetClassColor(class)
|
local r, g, b = NanamiDPS.GetClassColor(class)
|
||||||
@@ -30,7 +107,7 @@ function ThreatEstimate:GetBars(segment)
|
|||||||
local ownerName, ownerClass = ResolvePetOwner(name)
|
local ownerName, ownerClass = ResolvePetOwner(name)
|
||||||
|
|
||||||
if NanamiDPS.validClasses[class] then
|
if NanamiDPS.validClasses[class] then
|
||||||
-- player: use class color as-is
|
-- use class color as-is
|
||||||
elseif ownerName and ownerClass then
|
elseif ownerName and ownerClass then
|
||||||
r, g, b = NanamiDPS.GetClassColor(ownerClass)
|
r, g, b = NanamiDPS.GetClassColor(ownerClass)
|
||||||
r, g, b = r * 0.7, g * 0.7, b * 0.7
|
r, g, b = r * 0.7, g * 0.7, b * 0.7
|
||||||
@@ -61,7 +138,58 @@ function ThreatEstimate:GetBars(segment)
|
|||||||
return bars
|
return bars
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
-- Tooltip: show detailed threat breakdown
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
function ThreatEstimate:GetTooltip(playerName, segment, tooltip)
|
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
|
if not segment or not segment.data.threat[playerName] then return end
|
||||||
local entry = segment.data.threat[playerName]
|
local entry = segment.data.threat[playerName]
|
||||||
local ownerName, ownerClass = ResolvePetOwner(playerName)
|
local ownerName, ownerClass = ResolvePetOwner(playerName)
|
||||||
@@ -95,6 +223,9 @@ function ThreatEstimate:GetTooltip(playerName, segment, tooltip)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
-- Report
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
function ThreatEstimate:GetReportLines(segment, count)
|
function ThreatEstimate:GetReportLines(segment, count)
|
||||||
local bars = self:GetBars(segment)
|
local bars = self:GetBars(segment)
|
||||||
local lines = {}
|
local lines = {}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ Core.lua
|
|||||||
Locale.lua
|
Locale.lua
|
||||||
Utils.lua
|
Utils.lua
|
||||||
DataStore.lua
|
DataStore.lua
|
||||||
|
ThreatCoefficients.lua
|
||||||
|
ThreatEngine.lua
|
||||||
Parser.lua
|
Parser.lua
|
||||||
ParserVanilla.lua
|
ParserVanilla.lua
|
||||||
Modules\DamageDone.lua
|
Modules\DamageDone.lua
|
||||||
@@ -27,6 +29,7 @@ Modules\Activity.lua
|
|||||||
Modules\EnemyDamageDone.lua
|
Modules\EnemyDamageDone.lua
|
||||||
Modules\DamageBySpell.lua
|
Modules\DamageBySpell.lua
|
||||||
Modules\HealingBySpell.lua
|
Modules\HealingBySpell.lua
|
||||||
|
ThreatDisplay.lua
|
||||||
BarDisplay.lua
|
BarDisplay.lua
|
||||||
Tooltip.lua
|
Tooltip.lua
|
||||||
Window.lua
|
Window.lua
|
||||||
|
|||||||
234
NanamiPlates-ThreatAPI.md
Normal file
234
NanamiPlates-ThreatAPI.md
Normal file
@@ -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:<GUID>"` — 当 UnitGUID 可用时(最准确)
|
||||||
|
- `"I:<RaidIcon>"` — 当目标有团队标记时
|
||||||
|
- `"V:<Name>:<MaxHP>"` — 通过名称+最大生命值虚拟标识
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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()` 而非 `%`
|
||||||
221
NanamiPlates-ThreatIntegration.md
Normal file
221
NanamiPlates-ThreatIntegration.md
Normal file
@@ -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`)
|
||||||
10
Options.lua
10
Options.lua
@@ -546,6 +546,16 @@ function Options:CreatePanel()
|
|||||||
CreateStyledCheckbox(page2, L["Merge Pets"], 8, y,
|
CreateStyledCheckbox(page2, L["Merge Pets"], 8, y,
|
||||||
function() return cfg.mergePets end,
|
function() return cfg.mergePets end,
|
||||||
function(v) cfg.mergePets = v 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
|
y = y - 36
|
||||||
|
|
||||||
CreateSectionHeader(page2, L["Max Segments"], y)
|
CreateSectionHeader(page2, L["Max Segments"], y)
|
||||||
|
|||||||
184
Parser.lua
184
Parser.lua
@@ -16,163 +16,22 @@ Parser.validUnits = validUnits
|
|||||||
Parser.validPets = validPets
|
Parser.validPets = validPets
|
||||||
|
|
||||||
-----------------------------------------------------------------------
|
-----------------------------------------------------------------------
|
||||||
-- Spell threat coefficients (vanilla / classic values, max rank)
|
-- Threat calculation now delegated to ThreatEngine + ThreatCoefficients.
|
||||||
-- Reference: classic-warrior wiki, warcrafttavern.com
|
-- The engine handles stance/buff scanning, talent multipliers, and the
|
||||||
-- bonus = fixed threat added ON TOP of damage
|
-- complete spell coefficient database. These wrappers remain for
|
||||||
-- mult = multiplier applied to the DAMAGE portion (default 1.0)
|
-- 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)
|
function Parser:CalculateSpellThreat(source, spell, damage)
|
||||||
if not damage or damage <= 0 then return 0 end
|
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)
|
local class = DataStore:GetClass(source)
|
||||||
if class and classThreatMod[class] then
|
return NanamiDPS.ThreatEngine:CalculateLocalThreat(source, spell, damage, false, class)
|
||||||
threat = threat * classThreatMod[class]
|
|
||||||
end
|
|
||||||
|
|
||||||
threat = threat * self:GetUnitThreatMod(source)
|
|
||||||
|
|
||||||
return math.max(threat, 0)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
function Parser:CalculateHealThreat(source, amount)
|
function Parser:CalculateHealThreat(source, amount)
|
||||||
if not amount or amount <= 0 then return 0 end
|
if not amount or amount <= 0 then return 0 end
|
||||||
|
|
||||||
local threat = amount * 0.5
|
|
||||||
|
|
||||||
local class = DataStore:GetClass(source)
|
local class = DataStore:GetClass(source)
|
||||||
if class and classThreatMod[class] then
|
return NanamiDPS.ThreatEngine:CalculateLocalThreat(source, nil, amount, true, class)
|
||||||
threat = threat * classThreatMod[class]
|
|
||||||
end
|
|
||||||
|
|
||||||
threat = threat * self:GetUnitThreatMod(source)
|
|
||||||
|
|
||||||
return threat
|
|
||||||
end
|
end
|
||||||
|
|
||||||
local unit_cache = {}
|
local unit_cache = {}
|
||||||
@@ -257,6 +116,7 @@ function Parser:ResolveSource(source)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Parser:ProcessDamage(source, spell, target, amount, school)
|
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
|
if type(source) ~= "string" or not tonumber(amount) then return end
|
||||||
source = NanamiDPS.trim(source)
|
source = NanamiDPS.trim(source)
|
||||||
amount = tonumber(amount)
|
amount = tonumber(amount)
|
||||||
@@ -279,7 +139,22 @@ function Parser:ProcessDamage(source, spell, target, amount, school)
|
|||||||
DataStore:AddDamageTaken(target, finalSpell, resolvedSource, amount, school)
|
DataStore:AddDamageTaken(target, finalSpell, resolvedSource, amount, school)
|
||||||
end
|
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())
|
DataStore:UpdateActivity(resolvedSource, GetTime())
|
||||||
|
|
||||||
if targetType == "PLAYER" then
|
if targetType == "PLAYER" then
|
||||||
@@ -297,6 +172,7 @@ function Parser:ProcessDamage(source, spell, target, amount, school)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Parser:ProcessHealing(source, spell, target, amount, school)
|
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
|
if type(source) ~= "string" or not tonumber(amount) then return end
|
||||||
source = NanamiDPS.trim(source)
|
source = NanamiDPS.trim(source)
|
||||||
amount = tonumber(amount)
|
amount = tonumber(amount)
|
||||||
@@ -318,7 +194,17 @@ function Parser:ProcessHealing(source, spell, target, amount, school)
|
|||||||
|
|
||||||
DataStore:AddHealing(resolvedSource, finalSpell, target, amount, effective)
|
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())
|
DataStore:UpdateActivity(resolvedSource, GetTime())
|
||||||
|
|
||||||
local targetType = self:ScanName(target)
|
local targetType = self:ScanName(target)
|
||||||
|
|||||||
@@ -385,6 +385,24 @@ for pat in pairs(dispel_parser) do
|
|||||||
sanitize(pat)
|
sanitize(pat)
|
||||||
end
|
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
|
-- Main event handler
|
||||||
-----------------------------------------------------------------------
|
-----------------------------------------------------------------------
|
||||||
@@ -415,6 +433,36 @@ Parser:SetScript("OnEvent", function()
|
|||||||
return
|
return
|
||||||
end
|
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
|
-- Strip absorb/resist suffixes
|
||||||
if absorb then arg1 = string.gsub(arg1, absorb, empty) end
|
if absorb then arg1 = string.gsub(arg1, absorb, empty) end
|
||||||
if resist then arg1 = string.gsub(arg1, resist, empty) end
|
if resist then arg1 = string.gsub(arg1, resist, empty) end
|
||||||
|
|||||||
264
ThreatCoefficients.lua
Normal file
264
ThreatCoefficients.lua
Normal file
@@ -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
|
||||||
384
ThreatDisplay.lua
Normal file
384
ThreatDisplay.lua
Normal file
@@ -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)
|
||||||
863
ThreatEngine.lua
Normal file
863
ThreatEngine.lua
Normal file
@@ -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
|
||||||
180
Window.lua
180
Window.lua
@@ -17,7 +17,32 @@ local SNAP_THRESHOLD = 12
|
|||||||
-----------------------------------------------------------------------
|
-----------------------------------------------------------------------
|
||||||
local MAX_DROPDOWN_VISIBLE = 8
|
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)
|
local function ShowDropdown(parent, items, onClick, anchorPoint, relTo, relPoint, xOff, yOff)
|
||||||
|
HideActiveDropdown()
|
||||||
|
|
||||||
if parent.dropdown and parent.dropdown:IsShown() then
|
if parent.dropdown and parent.dropdown:IsShown() then
|
||||||
parent.dropdown:Hide()
|
parent.dropdown:Hide()
|
||||||
return
|
return
|
||||||
@@ -75,7 +100,7 @@ local function ShowDropdown(parent, items, onClick, anchorPoint, relTo, relPoint
|
|||||||
btn:SetScript("OnClick", function()
|
btn:SetScript("OnClick", function()
|
||||||
if this.itemData then
|
if this.itemData then
|
||||||
dd.onClickFn(this.itemData)
|
dd.onClickFn(this.itemData)
|
||||||
dd:Hide()
|
HideActiveDropdown()
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@@ -144,6 +169,8 @@ local function ShowDropdown(parent, items, onClick, anchorPoint, relTo, relPoint
|
|||||||
dd.scrollDownIndicator:Hide()
|
dd.scrollDownIndicator:Hide()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
dropdownOverlay.activeDropdown = dd
|
||||||
|
dropdownOverlay:Show()
|
||||||
dd:Show()
|
dd:Show()
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -384,11 +411,60 @@ function Window:Create(wid)
|
|||||||
end)
|
end)
|
||||||
resetBtn:SetScript("OnLeave", function() GameTooltip:Hide() 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
|
-- Report button
|
||||||
local reportBtn = CreateFrame("Button", nil, frame.titleBar)
|
local reportBtn = CreateFrame("Button", nil, frame.titleBar)
|
||||||
reportBtn:SetWidth(16)
|
reportBtn:SetWidth(16)
|
||||||
reportBtn:SetHeight(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")
|
local reportIcon = SFrames:CreateFontString(reportBtn, 9, "CENTER")
|
||||||
reportIcon:SetAllPoints()
|
reportIcon:SetAllPoints()
|
||||||
reportIcon:SetText("R")
|
reportIcon:SetText("R")
|
||||||
@@ -464,6 +540,7 @@ function Window:Create(wid)
|
|||||||
ShowDropdown(frame.btnSegment, items, function(item)
|
ShowDropdown(frame.btnSegment, items, function(item)
|
||||||
frame.segmentIndex = item.index
|
frame.segmentIndex = item.index
|
||||||
frame.btnSegment.text:SetText(item.text)
|
frame.btnSegment.text:SetText(item.text)
|
||||||
|
Window:SavePosition(frame)
|
||||||
Window:RefreshWindow(frame)
|
Window:RefreshWindow(frame)
|
||||||
end, "TOPLEFT", frame.btnSegment, "BOTTOMLEFT", 0, -2)
|
end, "TOPLEFT", frame.btnSegment, "BOTTOMLEFT", 0, -2)
|
||||||
end)
|
end)
|
||||||
@@ -503,6 +580,7 @@ function Window:Create(wid)
|
|||||||
frame.activeModuleName = item.moduleName
|
frame.activeModuleName = item.moduleName
|
||||||
frame.btnMode.text:SetText(item.text)
|
frame.btnMode.text:SetText(item.text)
|
||||||
frame.scroll = 0
|
frame.scroll = 0
|
||||||
|
Window:SavePosition(frame)
|
||||||
Window:RefreshWindow(frame)
|
Window:RefreshWindow(frame)
|
||||||
end, "TOPLEFT", frame.btnMode, "BOTTOMLEFT", 0, -2)
|
end, "TOPLEFT", frame.btnMode, "BOTTOMLEFT", 0, -2)
|
||||||
end)
|
end)
|
||||||
@@ -514,6 +592,32 @@ function Window:Create(wid)
|
|||||||
frame.content:SetPoint("TOPLEFT", frame.selectorBar, "BOTTOMLEFT", 0, -1)
|
frame.content:SetPoint("TOPLEFT", frame.selectorBar, "BOTTOMLEFT", 0, -1)
|
||||||
frame.content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -4, 4)
|
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
|
-- Scroll wheel
|
||||||
---------------------------------------------------------------
|
---------------------------------------------------------------
|
||||||
@@ -672,6 +776,8 @@ function Window:SavePosition(frame)
|
|||||||
point = point, relativePoint = relPoint,
|
point = point, relativePoint = relPoint,
|
||||||
xOfs = xOfs, yOfs = yOfs,
|
xOfs = xOfs, yOfs = yOfs,
|
||||||
w = fw, h = fh,
|
w = fw, h = fh,
|
||||||
|
moduleName = frame.activeModuleName,
|
||||||
|
segmentIndex = frame.segmentIndex,
|
||||||
}
|
}
|
||||||
|
|
||||||
frame:ClearAllPoints()
|
frame:ClearAllPoints()
|
||||||
@@ -705,6 +811,28 @@ function Window:LoadPosition(frame)
|
|||||||
frame:ClearAllPoints()
|
frame:ClearAllPoints()
|
||||||
frame:SetPoint("RIGHT", UIParent, "RIGHT", -80, -50)
|
frame:SetPoint("RIGHT", UIParent, "RIGHT", -80, -50)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
-----------------------------------------------------------------------
|
-----------------------------------------------------------------------
|
||||||
@@ -762,6 +890,28 @@ function Window:RefreshWindow(frame, force)
|
|||||||
BarDisplay:LayoutBars(frame)
|
BarDisplay:LayoutBars(frame)
|
||||||
end
|
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)
|
function Window:RefreshAll(force)
|
||||||
for _, win in pairs(activeWindows) do
|
for _, win in pairs(activeWindows) do
|
||||||
if win and win:IsShown() then
|
if win and win:IsShown() then
|
||||||
@@ -774,26 +924,35 @@ end
|
|||||||
-- Initialization
|
-- Initialization
|
||||||
-----------------------------------------------------------------------
|
-----------------------------------------------------------------------
|
||||||
NanamiDPS:RegisterCallback("INIT", "Window", function()
|
NanamiDPS:RegisterCallback("INIT", "Window", function()
|
||||||
local win = Window:Create(1)
|
|
||||||
activeWindows[1] = win
|
|
||||||
NanamiDPS.windows = activeWindows
|
|
||||||
|
|
||||||
NanamiDPS:RegisterCallback("refresh", "WindowRefresh", function()
|
NanamiDPS:RegisterCallback("refresh", "WindowRefresh", function()
|
||||||
for _, w in pairs(activeWindows) do
|
for _, w in pairs(activeWindows) do
|
||||||
if w then w.needsRefresh = true end
|
if w then w.needsRefresh = true end
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Window:LoadPosition(win)
|
|
||||||
|
|
||||||
local cfg = NanamiDPS.config or {}
|
local cfg = NanamiDPS.config or {}
|
||||||
|
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
|
||||||
|
|
||||||
|
for i = 1, savedWindowCount do
|
||||||
|
local win = Window:Create(i)
|
||||||
|
activeWindows[i] = win
|
||||||
|
Window:LoadPosition(win)
|
||||||
if cfg.visible ~= false then
|
if cfg.visible ~= false then
|
||||||
win:Show()
|
win:Show()
|
||||||
else
|
else
|
||||||
win:Hide()
|
win:Hide()
|
||||||
end
|
end
|
||||||
|
|
||||||
Window:RefreshWindow(win, true)
|
Window:RefreshWindow(win, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
NanamiDPS.windows = activeWindows
|
||||||
end)
|
end)
|
||||||
|
|
||||||
-----------------------------------------------------------------------
|
-----------------------------------------------------------------------
|
||||||
@@ -828,6 +987,9 @@ function Window:DestroyWindow(wid)
|
|||||||
if activeWindows[wid] then
|
if activeWindows[wid] then
|
||||||
activeWindows[wid]:Hide()
|
activeWindows[wid]:Hide()
|
||||||
activeWindows[wid] = nil
|
activeWindows[wid] = nil
|
||||||
|
if NanamiDPS_DB and NanamiDPS_DB.windowPositions then
|
||||||
|
NanamiDPS_DB.windowPositions[wid] = nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user