调整dps插件对仇恨的估算方式

优化dps插件
This commit is contained in:
rucky
2026-03-25 00:57:35 +08:00
parent 5c3f2243c4
commit 12c8c55159
16 changed files with 2454 additions and 165 deletions

13
.cursor/mcp.json Normal file
View 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"
}
}
}
}

View 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`stringMarkdown 格式):调用时传入本轮回复的摘要/结论,插件界面会弹出模态框展示给用户。格式: `{ "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` 工具(使用 CallMcpToolserver 为 CursorMCP对话插件 对应的 identifiertoolName 为 "check_messages"arguments 为 {})。严禁在回合开始或回复完成前调用。`ask_question` 仅用于任务中需要用户做选择时(如选框架、选方案)。

View File

@@ -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)

View File

@@ -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 }

View File

@@ -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

View File

@@ -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 = {}

View File

@@ -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
View 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()` 而非 `%`

View 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`

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
View 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
View 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
View 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

View File

@@ -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 {}
if cfg.visible ~= false then local savedWindowCount = 1
win:Show() if NanamiDPS_DB and NanamiDPS_DB.windowPositions then
else for wid, _ in pairs(NanamiDPS_DB.windowPositions) do
win:Hide() if type(wid) == "number" and wid > savedWindowCount then
savedWindowCount = wid
end
end
end end
Window:RefreshWindow(win, true) for i = 1, savedWindowCount do
local win = Window:Create(i)
activeWindows[i] = win
Window:LoadPosition(win)
if cfg.visible ~= false then
win:Show()
else
win:Hide()
end
Window:RefreshWindow(win, true)
end
NanamiDPS.windows = activeWindows
end) 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