commit 5c3f2243c4b4adb8a63d7b89e735f3d94c052b2d Author: rucky Date: Mon Mar 23 10:26:31 2026 +0800 更新发送到功能 更新仇恨计算方式 还在开发 更新其他细节 diff --git a/BarDisplay.lua b/BarDisplay.lua new file mode 100644 index 0000000..01b1a1d --- /dev/null +++ b/BarDisplay.lua @@ -0,0 +1,132 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local BarDisplay = {} +NanamiDPS.BarDisplay = BarDisplay + +local BAR_POOL = {} + +function BarDisplay:CreateBar(parent, index) + local cfg = NanamiDPS.config or {} + local barHeight = cfg.barHeight or 16 + local barSpacing = cfg.barSpacing or 1 + local fontSize = cfg.fontSize or 10 + local A = SFrames.ActiveTheme + + local bar = CreateFrame("StatusBar", "NanamiDPSBar" .. parent:GetID() .. "_" .. index, parent) + bar:SetStatusBarTexture(SFrames:GetTexture()) + bar:SetHeight(barHeight) + bar:SetMinMaxValues(0, 1) + bar:SetValue(0) + bar:SetFrameLevel(parent:GetFrameLevel() + 3) + + bar.bg = bar:CreateTexture(nil, "BACKGROUND") + bar.bg:SetTexture(SFrames:GetTexture()) + bar.bg:SetAllPoints(bar) + if A and A.barBg then + bar.bg:SetVertexColor(A.barBg[1], A.barBg[2], A.barBg[3], A.barBg[4] or 0.6) + else + bar.bg:SetVertexColor(0.15, 0.15, 0.15, 0.6) + end + + if cfg.showClassIcons and SFrames.CreateClassIcon then + bar.classIcon = SFrames:CreateClassIcon(bar, barHeight - 2) + bar.classIcon.overlay:SetPoint("LEFT", bar, "LEFT", 2, 0) + end + + local textOffset = (cfg.showClassIcons and barHeight + 2) or 4 + + bar.textLeft = SFrames:CreateFontString(bar, fontSize, "LEFT") + bar.textLeft:SetPoint("LEFT", bar, "LEFT", textOffset, 0) + bar.textLeft:SetPoint("RIGHT", bar, "RIGHT", -130, 0) + + bar.textRight = SFrames:CreateFontString(bar, fontSize, "RIGHT") + bar.textRight:SetPoint("RIGHT", bar, "RIGHT", -4, 0) + bar.textRight:SetWidth(126) + + bar:EnableMouse(true) + bar:SetScript("OnEnter", function() + if this.onEnter then this.onEnter(this) end + end) + bar:SetScript("OnLeave", function() + if this.onLeave then this.onLeave(this) end + end) + bar:SetScript("OnMouseUp", function() + if this.onClick then this.onClick(this, arg1) end + end) + + bar.index = index + return bar +end + +function BarDisplay:LayoutBars(window) + local cfg = NanamiDPS.config or {} + local barHeight = cfg.barHeight or 16 + local barSpacing = cfg.barSpacing or 1 + local headerHeight = 22 + + for i, bar in ipairs(window.bars) do + bar:ClearAllPoints() + bar:SetPoint("TOPLEFT", window.content, "TOPLEFT", 1, -((i - 1) * (barHeight + barSpacing))) + bar:SetPoint("TOPRIGHT", window.content, "TOPRIGHT", -1, -((i - 1) * (barHeight + barSpacing))) + bar:SetHeight(barHeight) + end +end + +function BarDisplay:UpdateBar(bar, data, maxVal, rank) + if not data then + bar:Hide() + return + end + + bar:Show() + bar:SetMinMaxValues(0, maxVal > 0 and maxVal or 1) + bar:SetValue(data.value or 0) + + local r, g, b = data.r or 0.6, data.g or 0.6, data.b or 0.6 + bar:SetStatusBarColor(r, g, b, 1) + bar.bg:SetVertexColor(r * 0.25, g * 0.25, b * 0.25, 0.6) + + local nameStr = rank .. ". " .. (data.name or L["Unknown"]) + bar.textLeft:SetText(nameStr) + + local valStr + if data.valueText then + valStr = data.valueText + else + valStr = NanamiDPS.formatNumber(data.value or 0) + if data.percent then + valStr = valStr .. " (" .. NanamiDPS.round(data.percent, 1) .. "%)" + end + end + bar.textRight:SetText(valStr) + + if bar.classIcon and data.class and NanamiDPS.validClasses[data.class] then + SFrames:SetClassIcon(bar.classIcon, data.class) + elseif bar.classIcon then + bar.classIcon:Hide() + if bar.classIcon.overlay then bar.classIcon.overlay:Hide() end + end + + bar.barData = data +end + +function BarDisplay:SetBarCallbacks(bar, onEnter, onLeave, onClick) + bar.onEnter = onEnter + bar.onLeave = onLeave + bar.onClick = onClick +end + +function BarDisplay:GetMaxBars(window) + local cfg = NanamiDPS.config or {} + local barHeight = cfg.barHeight or 16 + local barSpacing = cfg.barSpacing or 1 + local contentHeight = 200 + if window.content then + local h = window.content:GetHeight() + if h and h > 10 then contentHeight = h end + end + local maxBars = math.floor(contentHeight / (barHeight + barSpacing)) + return math.max(maxBars, 1) +end diff --git a/Core.lua b/Core.lua new file mode 100644 index 0000000..234b8d9 --- /dev/null +++ b/Core.lua @@ -0,0 +1,78 @@ +NanamiDPS = {} + +NanamiDPS.version = "1.0.0" + +NanamiDPS.modules = {} +NanamiDPS.moduleOrder = {} +NanamiDPS.windows = {} + +NanamiDPS.callbacks = { + refresh = {}, +} + +local defaultConfig = { + barHeight = 16, + barSpacing = 1, + fontSize = 10, + trackAllUnits = false, + mergePets = true, + visible = true, + locked = false, + maxSegments = 10, + backdropAlpha = 0.92, + showClassIcons = true, +} + +function NanamiDPS:RegisterModule(name, mod) + if self.modules[name] then return end + self.modules[name] = mod + table.insert(self.moduleOrder, name) +end + +function NanamiDPS:GetModule(name) + return self.modules[name] +end + +function NanamiDPS:FireCallback(name, a1, a2, a3, a4, a5) + if self.callbacks[name] then + for _, fn in pairs(self.callbacks[name]) do + fn(a1, a2, a3, a4, a5) + end + end +end + +function NanamiDPS:RegisterCallback(name, id, fn) + if not self.callbacks[name] then + self.callbacks[name] = {} + end + self.callbacks[name][id] = fn +end + +StaticPopupDialogs["NANAMI_DPS_CONFIRM"] = { + button1 = YES, + button2 = NO, + timeout = 0, + whileDead = 1, + hideOnEscape = 1, +} + +local initFrame = CreateFrame("Frame") +initFrame:RegisterEvent("PLAYER_ENTERING_WORLD") +initFrame:SetScript("OnEvent", function() + if not NanamiDPS_DB then + NanamiDPS_DB = {} + end + + NanamiDPS_DB.config = NanamiDPS_DB.config or {} + for k, v in pairs(defaultConfig) do + if NanamiDPS_DB.config[k] == nil then + NanamiDPS_DB.config[k] = v + end + end + NanamiDPS.config = NanamiDPS_DB.config + + NanamiDPS_DB.windows = NanamiDPS_DB.windows or {} + NanamiDPS_DB.windowPositions = NanamiDPS_DB.windowPositions or {} + + NanamiDPS:FireCallback("INIT") +end) diff --git a/DataStore.lua b/DataStore.lua new file mode 100644 index 0000000..52d33cf --- /dev/null +++ b/DataStore.lua @@ -0,0 +1,249 @@ +local NanamiDPS = NanamiDPS + +local DataStore = {} +NanamiDPS.DataStore = DataStore + +local function CreateEmptySegmentData() + return { + damage = {}, + healing = {}, + damageTaken = {}, + deaths = {}, + dispels = {}, + interrupts = {}, + threat = {}, + activity = {}, + classes = {}, + } +end + +local function CreateSegment(name) + return { + name = name or "Unknown", + startTime = GetTime(), + endTime = 0, + duration = 0, + data = CreateEmptySegmentData(), + } +end + +DataStore.total = CreateSegment("Total") +DataStore.current = CreateSegment("Current") +DataStore.history = {} + +DataStore.inCombat = false + +function DataStore:GetSegment(index) + if index == 0 then + return self.total + elseif index == 1 then + return self.current + elseif index and self.history[index - 1] then + return self.history[index - 1] + end + return self.current +end + +function DataStore:GetSegmentList() + local list = {} + table.insert(list, { index = 0, name = NanamiDPS.L["Total"] }) + table.insert(list, { index = 1, name = NanamiDPS.L["Current"] }) + for i, seg in ipairs(self.history) do + table.insert(list, { index = i + 1, name = seg.name }) + end + return list +end + +function DataStore:StartCombat() + if self.inCombat then return end + self.inCombat = true + + self.current = CreateSegment("Current") + self.current.startTime = GetTime() + + NanamiDPS:FireCallback("COMBAT_START") +end + +function DataStore:StopCombat() + if not self.inCombat then return end + self.inCombat = false + + self.current.endTime = GetTime() + self.current.duration = self.current.endTime - self.current.startTime + + local hasData = false + for _ in pairs(self.current.data.damage) do hasData = true; break end + if not hasData then + for _ in pairs(self.current.data.healing) do hasData = true; break end + end + + if hasData and self.current.duration >= 2 then + local segName = date("%H:%M:%S") + self.current.name = segName + + table.insert(self.history, 1, self.current) + + local maxSegs = (NanamiDPS.config and NanamiDPS.config.maxSegments) or 10 + while table.getn(self.history) > maxSegs do + table.remove(self.history) + end + end + + self.current = CreateSegment("Current") + + NanamiDPS:FireCallback("COMBAT_STOP") + NanamiDPS:FireCallback("refresh") +end + +function DataStore:AddDamage(source, spell, target, amount, school) + if not source or not amount then return end + amount = tonumber(amount) + if not amount or amount <= 0 then return end + + local segs = { self.current, self.total } + for _, seg in ipairs(segs) do + local d = seg.data.damage + if not d[source] then + d[source] = { _sum = 0, _ctime = 1, _tick = 0, spells = {} } + end + d[source]._sum = d[source]._sum + amount + d[source].spells[spell] = (d[source].spells[spell] or 0) + amount + self:UpdateCombatTime(d[source]) + end +end + +function DataStore:AddDamageTaken(target, spell, source, amount, school) + if not target or not amount then return end + amount = tonumber(amount) + if not amount or amount <= 0 then return end + + local segs = { self.current, self.total } + for _, seg in ipairs(segs) do + local d = seg.data.damageTaken + if not d[target] then + d[target] = { _sum = 0, _ctime = 1, _tick = 0, spells = {} } + end + d[target]._sum = d[target]._sum + amount + d[target].spells[spell] = (d[target].spells[spell] or 0) + amount + self:UpdateCombatTime(d[target]) + end +end + +function DataStore:AddHealing(source, spell, target, amount, effective) + if not source or not amount then return end + amount = tonumber(amount) + effective = tonumber(effective) or amount + if not amount or amount <= 0 then return end + + local segs = { self.current, self.total } + for _, seg in ipairs(segs) do + local d = seg.data.healing + if not d[source] then + d[source] = { _sum = 0, _esum = 0, _ctime = 1, _tick = 0, spells = {}, effective = {} } + end + d[source]._sum = d[source]._sum + amount + d[source]._esum = d[source]._esum + effective + d[source].spells[spell] = (d[source].spells[spell] or 0) + amount + d[source].effective[spell] = (d[source].effective[spell] or 0) + effective + self:UpdateCombatTime(d[source]) + end +end + +function DataStore:AddDeath(playerName, deathLog) + local segs = { self.current, self.total } + for _, seg in ipairs(segs) do + local d = seg.data.deaths + if not d[playerName] then + d[playerName] = { _sum = 0, events = {} } + end + d[playerName]._sum = d[playerName]._sum + 1 + table.insert(d[playerName].events, deathLog) + end +end + +function DataStore:AddDispel(source, spell, target, aura) + if not source then return end + local segs = { self.current, self.total } + for _, seg in ipairs(segs) do + local d = seg.data.dispels + if not d[source] then + d[source] = { _sum = 0, spells = {} } + end + d[source]._sum = d[source]._sum + 1 + d[source].spells[spell] = (d[source].spells[spell] or 0) + 1 + end +end + +function DataStore:AddInterrupt(source, spell, target, interrupted) + if not source then return end + local segs = { self.current, self.total } + for _, seg in ipairs(segs) do + local d = seg.data.interrupts + if not d[source] then + d[source] = { _sum = 0, spells = {} } + end + d[source]._sum = d[source]._sum + 1 + d[source].spells[spell] = (d[source].spells[spell] or 0) + 1 + end +end + +function DataStore:AddThreat(source, amount) + if not source or not amount then return end + local segs = { self.current, self.total } + for _, seg in ipairs(segs) do + local d = seg.data.threat + if not d[source] then + d[source] = { _sum = 0 } + end + d[source]._sum = d[source]._sum + amount + end +end + +function DataStore:UpdateActivity(source, timestamp) + if not source then return end + local segs = { self.current, self.total } + for _, seg in ipairs(segs) do + local d = seg.data.activity + if not d[source] then + d[source] = { _firstAction = timestamp, _lastAction = timestamp, _activeTime = 0, _tick = 0 } + end + local entry = d[source] + if entry._tick > 0 and (timestamp - entry._tick) < 5 then + entry._activeTime = entry._activeTime + (timestamp - entry._tick) + end + entry._tick = timestamp + entry._lastAction = timestamp + end +end + +function DataStore:SetClass(name, class) + self.current.data.classes[name] = class + self.total.data.classes[name] = class +end + +function DataStore:GetClass(name) + return self.current.data.classes[name] or self.total.data.classes[name] +end + +function DataStore:UpdateCombatTime(entry) + if not entry._tick then entry._tick = 0 end + local now = GetTime() + if entry._tick == 0 then + entry._tick = now + return + end + local diff = now - entry._tick + if diff >= 5 then + entry._ctime = entry._ctime + 5 + else + entry._ctime = entry._ctime + diff + end + entry._tick = now +end + +function DataStore:ResetAll() + self.total = CreateSegment("Total") + self.current = CreateSegment("Current") + self.history = {} + NanamiDPS:FireCallback("refresh") +end diff --git a/DetailView.lua b/DetailView.lua new file mode 100644 index 0000000..56ce311 --- /dev/null +++ b/DetailView.lua @@ -0,0 +1,1045 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local DetailView = {} +NanamiDPS.DetailView = DetailView + +local detailFrame = nil +local MAX_SPELL_BARS = 20 +local MAX_COMPARE_BARS = 10 + +----------------------------------------------------------------------- +-- Data-type resolution: which sub-table to look up for a given module +----------------------------------------------------------------------- +local function GetPlayerEntry(moduleName, segment, playerName) + if not segment or not segment.data then return nil, nil end + + local dataMap = { + DamageDone = "damage", + DPS = "damage", + DamageTaken = "damageTaken", + HealingDone = "healing", + HPS = "healing", + Overhealing = "healing", + Deaths = "deaths", + Dispels = "dispels", + Interrupts = "interrupts", + ThreatEstimate = "threat", + Activity = "activity", + EnemyDamageDone = "damage", + DamageBySpell = "damage", + HealingBySpell = "healing", + } + + local key = dataMap[moduleName] + if not key then return nil, nil end + + local tbl = segment.data[key] + if not tbl then return nil, key end + return tbl[playerName], key +end + +----------------------------------------------------------------------- +-- Internal: create a mini bar (used for spell breakdown & comparison) +----------------------------------------------------------------------- +local function CreateMiniBar(parent, index) + local A = SFrames.ActiveTheme + local bar = CreateFrame("StatusBar", nil, parent) + bar:SetStatusBarTexture(SFrames:GetTexture()) + bar:SetHeight(14) + bar:SetMinMaxValues(0, 1) + bar:SetValue(0) + + bar.bg = bar:CreateTexture(nil, "BACKGROUND") + bar.bg:SetTexture(SFrames:GetTexture()) + bar.bg:SetAllPoints(bar) + if A and A.barBg then + bar.bg:SetVertexColor(A.barBg[1], A.barBg[2], A.barBg[3], A.barBg[4] or 0.5) + else + bar.bg:SetVertexColor(0.12, 0.12, 0.12, 0.5) + end + + bar.textLeft = SFrames:CreateFontString(bar, 9, "LEFT") + bar.textLeft:SetPoint("LEFT", bar, "LEFT", 4, 0) + bar.textLeft:SetPoint("RIGHT", bar, "RIGHT", -70, 0) + + bar.textRight = SFrames:CreateFontString(bar, 9, "RIGHT") + bar.textRight:SetPoint("RIGHT", bar, "RIGHT", -4, 0) + bar.textRight:SetWidth(66) + + bar.index = index + return bar +end + +local function LayoutMiniBars(bars, parent, startY, spacing) + for i, bar in ipairs(bars) do + bar:ClearAllPoints() + bar:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, startY - (i - 1) * (14 + spacing)) + bar:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, startY - (i - 1) * (14 + spacing)) + end +end + +----------------------------------------------------------------------- +-- Create the detail frame (once) +----------------------------------------------------------------------- +local function EnsureFrame() + if detailFrame then return detailFrame end + + local A = SFrames.ActiveTheme + + detailFrame = CreateFrame("Frame", "NanamiDPSDetailView", UIParent) + detailFrame:SetWidth(380) + detailFrame:SetHeight(480) + detailFrame:SetPoint("CENTER", UIParent, "CENTER", 200, 0) + detailFrame:SetMovable(true) + detailFrame:EnableMouse(true) + detailFrame:SetClampedToScreen(true) + detailFrame:SetFrameStrata("HIGH") + detailFrame:EnableMouseWheel(1) + detailFrame:SetResizable(true) + detailFrame:SetMinResize(280, 300) + + SFrames:CreateRoundBackdrop(detailFrame) + if A and A.panelBg then + detailFrame:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], 0.96) + end + if A and A.panelBorder then + detailFrame:SetBackdropBorderColor(A.panelBorder[1], A.panelBorder[2], A.panelBorder[3], A.panelBorder[4] or 0.9) + end + + -- Title bar + local titleBar = CreateFrame("Frame", nil, detailFrame) + titleBar:SetHeight(24) + titleBar:SetPoint("TOPLEFT", detailFrame, "TOPLEFT", 4, -4) + titleBar:SetPoint("TOPRIGHT", detailFrame, "TOPRIGHT", -4, -4) + titleBar:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + if A and A.headerBg then + titleBar:SetBackdropColor(A.headerBg[1], A.headerBg[2], A.headerBg[3], A.headerBg[4] or 0.98) + else + titleBar:SetBackdropColor(0.06, 0.06, 0.08, 0.98) + end + titleBar:EnableMouse(true) + titleBar:RegisterForDrag("LeftButton") + titleBar:SetScript("OnDragStart", function() detailFrame:StartMoving() end) + titleBar:SetScript("OnDragStop", function() detailFrame:StopMovingOrSizing() end) + detailFrame.titleBar = titleBar + + -- Title text (player name + module) + detailFrame.titleText = SFrames:CreateFontString(titleBar, 11, "LEFT") + detailFrame.titleText:SetPoint("LEFT", titleBar, "LEFT", 8, 0) + detailFrame.titleText:SetPoint("RIGHT", titleBar, "RIGHT", -55, 0) + + -- Close button + local closeBtn = CreateFrame("Button", nil, titleBar) + closeBtn:SetWidth(18) + closeBtn:SetHeight(18) + closeBtn:SetPoint("RIGHT", titleBar, "RIGHT", -3, 0) + local closeIcon = SFrames:CreateIcon(closeBtn, "close", 12) + closeIcon:SetPoint("CENTER", 0, 0) + closeBtn:SetScript("OnClick", function() detailFrame:Hide() end) + closeBtn:SetScript("OnEnter", function() closeIcon:SetVertexColor(1, 0.3, 0.3) end) + closeBtn:SetScript("OnLeave", function() closeIcon:SetVertexColor(1, 1, 1) end) + + -- Report button + local reportBtn = CreateFrame("Button", nil, titleBar) + reportBtn:SetWidth(16) + reportBtn:SetHeight(16) + reportBtn:SetPoint("RIGHT", closeBtn, "LEFT", -2, 0) + local reportIconFs = SFrames:CreateFontString(reportBtn, 9, "CENTER") + reportIconFs:SetAllPoints() + reportIconFs:SetText("R") + if A and A.text then + reportIconFs:SetTextColor(A.text[1], A.text[2], A.text[3]) + end + reportBtn:SetScript("OnClick", function() + DetailView:ShowReport() + end) + reportBtn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine(L["Report to Chat"]) + GameTooltip:Show() + if A and A.accent then + reportIconFs:SetTextColor(A.accent[1], A.accent[2], A.accent[3]) + end + end) + reportBtn:SetScript("OnLeave", function() + GameTooltip:Hide() + if A and A.text then + reportIconFs:SetTextColor(A.text[1], A.text[2], A.text[3]) + else + reportIconFs:SetTextColor(1, 1, 1) + end + end) + + ------------------------------------------------------------------- + -- Tab bar under title + ------------------------------------------------------------------- + local tabBar = CreateFrame("Frame", nil, detailFrame) + tabBar:SetHeight(18) + tabBar:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 0, 0) + tabBar:SetPoint("TOPRIGHT", titleBar, "BOTTOMRIGHT", 0, 0) + tabBar:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + if A and A.sectionBg then + tabBar:SetBackdropColor(A.sectionBg[1], A.sectionBg[2], A.sectionBg[3], A.sectionBg[4] or 0.95) + else + tabBar:SetBackdropColor(0.05, 0.03, 0.04, 0.95) + end + tabBar.tabs = {} + detailFrame.tabBar = tabBar + + local tabNames = { L["Spell Breakdown"], L["Summary"], L["Comparison"] } + local tabCount = table.getn(tabNames) + for i, name in ipairs(tabNames) do + local tab = CreateFrame("Button", nil, tabBar) + tab:SetHeight(18) + if i == 1 then + tab:SetPoint("TOPLEFT", tabBar, "TOPLEFT", 0, 0) + else + tab:SetPoint("LEFT", tabBar.tabs[i - 1], "RIGHT", 0, 0) + end + if i == tabCount then + tab:SetPoint("RIGHT", tabBar, "RIGHT", 0, 0) + else + local tabWidth = math.floor((detailFrame:GetWidth() - 8) / tabCount) + tab:SetWidth(tabWidth) + end + tab:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + if A and A.tabBg then + tab:SetBackdropColor(A.tabBg[1], A.tabBg[2], A.tabBg[3], A.tabBg[4] or 0.6) + else + tab:SetBackdropColor(0.12, 0.06, 0.09, 0.6) + end + + local label = SFrames:CreateFontString(tab, 9, "CENTER") + label:SetAllPoints() + label:SetText(name) + if A and A.tabText then + label:SetTextColor(A.tabText[1], A.tabText[2], A.tabText[3]) + end + tab.label = label + tab.tabIndex = i + + tab:SetScript("OnClick", function() + DetailView:SwitchTab(this.tabIndex) + end) + tab:SetScript("OnEnter", function() + if detailFrame.activeTab ~= this.tabIndex then + if A and A.buttonHoverBg then + this:SetBackdropColor(A.buttonHoverBg[1], A.buttonHoverBg[2], A.buttonHoverBg[3], 0.3) + else + this:SetBackdropColor(0.3, 0.3, 0.3, 0.15) + end + end + end) + tab:SetScript("OnLeave", function() + if detailFrame.activeTab ~= this.tabIndex then + if A and A.tabBg then + this:SetBackdropColor(A.tabBg[1], A.tabBg[2], A.tabBg[3], A.tabBg[4] or 0.6) + else + this:SetBackdropColor(0.12, 0.06, 0.09, 0.6) + end + end + end) + + tabBar.tabs[i] = tab + end + + ------------------------------------------------------------------- + -- Content area + ------------------------------------------------------------------- + local content = CreateFrame("Frame", nil, detailFrame) + content:SetPoint("TOPLEFT", tabBar, "BOTTOMLEFT", 8, -6) + content:SetPoint("BOTTOMRIGHT", detailFrame, "BOTTOMRIGHT", -10, 22) + detailFrame.content = content + + ------------------------------------------------------------------- + -- Page containers + ------------------------------------------------------------------- + -- Page 1: Spell breakdown + detailFrame.pageSpells = CreateFrame("Frame", nil, content) + detailFrame.pageSpells:SetAllPoints(content) + detailFrame.pageSpells.bars = {} + detailFrame.pageSpells.scroll = 0 + + -- Page 2: Summary stats + detailFrame.pageSummary = CreateFrame("Frame", nil, content) + detailFrame.pageSummary:SetAllPoints(content) + detailFrame.pageSummary.lines = {} + + -- Page 3: Comparison + detailFrame.pageCompare = CreateFrame("Frame", nil, content) + detailFrame.pageCompare:SetAllPoints(content) + detailFrame.pageCompare.bars = {} + + ------------------------------------------------------------------- + -- Scroll wheel + ------------------------------------------------------------------- + detailFrame:SetScript("OnMouseWheel", function() + if detailFrame.activeTab == 1 then + local page = detailFrame.pageSpells + page.scroll = page.scroll + (arg1 > 0 and -1 or 1) + page.scroll = math.max(page.scroll, 0) + DetailView:RefreshSpells() + end + end) + + ------------------------------------------------------------------- + -- Resize handle + ------------------------------------------------------------------- + local resizeBtn = CreateFrame("Frame", nil, detailFrame) + resizeBtn:SetWidth(12) + resizeBtn:SetHeight(12) + resizeBtn:SetPoint("BOTTOMRIGHT", detailFrame, "BOTTOMRIGHT", -5, 5) + resizeBtn:EnableMouse(true) + resizeBtn:SetFrameLevel(detailFrame:GetFrameLevel() + 10) + + resizeBtn.tex = resizeBtn:CreateTexture(nil, "OVERLAY") + resizeBtn.tex:SetAllPoints() + resizeBtn.tex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up") + if A and A.accent then + resizeBtn.tex:SetVertexColor(A.accent[1], A.accent[2], A.accent[3], 0.5) + else + resizeBtn.tex:SetVertexColor(0.5, 0.5, 0.5, 0.3) + end + + resizeBtn:SetScript("OnMouseDown", function() + detailFrame:StartSizing("BOTTOMRIGHT") + end) + resizeBtn:SetScript("OnMouseUp", function() + detailFrame:StopMovingOrSizing() + DetailView:Refresh() + end) + + detailFrame.activeTab = 1 + detailFrame:Hide() + return detailFrame +end + +----------------------------------------------------------------------- +-- Tab switching +----------------------------------------------------------------------- +function DetailView:SwitchTab(tabIndex) + local frame = EnsureFrame() + local A = SFrames.ActiveTheme + frame.activeTab = tabIndex + + for i, tab in ipairs(frame.tabBar.tabs) do + if i == tabIndex then + if A and A.tabActiveBg then + tab:SetBackdropColor(A.tabActiveBg[1], A.tabActiveBg[2], A.tabActiveBg[3], A.tabActiveBg[4] or 0.3) + else + tab:SetBackdropColor(0.3, 0.3, 0.3, 0.2) + end + if A and A.tabActiveText then + tab.label:SetTextColor(A.tabActiveText[1], A.tabActiveText[2], A.tabActiveText[3]) + else + tab.label:SetTextColor(1, 0.85, 0.9) + end + else + if A and A.tabBg then + tab:SetBackdropColor(A.tabBg[1], A.tabBg[2], A.tabBg[3], A.tabBg[4] or 0.6) + else + tab:SetBackdropColor(0.12, 0.06, 0.09, 0.6) + end + if A and A.tabText then + tab.label:SetTextColor(A.tabText[1], A.tabText[2], A.tabText[3]) + else + tab.label:SetTextColor(0.7, 0.7, 0.7) + end + end + end + + frame.pageSpells:Hide() + frame.pageSummary:Hide() + frame.pageCompare:Hide() + + if tabIndex == 1 then + frame.pageSpells:Show() + self:RefreshSpells() + elseif tabIndex == 2 then + frame.pageSummary:Show() + self:RefreshSummary() + elseif tabIndex == 3 then + frame.pageCompare:Show() + self:RefreshCompare() + end +end + +----------------------------------------------------------------------- +-- Show detail view for a bar +----------------------------------------------------------------------- +function DetailView:Show(barData, moduleName, segmentIndex) + local frame = EnsureFrame() + + frame.playerName = barData.id or barData.name + frame.moduleName = moduleName + frame.segmentIndex = segmentIndex + + local mod = NanamiDPS.modules[moduleName] + local modDisplayName = mod and mod:GetName() or moduleName + + local accentHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + frame.titleText:SetText("|c" .. accentHex .. (barData.name or L["Unknown"]) .. "|r - " .. modDisplayName) + + frame.pageSpells.scroll = 0 + frame:Show() + self:SwitchTab(1) +end + +----------------------------------------------------------------------- +-- Page 1: Spell Breakdown (visual bar chart) +----------------------------------------------------------------------- +function DetailView:RefreshSpells() + local frame = detailFrame + if not frame or not frame:IsShown() then return end + + local page = frame.pageSpells + local seg = DataStore:GetSegment(frame.segmentIndex or 1) + local entry, dataKey = GetPlayerEntry(frame.moduleName, seg, frame.playerName) + + -- Collect spell data + local spellData = {} + local totalVal = 0 + + if entry then + if entry.spells then + for spell, amount in pairs(entry.spells) do + table.insert(spellData, { + name = spell, + value = amount, + effective = entry.effective and entry.effective[spell], + }) + totalVal = totalVal + amount + end + elseif entry.events then + -- Deaths module: show event log + local evts = entry.events + if table.getn(evts) > 0 then + local lastDeath = evts[table.getn(evts)] + if lastDeath.events then + for _, evt in ipairs(lastDeath.events) do + local spell = evt.spell or L["Unknown"] + local amount = math.abs(evt.amount or 0) + table.insert(spellData, { + name = spell .. " (" .. (evt.source or "?") .. ")", + value = amount, + isDamage = evt.type == "damage", + isHeal = evt.type == "heal", + }) + totalVal = totalVal + amount + end + end + end + elseif dataKey == "activity" then + -- Activity doesn't have spells + local activeTime = entry._activeTime or 0 + table.insert(spellData, { name = L["Active Time"], value = activeTime }) + totalVal = activeTime + end + end + + table.sort(spellData, function(a, b) return a.value > b.value end) + + -- Determine how many bars fit + local contentH = page:GetHeight() + if not contentH or contentH < 20 then contentH = 340 end + local barH = 16 + local barSpacing = 2 + local maxBars = math.max(1, math.floor(contentH / (barH + barSpacing))) + local totalEntries = table.getn(spellData) + + page.scroll = math.min(page.scroll, math.max(0, totalEntries - maxBars)) + + local barsNeeded = math.max(0, math.min(maxBars, totalEntries - page.scroll)) + + -- Create bars as needed + while table.getn(page.bars) < barsNeeded do + local bar = CreateMiniBar(page, table.getn(page.bars) + 1) + table.insert(page.bars, bar) + end + + local maxVal = 0 + for _, sd in ipairs(spellData) do + if sd.value > maxVal then maxVal = sd.value end + end + + -- Update bars + for i = 1, math.max(table.getn(page.bars), barsNeeded) do + local dataIdx = i + page.scroll + local bar = page.bars[i] + if not bar then break end + + if dataIdx <= totalEntries then + local sd = spellData[dataIdx] + bar:Show() + bar:SetMinMaxValues(0, maxVal > 0 and maxVal or 1) + bar:SetValue(sd.value) + + if sd.isDamage then + bar:SetStatusBarColor(0.9, 0.3, 0.3, 0.8) + bar.bg:SetVertexColor(0.2, 0.06, 0.06, 0.5) + elseif sd.isHeal then + bar:SetStatusBarColor(0.3, 0.9, 0.3, 0.8) + bar.bg:SetVertexColor(0.06, 0.2, 0.06, 0.5) + else + local A = SFrames.ActiveTheme + if A and A.accent then + bar:SetStatusBarColor(A.accent[1], A.accent[2], A.accent[3], 0.7) + bar.bg:SetVertexColor(A.accent[1] * 0.2, A.accent[2] * 0.2, A.accent[3] * 0.2, 0.5) + else + bar:SetStatusBarColor(1, 0.53, 0.67, 0.7) + bar.bg:SetVertexColor(0.15, 0.08, 0.1, 0.5) + end + end + + local pct = totalVal > 0 and NanamiDPS.round(sd.value / totalVal * 100, 1) or 0 + bar.textLeft:SetText("|cffffffff" .. dataIdx .. ". " .. sd.name) + + local rightStr = NanamiDPS.formatNumber(sd.value) + if sd.effective and sd.effective ~= sd.value then + local oh = sd.value - sd.effective + rightStr = NanamiDPS.formatNumber(sd.effective) .. " |cffcc8888+" .. NanamiDPS.formatNumber(oh) + end + rightStr = rightStr .. " |cffaaaaaa(" .. pct .. "%)" + bar.textRight:SetText(rightStr) + + bar:ClearAllPoints() + bar:SetPoint("TOPLEFT", page, "TOPLEFT", 0, -((i - 1) * (barH + barSpacing))) + bar:SetPoint("TOPRIGHT", page, "TOPRIGHT", 0, -((i - 1) * (barH + barSpacing))) + bar:SetHeight(barH) + else + bar:Hide() + end + end +end + +----------------------------------------------------------------------- +-- Page 2: Summary +----------------------------------------------------------------------- +function DetailView:RefreshSummary() + local frame = detailFrame + if not frame or not frame:IsShown() then return end + + local page = frame.pageSummary + local seg = DataStore:GetSegment(frame.segmentIndex or 1) + local entry, dataKey = GetPlayerEntry(frame.moduleName, seg, frame.playerName) + + -- Clear old lines + for _, item in ipairs(page.lines) do + if type(item) == "table" and item.left then + item.left:SetText("") + item.right:SetText("") + elseif type(item) ~= "table" then + item:SetText("") + end + end + + local lineIdx = 0 + local labelHex = "|cffffd100" + local localA = SFrames.ActiveTheme + if localA and localA.sectionTitle then + local lr, lg, lb = localA.sectionTitle[1], localA.sectionTitle[2], localA.sectionTitle[3] + labelHex = "|c" .. SFrames.Theme.RGBtoHex(lr, lg, lb) + end + + local function AddLine(left, right, leftColor, rightColor) + lineIdx = lineIdx + 1 + if not page.lines[lineIdx] then + local leftFs = SFrames:CreateFontString(page, 10, "LEFT") + local rightFs = SFrames:CreateFontString(page, 10, "RIGHT") + page.lines[lineIdx] = { left = leftFs, right = rightFs } + end + local pair = page.lines[lineIdx] + + if type(pair) ~= "table" or not pair.left then + local leftFs = SFrames:CreateFontString(page, 10, "LEFT") + local rightFs = SFrames:CreateFontString(page, 10, "RIGHT") + page.lines[lineIdx] = { left = leftFs, right = rightFs } + pair = page.lines[lineIdx] + end + + local yOff = -((lineIdx - 1) * 18) + pair.left:ClearAllPoints() + pair.left:SetPoint("TOPLEFT", page, "TOPLEFT", 4, yOff) + pair.left:SetWidth(160) + pair.left:SetText((leftColor or labelHex) .. left) + + pair.right:ClearAllPoints() + pair.right:SetPoint("TOPRIGHT", page, "TOPRIGHT", -4, yOff) + pair.right:SetWidth(180) + pair.right:SetText((rightColor or "|cffffffff") .. (right or "")) + end + + local function AddSeparator() + lineIdx = lineIdx + 1 + if not page.lines[lineIdx] then + local fs = SFrames:CreateFontString(page, 6, "LEFT") + page.lines[lineIdx] = fs + end + local fs = page.lines[lineIdx] + if type(fs) == "table" and fs.left then + fs.left:SetText("") + fs.right:SetText("") + elseif type(fs) ~= "table" then + fs:ClearAllPoints() + fs:SetPoint("TOPLEFT", page, "TOPLEFT", 0, -((lineIdx - 1) * 18)) + fs:SetText("") + end + end + + if not entry then + AddLine(L["No Data"], "") + return + end + + local playerName = frame.playerName + local class = DataStore:GetClass(playerName) + + -- Player header + local pAccentHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + AddLine(playerName, class or "", "|c" .. pAccentHex, "|cffaaaaaa") + AddSeparator() + + -- Module-specific summary + if dataKey == "damage" then + local sum = entry._sum or 0 + local ctime = entry._ctime or 1 + local dps = sum / math.max(ctime, 1) + + AddLine(L["Total Damage"], NanamiDPS.formatNumber(sum)) + AddLine(L["DPS"], NanamiDPS.round(dps, 1)) + AddLine(L["Combat Time"], NanamiDPS.formatTime(ctime)) + + if entry.spells then + local maxHit = 0 + local spellCount = 0 + for _, amount in pairs(entry.spells) do + if amount > maxHit then maxHit = amount end + spellCount = spellCount + 1 + end + AddLine(L["Top Spells"], tostring(spellCount)) + end + + -- Rank in segment + local mod = NanamiDPS.modules[frame.moduleName] + if mod then + local bars = mod:GetBars(seg) + for i, b in ipairs(bars) do + if b.id == playerName then + AddLine(L["Rank"], "#" .. i .. " " .. L["of total"] .. " " .. table.getn(bars)) + if b.percent then + AddLine(L["Percent"], NanamiDPS.round(b.percent, 1) .. "%") + end + break + end + end + end + + elseif dataKey == "healing" then + local sum = entry._sum or 0 + local esum = entry._esum or sum + local overheal = sum - esum + local ctime = entry._ctime or 1 + local hps = esum / math.max(ctime, 1) + local overPct = sum > 0 and NanamiDPS.round(overheal / sum * 100, 1) or 0 + + AddLine(L["Total Healing"], NanamiDPS.formatNumber(sum)) + AddLine(L["Effective Healing"], NanamiDPS.formatNumber(esum)) + AddLine(L["Overhealing"], "|cffcc8888+" .. NanamiDPS.formatNumber(overheal) .. " (" .. overPct .. "%)") + AddLine(L["HPS"], NanamiDPS.round(hps, 1)) + AddLine(L["Combat Time"], NanamiDPS.formatTime(ctime)) + + local mod = NanamiDPS.modules[frame.moduleName] + if mod then + local bars = mod:GetBars(seg) + for i, b in ipairs(bars) do + if b.id == playerName then + AddLine(L["Rank"], "#" .. i .. " " .. L["of total"] .. " " .. table.getn(bars)) + if b.percent then + AddLine(L["Percent"], NanamiDPS.round(b.percent, 1) .. "%") + end + break + end + end + end + + elseif dataKey == "damageTaken" then + local sum = entry._sum or 0 + local ctime = entry._ctime or 1 + + AddLine(L["Damage Taken"], NanamiDPS.formatNumber(sum)) + AddLine(L["Per Second"], NanamiDPS.round(sum / math.max(ctime, 1), 1)) + AddLine(L["Combat Time"], NanamiDPS.formatTime(ctime)) + + elseif dataKey == "deaths" then + AddLine(L["Deaths"], tostring(entry._sum or 0)) + if entry.events and table.getn(entry.events) > 0 then + AddSeparator() + AddLine(L["Death Log"], "", labelHex) + local evts = entry.events + local showCount = math.min(table.getn(evts), 5) + for di = table.getn(evts), math.max(1, table.getn(evts) - showCount + 1), -1 do + local death = evts[di] + if death then + AddLine(" #" .. di, death.timeStr or "", "|cffaaaaaa", "|cffaaaaaa") + if death.events then + local last = death.events[table.getn(death.events)] + if last then + local info = (last.spell or "?") .. " (" .. (last.source or "?") .. ")" + AddLine(" " .. L["Killing Blow"], info, "|cffff4444", "|cffff8888") + end + end + end + end + end + + elseif dataKey == "dispels" then + AddLine(L["Dispels"], tostring(entry._sum or 0)) + if entry.spells then + AddSeparator() + AddLine(L["Spell Breakdown"], "", labelHex) + local sorted = {} + for spell, count in pairs(entry.spells) do + table.insert(sorted, { spell = spell, count = count }) + end + table.sort(sorted, function(a, b) return a.count > b.count end) + for _, s in ipairs(sorted) do + AddLine(" " .. s.spell, tostring(s.count), "|cffffffff", "|cffffffff") + end + end + + elseif dataKey == "interrupts" then + AddLine(L["Interrupts"], tostring(entry._sum or 0)) + if entry.spells then + AddSeparator() + AddLine(L["Spell Breakdown"], "", labelHex) + local sorted = {} + for spell, count in pairs(entry.spells) do + table.insert(sorted, { spell = spell, count = count }) + end + table.sort(sorted, function(a, b) return a.count > b.count end) + for _, s in ipairs(sorted) do + AddLine(" " .. s.spell, tostring(s.count), "|cffffffff", "|cffffffff") + end + end + + elseif dataKey == "threat" then + AddLine(L["Threat (Est.)"], NanamiDPS.formatNumber(entry._sum or 0)) + + local dmgEntry = seg.data.damage[playerName] + local healEntry = seg.data.healing[playerName] + AddSeparator() + AddLine(L["Threat Breakdown"], "", labelHex) + if dmgEntry then + AddLine(" " .. L["Damage Contribution"], NanamiDPS.formatNumber(dmgEntry._sum)) + end + if healEntry then + AddLine(" " .. L["Healing Contribution"], NanamiDPS.formatNumber(healEntry._sum) .. " (x0.5)") + end + + elseif dataKey == "activity" then + local segDuration = seg.duration + if segDuration <= 0 then + segDuration = GetTime() - (seg.startTime or GetTime()) + end + if segDuration <= 0 then segDuration = 1 end + + local activeTime = entry._activeTime or 0 + local pct = math.min(activeTime / segDuration * 100, 100) + + AddLine(L["Activity"], NanamiDPS.round(pct, 1) .. "%") + AddLine(L["Active Time"], NanamiDPS.formatTime(activeTime)) + AddLine(L["Fight Duration"], NanamiDPS.formatTime(segDuration)) + end +end + +----------------------------------------------------------------------- +-- Page 3: Comparison (show all players as horizontal bars) +----------------------------------------------------------------------- +function DetailView:RefreshCompare() + local frame = detailFrame + if not frame or not frame:IsShown() then return end + + local page = frame.pageCompare + local seg = DataStore:GetSegment(frame.segmentIndex or 1) + + local mod = NanamiDPS.modules[frame.moduleName] + if not mod then return end + + local bars = mod:GetBars(seg) + if not bars then bars = {} end + + local maxVal = 0 + for _, b in ipairs(bars) do + if (b.value or 0) > maxVal then maxVal = b.value end + end + + local barH = 16 + local barSpacing = 2 + + -- Create bars as needed + while table.getn(page.bars) < table.getn(bars) do + local bar = CreateMiniBar(page, table.getn(page.bars) + 1) + table.insert(page.bars, bar) + end + + local playerName = frame.playerName + + for i = 1, math.max(table.getn(page.bars), table.getn(bars)) do + local bar = page.bars[i] + if not bar then break end + + if i <= table.getn(bars) then + local d = bars[i] + bar:Show() + bar:SetMinMaxValues(0, maxVal > 0 and maxVal or 1) + bar:SetValue(d.value or 0) + + local r, g, b = d.r or 0.6, d.g or 0.6, d.b or 0.6 + if d.id == playerName then + local A = SFrames.ActiveTheme + if A and A.accent then + bar:SetStatusBarColor(A.accent[1], A.accent[2], A.accent[3], 0.9) + bar.bg:SetVertexColor(A.accent[1] * 0.3, A.accent[2] * 0.3, A.accent[3] * 0.3, 0.6) + else + bar:SetStatusBarColor(1, 0.53, 0.67, 0.9) + bar.bg:SetVertexColor(0.25, 0.1, 0.15, 0.6) + end + else + bar:SetStatusBarColor(r, g, b, 0.7) + bar.bg:SetVertexColor(r * 0.2, g * 0.2, b * 0.2, 0.5) + end + + bar.textLeft:SetText("|cffffffff" .. i .. ". " .. (d.name or L["Unknown"])) + + local valStr + if d.valueText then + valStr = d.valueText + else + valStr = NanamiDPS.formatNumber(d.value or 0) + if d.percent then + valStr = valStr .. " (" .. NanamiDPS.round(d.percent, 1) .. "%)" + end + end + bar.textRight:SetText("|cffffffff" .. valStr) + + bar:ClearAllPoints() + bar:SetPoint("TOPLEFT", page, "TOPLEFT", 0, -((i - 1) * (barH + barSpacing))) + bar:SetPoint("TOPRIGHT", page, "TOPRIGHT", 0, -((i - 1) * (barH + barSpacing))) + bar:SetHeight(barH) + else + bar:Hide() + end + end +end + +----------------------------------------------------------------------- +-- Refresh current active tab +----------------------------------------------------------------------- +function DetailView:Refresh() + if not detailFrame or not detailFrame:IsShown() then return end + self:SwitchTab(detailFrame.activeTab or 1) +end + +----------------------------------------------------------------------- +-- Report detail data to chat +----------------------------------------------------------------------- +local function GetSpellReportLines(count) + if not detailFrame then return {} end + + local seg = DataStore:GetSegment(detailFrame.segmentIndex or 1) + local entry, dataKey = GetPlayerEntry(detailFrame.moduleName, seg, detailFrame.playerName) + if not entry then return {} end + + local spellData = {} + local totalVal = 0 + + if entry.spells then + for spell, amount in pairs(entry.spells) do + table.insert(spellData, { + name = spell, + value = amount, + effective = entry.effective and entry.effective[spell], + }) + totalVal = totalVal + amount + end + elseif entry.events then + local evts = entry.events + if table.getn(evts) > 0 then + local lastDeath = evts[table.getn(evts)] + if lastDeath.events then + for _, evt in ipairs(lastDeath.events) do + local spell = evt.spell or L["Unknown"] + local amount = math.abs(evt.amount or 0) + table.insert(spellData, { + name = spell .. " (" .. (evt.source or "?") .. ")", + value = amount, + }) + totalVal = totalVal + amount + end + end + end + elseif dataKey == "activity" then + local activeTime = entry._activeTime or 0 + table.insert(spellData, { name = L["Active Time"], value = activeTime }) + totalVal = activeTime + end + + table.sort(spellData, function(a, b) return a.value > b.value end) + + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(spellData)) do + local sd = spellData[i] + local pct = totalVal > 0 and NanamiDPS.round(sd.value / totalVal * 100, 1) or 0 + local valStr = NanamiDPS.formatNumber(sd.value) + if sd.effective and sd.effective ~= sd.value then + valStr = NanamiDPS.formatNumber(sd.effective) .. " [+" .. NanamiDPS.formatNumber(sd.value - sd.effective) .. "]" + end + table.insert(lines, string.format("%d. %s - %s (%.1f%%)", i, sd.name, valStr, pct)) + end + return lines +end + +local function GetSummaryReportLines(count) + if not detailFrame then return {} end + + local seg = DataStore:GetSegment(detailFrame.segmentIndex or 1) + local entry, dataKey = GetPlayerEntry(detailFrame.moduleName, seg, detailFrame.playerName) + if not entry then return {} end + + local lines = {} + + if dataKey == "damage" then + local sum = entry._sum or 0 + local ctime = entry._ctime or 1 + local dps = sum / math.max(ctime, 1) + table.insert(lines, L["Total Damage"] .. ": " .. NanamiDPS.formatNumber(sum)) + table.insert(lines, L["DPS"] .. ": " .. NanamiDPS.round(dps, 1)) + table.insert(lines, L["Combat Time"] .. ": " .. NanamiDPS.formatTime(ctime)) + elseif dataKey == "healing" then + local sum = entry._sum or 0 + local esum = entry._esum or sum + local overheal = sum - esum + local ctime = entry._ctime or 1 + local hps = esum / math.max(ctime, 1) + local overPct = sum > 0 and NanamiDPS.round(overheal / sum * 100, 1) or 0 + table.insert(lines, L["Total Healing"] .. ": " .. NanamiDPS.formatNumber(sum)) + table.insert(lines, L["Effective Healing"] .. ": " .. NanamiDPS.formatNumber(esum)) + table.insert(lines, L["Overhealing"] .. ": +" .. NanamiDPS.formatNumber(overheal) .. " (" .. overPct .. "%)") + table.insert(lines, L["HPS"] .. ": " .. NanamiDPS.round(hps, 1)) + table.insert(lines, L["Combat Time"] .. ": " .. NanamiDPS.formatTime(ctime)) + elseif dataKey == "damageTaken" then + local sum = entry._sum or 0 + local ctime = entry._ctime or 1 + table.insert(lines, L["Damage Taken"] .. ": " .. NanamiDPS.formatNumber(sum)) + table.insert(lines, L["Per Second"] .. ": " .. NanamiDPS.round(sum / math.max(ctime, 1), 1)) + table.insert(lines, L["Combat Time"] .. ": " .. NanamiDPS.formatTime(ctime)) + elseif dataKey == "deaths" then + table.insert(lines, L["Deaths"] .. ": " .. tostring(entry._sum or 0)) + elseif dataKey == "dispels" then + table.insert(lines, L["Dispels"] .. ": " .. tostring(entry._sum or 0)) + elseif dataKey == "interrupts" then + table.insert(lines, L["Interrupts"] .. ": " .. tostring(entry._sum or 0)) + elseif dataKey == "threat" then + table.insert(lines, L["Threat (Est.)"] .. ": " .. NanamiDPS.formatNumber(entry._sum or 0)) + elseif dataKey == "activity" then + local segDuration = seg.duration + if segDuration <= 0 then + segDuration = GetTime() - (seg.startTime or GetTime()) + end + if segDuration <= 0 then segDuration = 1 end + local activeTime = entry._activeTime or 0 + local pct = math.min(activeTime / segDuration * 100, 100) + table.insert(lines, L["Activity"] .. ": " .. NanamiDPS.round(pct, 1) .. "%") + table.insert(lines, L["Active Time"] .. ": " .. NanamiDPS.formatTime(activeTime)) + table.insert(lines, L["Fight Duration"] .. ": " .. NanamiDPS.formatTime(segDuration)) + end + + return lines +end + +function DetailView:ShowReport() + if not detailFrame or not detailFrame:IsShown() then return end + + local Window = NanamiDPS.Window + if not Window or not Window.ShowReportCustom then return end + + local playerName = detailFrame.playerName or L["Unknown"] + local mod = NanamiDPS.modules[detailFrame.moduleName] + local modName = mod and mod:GetName() or detailFrame.moduleName + local activeTab = detailFrame.activeTab or 1 + + local segIdx = detailFrame.segmentIndex or 1 + local segName + if segIdx == 0 then segName = L["Total"] + elseif segIdx == 1 then segName = L["Current"] + else + local segList = DataStore:GetSegmentList() + segName = "Segment " .. segIdx + for _, s in ipairs(segList) do + if s.index == segIdx then segName = s.name; break end + end + end + + local accentHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + local tabName + if activeTab == 1 then tabName = L["Spell Breakdown"] + elseif activeTab == 2 then tabName = L["Summary"] + else tabName = L["Comparison"] + end + + local infoStr = "|c" .. accentHex .. playerName .. "|r - " .. modName .. " (" .. tabName .. ")" + + local headerFn, linesFn + + if activeTab == 3 then + headerFn = function() + return "Nanami DPS - " .. segName .. " " .. modName .. ":" + end + linesFn = function(count) + local seg = DataStore:GetSegment(segIdx) + if mod and mod.GetReportLines then + return mod:GetReportLines(seg, count) + end + return {} + end + elseif activeTab == 2 then + headerFn = function() + return "Nanami DPS - " .. playerName .. " " .. modName .. " (" .. segName .. "):" + end + linesFn = function(count) + return GetSummaryReportLines(count) + end + else + headerFn = function() + return "Nanami DPS - " .. playerName .. " " .. modName .. " " .. L["Spell Breakdown"] .. " (" .. segName .. "):" + end + linesFn = function(count) + return GetSpellReportLines(count) + end + end + + Window:ShowReportCustom(detailFrame, infoStr, headerFn, linesFn) +end + +----------------------------------------------------------------------- +-- Register refresh callback +----------------------------------------------------------------------- +NanamiDPS:RegisterCallback("INIT", "DetailView", function() + NanamiDPS:RegisterCallback("refresh", "DetailViewRefresh", function() + if detailFrame and detailFrame:IsShown() then + DetailView:Refresh() + end + end) +end) diff --git a/Locale.lua b/Locale.lua new file mode 100644 index 0000000..248ec49 --- /dev/null +++ b/Locale.lua @@ -0,0 +1,216 @@ +NanamiDPS.L = {} +local L = NanamiDPS.L + +local locale = GetLocale and GetLocale() or "enUS" + +L["Damage Done"] = "Damage Done" +L["DPS"] = "DPS" +L["Damage Taken"] = "Damage Taken" +L["Healing Done"] = "Healing Done" +L["HPS"] = "HPS" +L["Overhealing"] = "Overhealing" +L["Deaths"] = "Deaths" +L["Dispels"] = "Dispels" +L["Interrupts"] = "Interrupts" +L["Threat (Est.)"] = "Threat (Est.)" +L["Activity"] = "Activity" +L["Enemy Damage Done"] = "Enemy Damage Done" +L["Damage by Spell"] = "Damage by Spell" +L["Healing by Spell"] = "Healing by Spell" +L["Current"] = "Current" +L["Total"] = "Total" +L["Reset"] = "Reset" +L["Settings"] = "Settings" +L["Report"] = "Report" +L["Lock"] = "Lock" +L["Unlock"] = "Unlock" +L["Segment"] = "Segment" +L["Mode"] = "Mode" +L["No Data"] = "No Data" +L["Pet"] = "Pet" +L["Auto Hit"] = "Auto Hit" +L["Details"] = "Details" +L["Overheal"] = "Overheal" +L["Per Second"] = "Per Second" +L["Total Amount"] = "Total Amount" +L["Active Time"] = "Active Time" +L["Percent"] = "Percent" +L["Died at"] = "Died at" +L["Killing Blow"] = "Killing Blow" +L["Last Events"] = "Last Events" +L["Report to Chat"] = "Report to Chat" +L["Reset Data?"] = "Do you wish to reset all data?" +L["Bar Height"] = "Bar Height" +L["Bar Spacing"] = "Bar Spacing" +L["Font Size"] = "Font Size" +L["Track All Units"] = "Track All Units" +L["Merge Pets"] = "Merge Pets with Owner" +L["Show Class Icons"] = "Show Class Icons" +L["Lock Windows"] = "Lock Windows" +L["Backdrop Alpha"] = "Backdrop Alpha" +L["Max Segments"] = "Max History Segments" +L["Hide Window"] = "Hide Window" +L["Shift-click to reset"] = "Shift-click to reset instantly" +L["Fight Duration"] = "Fight Duration" +L["Effective"] = "Effective" +L["Unknown"] = "Unknown" +L["Display Settings"] = "Display Settings" +L["Data Settings"] = "Data Settings" +L["Window Settings"] = "Window Settings" +L["Players"] = "Players" +L["Targets"] = "Targets" +L["Data Reset"] = "Data reset." +L["Windows Locked"] = "Windows locked" +L["Windows Unlocked"] = "Windows unlocked" +L["Commands"] = "Commands" +L["Show/Hide"] = "Show/Hide" +L["Reset All Data"] = "Reset all data" +L["Open Settings"] = "Open settings" +L["Lock/Unlock"] = "Lock/Unlock windows" +L["Report to chat"] = "Report to chat" +L["Create New Window"] = "Create new window" +L["Back"] = "Back" +L["Detail View"] = "Detail View" +L["Spell Breakdown"] = "Spell Breakdown" +L["Summary"] = "Summary" +L["Comparison"] = "Comparison" +L["Top Spells"] = "Top Spells" +L["Click to view details"] = "Click to view details" +L["Combat Time"] = "Combat Time" +L["Max Hit"] = "Max Hit" +L["Avg Hit"] = "Avg Hit" +L["Overheal %"] = "Overheal %" +L["Total Healing"] = "Total Healing" +L["Effective Healing"] = "Effective Healing" +L["Total Damage"] = "Total Damage" +L["Rank"] = "Rank" +L["of total"] = "of total" +L["Spell Chart"] = "Spell Chart" +L["Player Comparison"] = "Player Comparison" +L["Death Log"] = "Death Log" +L["Threat Breakdown"] = "Threat Breakdown" +L["Damage Contribution"] = "Damage Contribution" +L["Healing Contribution"] = "Healing Contribution" +L["Say"] = "Say" +L["Yell"] = "Yell" +L["Party"] = "Party" +L["Raid"] = "Raid" +L["Guild"] = "Guild" +L["Officer"] = "Officer" +L["Whisper"] = "Whisper" +L["Channel"] = "Channel" +L["Send Report"] = "Send Report" +L["Report Lines"] = "Lines" +L["Report Sent"] = "Report sent!" +L["No Report Data"] = "No data to report." +L["Enter Whisper Target"] = "Enter player name" +L["Owner"] = "Owner" +L["Threat Note"] = "Includes spell-specific threat modifiers" +L["Drag to Resize"] = "Drag to resize" + +if locale == "zhCN" or locale == "zhTW" then + L["Damage Done"] = "造成伤害" + L["DPS"] = "每秒伤害" + L["Damage Taken"] = "受到伤害" + L["Healing Done"] = "治疗量" + L["HPS"] = "每秒治疗" + L["Overhealing"] = "过量治疗" + L["Deaths"] = "死亡" + L["Dispels"] = "驱散" + L["Interrupts"] = "打断" + L["Threat (Est.)"] = "仇恨(估算)" + L["Activity"] = "活跃时间" + L["Enemy Damage Done"] = "敌方伤害" + L["Damage by Spell"] = "技能伤害" + L["Healing by Spell"] = "技能治疗" + L["Current"] = "当前" + L["Total"] = "总计" + L["Reset"] = "重置" + L["Settings"] = "设置" + L["Report"] = "汇报" + L["Lock"] = "锁定" + L["Unlock"] = "解锁" + L["Segment"] = "战斗段" + L["Mode"] = "模式" + L["No Data"] = "暂无数据" + L["Pet"] = "宠物" + L["Auto Hit"] = "自动攻击" + L["Details"] = "详情" + L["Overheal"] = "过量治疗" + L["Per Second"] = "每秒" + L["Total Amount"] = "总量" + L["Active Time"] = "活跃时间" + L["Percent"] = "百分比" + L["Died at"] = "死亡于" + L["Killing Blow"] = "致死一击" + L["Last Events"] = "最后事件" + L["Report to Chat"] = "汇报到聊天" + L["Reset Data?"] = "确定重置所有数据吗?" + L["Bar Height"] = "条高度" + L["Bar Spacing"] = "条间距" + L["Font Size"] = "字体大小" + L["Track All Units"] = "追踪所有单位" + L["Merge Pets"] = "合并宠物数据" + L["Show Class Icons"] = "显示职业图标" + L["Lock Windows"] = "锁定窗口" + L["Backdrop Alpha"] = "背景透明度" + L["Max Segments"] = "最大历史段数" + L["Hide Window"] = "隐藏窗口" + L["Shift-click to reset"] = "Shift-点击立即重置" + L["Fight Duration"] = "战斗时长" + L["Effective"] = "有效值" + L["Unknown"] = "未知" + L["Display Settings"] = "显示设置" + L["Data Settings"] = "数据设置" + L["Window Settings"] = "窗口设置" + L["Players"] = "玩家" + L["Targets"] = "目标" + L["Data Reset"] = "数据已重置。" + L["Windows Locked"] = "窗口已锁定" + L["Windows Unlocked"] = "窗口已解锁" + L["Commands"] = "命令列表" + L["Show/Hide"] = "显示/隐藏" + L["Reset All Data"] = "重置所有数据" + L["Open Settings"] = "打开设置" + L["Lock/Unlock"] = "锁定/解锁窗口" + L["Report to chat"] = "汇报到聊天" + L["Create New Window"] = "创建新窗口" + L["Back"] = "返回" + L["Detail View"] = "详细信息" + L["Spell Breakdown"] = "技能分解" + L["Summary"] = "概要" + L["Comparison"] = "对比" + L["Top Spells"] = "技能排名" + L["Click to view details"] = "点击查看详情" + L["Combat Time"] = "战斗时间" + L["Max Hit"] = "最高一击" + L["Avg Hit"] = "平均一击" + L["Overheal %"] = "过量治疗%" + L["Total Healing"] = "总治疗量" + L["Effective Healing"] = "有效治疗" + L["Total Damage"] = "总伤害" + L["Rank"] = "排名" + L["of total"] = "占总量" + L["Spell Chart"] = "技能图表" + L["Player Comparison"] = "玩家对比" + L["Death Log"] = "死亡记录" + L["Threat Breakdown"] = "仇恨分解" + L["Damage Contribution"] = "伤害贡献" + L["Healing Contribution"] = "治疗贡献" + L["Say"] = "说" + L["Yell"] = "喊叫" + L["Party"] = "小队" + L["Raid"] = "团队" + L["Guild"] = "公会" + L["Officer"] = "军官" + L["Whisper"] = "密语" + L["Channel"] = "频道" + L["Send Report"] = "发送汇报" + L["Report Lines"] = "行数" + L["Report Sent"] = "汇报已发送!" + L["No Report Data"] = "没有可汇报的数据。" + L["Enter Whisper Target"] = "输入玩家名称" + L["Owner"] = "主人" + L["Threat Note"] = "已计入技能仇恨系数" + L["Drag to Resize"] = "拖拽调整大小" +end diff --git a/Modules/Activity.lua b/Modules/Activity.lua new file mode 100644 index 0000000..94dce71 --- /dev/null +++ b/Modules/Activity.lua @@ -0,0 +1,81 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local Activity = {} + +function Activity:GetName() + return L["Activity"] +end + +function Activity:GetBars(segment) + if not segment or not segment.data or not segment.data.activity then return {} end + + local segDuration = segment.duration + if segDuration <= 0 then + segDuration = GetTime() - (segment.startTime or GetTime()) + end + if segDuration <= 0 then segDuration = 1 end + + local bars = {} + for name, entry in pairs(segment.data.activity) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + if not NanamiDPS.validClasses[class] then + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + local activeTime = entry._activeTime or 0 + local pct = segDuration > 0 and (activeTime / segDuration * 100) or 0 + pct = math.min(pct, 100) + + table.insert(bars, { + id = name, + name = name, + value = pct, + class = class, + r = r, g = g, b = b, + valueText = NanamiDPS.round(pct, 1) .. "% (" .. NanamiDPS.formatTime(activeTime) .. ")", + activeTime = activeTime, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + return bars +end + +function Activity:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.activity[playerName] then return end + local entry = segment.data.activity[playerName] + + local segDuration = segment.duration + if segDuration <= 0 then + segDuration = GetTime() - (segment.startTime or GetTime()) + end + if segDuration <= 0 then segDuration = 1 end + + local activeTime = entry._activeTime or 0 + local pct = math.min(activeTime / segDuration * 100, 100) + + tooltip:AddLine("|cffffd100" .. playerName) + tooltip:AddDoubleLine("|cffffffff" .. L["Activity"], "|cffffffff" .. NanamiDPS.round(pct, 1) .. "%") + tooltip:AddDoubleLine("|cffffffff" .. L["Active Time"], "|cffffffff" .. NanamiDPS.formatTime(activeTime)) + tooltip:AddDoubleLine("|cffffffff" .. L["Fight Duration"], "|cffffffff" .. NanamiDPS.formatTime(segDuration)) +end + +function Activity:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %.1f%% active", i, d.name, d.value)) + end + return lines +end + +NanamiDPS:RegisterModule("Activity", Activity) diff --git a/Modules/DPS.lua b/Modules/DPS.lua new file mode 100644 index 0000000..3650a00 --- /dev/null +++ b/Modules/DPS.lua @@ -0,0 +1,74 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local DPS = {} + +function DPS:GetName() + return L["DPS"] +end + +function DPS:GetBars(segment) + if not segment or not segment.data or not segment.data.damage then return {} end + + local bars = {} + for name, entry in pairs(segment.data.damage) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + if not NanamiDPS.validClasses[class] then + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + local dps = entry._sum / math.max(entry._ctime, 1) + table.insert(bars, { + id = name, + name = name, + value = dps, + class = class, + r = r, g = g, b = b, + valueText = NanamiDPS.round(dps, 1) .. " DPS", + totalDamage = entry._sum, + ctime = entry._ctime, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local best = bars[1] and bars[1].value or 0 + for _, bar in ipairs(bars) do + bar.percent = best > 0 and (bar.value / best * 100) or 0 + end + + return bars +end + +function DPS:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.damage[playerName] then return end + local entry = segment.data.damage[playerName] + local dps = entry._sum / math.max(entry._ctime, 1) + + tooltip:AddLine("|cffffd100" .. playerName) + tooltip:AddDoubleLine("|cffffffff" .. L["DPS"], "|cffffffff" .. NanamiDPS.round(dps, 1)) + tooltip:AddDoubleLine("|cffffffff" .. L["Damage Done"], "|cffffffff" .. NanamiDPS.formatNumber(entry._sum)) + tooltip:AddDoubleLine("|cffffffff" .. L["Active Time"], + "|cffffffff" .. NanamiDPS.formatTime(entry._ctime)) + + NanamiDPS.Tooltip:ShowSpellDetail(playerName, entry.spells, nil, entry._sum, nil, tooltip) +end + +function DPS:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %.1f DPS (%s)", + i, d.name, d.value, NanamiDPS.formatNumber(d.totalDamage))) + end + return lines +end + +NanamiDPS:RegisterModule("DPS", DPS) diff --git a/Modules/DamageBySpell.lua b/Modules/DamageBySpell.lua new file mode 100644 index 0000000..2245b11 --- /dev/null +++ b/Modules/DamageBySpell.lua @@ -0,0 +1,90 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local DamageBySpell = {} + +function DamageBySpell:GetName() + return L["Damage by Spell"] +end + +function DamageBySpell:GetBars(segment) + if not segment or not segment.data or not segment.data.damage then return {} end + + -- Aggregate all spells across all players + local spellTotals = {} + for name, entry in pairs(segment.data.damage) do + if entry.spells then + for spell, amount in pairs(entry.spells) do + spellTotals[spell] = (spellTotals[spell] or 0) + amount + end + end + end + + local bars = {} + for spell, total in pairs(spellTotals) do + local r, g, b = NanamiDPS.str2rgb(spell) + r = r * 0.5 + 0.4 + g = g * 0.5 + 0.4 + b = b * 0.5 + 0.4 + + table.insert(bars, { + id = spell, + name = spell, + value = total, + r = r, g = g, b = b, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local grandTotal = 0 + for _, bar in ipairs(bars) do grandTotal = grandTotal + bar.value end + for _, bar in ipairs(bars) do + bar.percent = grandTotal > 0 and (bar.value / grandTotal * 100) or 0 + end + + return bars +end + +function DamageBySpell:GetTooltip(spellName, segment, tooltip) + tooltip:AddLine("|cffffd100" .. (spellName or L["Unknown"])) + + if segment and segment.data.damage then + local users = {} + for name, entry in pairs(segment.data.damage) do + if entry.spells and entry.spells[spellName] then + table.insert(users, { name = name, amount = entry.spells[spellName] }) + end + end + table.sort(users, function(a, b) return a.amount > b.amount end) + + if table.getn(users) > 0 then + local total = 0 + for _, u in ipairs(users) do total = total + u.amount end + + tooltip:AddDoubleLine("|cffffffff" .. L["Total Amount"], "|cffffffff" .. NanamiDPS.formatNumber(total)) + tooltip:AddLine(" ") + tooltip:AddLine("|cffffd100" .. L["Players"] .. ":") + for _, u in ipairs(users) do + local pct = total > 0 and NanamiDPS.round(u.amount / total * 100, 1) or 0 + tooltip:AddDoubleLine("|cffffffff" .. u.name, + "|cffffffff" .. NanamiDPS.formatNumber(u.amount) .. " (" .. pct .. "%)") + end + end + end +end + +function DamageBySpell:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %s (%.1f%%)", + i, d.name, NanamiDPS.formatNumber(d.value), d.percent)) + end + return lines +end + +NanamiDPS:RegisterModule("DamageBySpell", DamageBySpell) diff --git a/Modules/DamageDone.lua b/Modules/DamageDone.lua new file mode 100644 index 0000000..350290b --- /dev/null +++ b/Modules/DamageDone.lua @@ -0,0 +1,79 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local DamageDone = {} + +function DamageDone:GetName() + return L["Damage Done"] +end + +function DamageDone:GetBars(segment) + if not segment or not segment.data or not segment.data.damage then return {} end + + local bars = {} + for name, entry in pairs(segment.data.damage) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + if not NanamiDPS.validClasses[class] then + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + local dmg = entry._sum or 0 + local dps = dmg / math.max(entry._ctime or 0, 1) + + table.insert(bars, { + id = name, + name = name, + value = dmg, + class = class, + r = r, g = g, b = b, + dps = dps, + ctime = entry._ctime, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local total = 0 + for _, bar in ipairs(bars) do total = total + bar.value end + for _, bar in ipairs(bars) do + bar.percent = total > 0 and (bar.value / total * 100) or 0 + bar.valueText = NanamiDPS.formatNumber(bar.value) + .. " " .. NanamiDPS.formatNumber(bar.dps) .. "/s" + .. " (" .. NanamiDPS.round(bar.percent, 1) .. "%)" + end + + return bars +end + +function DamageDone:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.damage[playerName] then return end + local entry = segment.data.damage[playerName] + + tooltip:AddLine("|cffffd100" .. playerName) + tooltip:AddDoubleLine("|cffffffff" .. L["Damage Done"], "|cffffffff" .. NanamiDPS.formatNumber(entry._sum)) + tooltip:AddDoubleLine("|cffffffff" .. L["DPS"], + "|cffffffff" .. NanamiDPS.round(entry._sum / math.max(entry._ctime, 1), 1)) + tooltip:AddDoubleLine("|cffffffff" .. L["Active Time"], + "|cffffffff" .. NanamiDPS.formatTime(entry._ctime)) + + NanamiDPS.Tooltip:ShowSpellDetail(playerName, entry.spells, nil, entry._sum, nil, tooltip) +end + +function DamageDone:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %s (%.1f%%)", + i, d.name, NanamiDPS.formatNumber(d.value), d.percent)) + end + return lines +end + +NanamiDPS:RegisterModule("DamageDone", DamageDone) diff --git a/Modules/DamageTaken.lua b/Modules/DamageTaken.lua new file mode 100644 index 0000000..2e9aca6 --- /dev/null +++ b/Modules/DamageTaken.lua @@ -0,0 +1,69 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local DamageTaken = {} + +function DamageTaken:GetName() + return L["Damage Taken"] +end + +function DamageTaken:GetBars(segment) + if not segment or not segment.data or not segment.data.damageTaken then return {} end + + local bars = {} + for name, entry in pairs(segment.data.damageTaken) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + if not NanamiDPS.validClasses[class] then + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + table.insert(bars, { + id = name, + name = name, + value = entry._sum or 0, + class = class, + r = r, g = g, b = b, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local total = 0 + for _, bar in ipairs(bars) do total = total + bar.value end + for _, bar in ipairs(bars) do + bar.percent = total > 0 and (bar.value / total * 100) or 0 + end + + return bars +end + +function DamageTaken:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.damageTaken[playerName] then return end + local entry = segment.data.damageTaken[playerName] + + tooltip:AddLine("|cffffd100" .. playerName) + tooltip:AddDoubleLine("|cffffffff" .. L["Damage Taken"], "|cffffffff" .. NanamiDPS.formatNumber(entry._sum)) + tooltip:AddDoubleLine("|cffffffff" .. L["Active Time"], + "|cffffffff" .. NanamiDPS.formatTime(entry._ctime)) + + NanamiDPS.Tooltip:ShowSpellDetail(playerName, entry.spells, nil, entry._sum, nil, tooltip) +end + +function DamageTaken:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %s (%.1f%%)", + i, d.name, NanamiDPS.formatNumber(d.value), d.percent)) + end + return lines +end + +NanamiDPS:RegisterModule("DamageTaken", DamageTaken) diff --git a/Modules/Deaths.lua b/Modules/Deaths.lua new file mode 100644 index 0000000..7f47c89 --- /dev/null +++ b/Modules/Deaths.lua @@ -0,0 +1,94 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local Deaths = {} + +function Deaths:GetName() + return L["Deaths"] +end + +function Deaths:GetBars(segment) + if not segment or not segment.data or not segment.data.deaths then return {} end + + local bars = {} + for name, entry in pairs(segment.data.deaths) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + if not NanamiDPS.validClasses[class] then + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + table.insert(bars, { + id = name, + name = name, + value = entry._sum or 0, + class = class, + r = r, g = g, b = b, + valueText = tostring(entry._sum or 0) .. "x", + events = entry.events, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + return bars +end + +function Deaths:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.deaths[playerName] then return end + local entry = segment.data.deaths[playerName] + + tooltip:AddLine("|cffffd100" .. playerName .. " - " .. L["Deaths"]) + tooltip:AddDoubleLine("|cffffffff" .. L["Deaths"], "|cffffffff" .. (entry._sum or 0)) + + if entry.events and table.getn(entry.events) > 0 then + local lastDeath = entry.events[table.getn(entry.events)] + + tooltip:AddLine(" ") + tooltip:AddLine("|cffffd100" .. L["Last Events"] .. " (" .. (lastDeath.timeStr or "") .. "):") + + if lastDeath.events then + local startIdx = math.max(1, table.getn(lastDeath.events) - 9) + for i = startIdx, table.getn(lastDeath.events) do + local evt = lastDeath.events[i] + if evt then + local timeAgo = "" + if lastDeath.time and evt.time then + timeAgo = string.format("-%.1fs", lastDeath.time - evt.time) + end + + if evt.type == "damage" then + local line = string.format("|cffff4444-%s|r %s (%s)", + NanamiDPS.formatNumber(math.abs(evt.amount or 0)), + evt.spell or "?", + evt.source or "?") + tooltip:AddDoubleLine("|cffaaaaaa" .. timeAgo, line) + elseif evt.type == "heal" then + local line = string.format("|cff44ff44+%s|r %s (%s)", + NanamiDPS.formatNumber(evt.amount or 0), + evt.spell or "?", + evt.source or "?") + tooltip:AddDoubleLine("|cffaaaaaa" .. timeAgo, line) + end + end + end + end + end +end + +function Deaths:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %dx deaths", i, d.name, d.value)) + end + return lines +end + +NanamiDPS:RegisterModule("Deaths", Deaths) diff --git a/Modules/Dispels.lua b/Modules/Dispels.lua new file mode 100644 index 0000000..1075589 --- /dev/null +++ b/Modules/Dispels.lua @@ -0,0 +1,78 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local Dispels = {} + +function Dispels:GetName() + return L["Dispels"] +end + +function Dispels:GetBars(segment) + if not segment or not segment.data or not segment.data.dispels then return {} end + + local bars = {} + for name, entry in pairs(segment.data.dispels) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + if not NanamiDPS.validClasses[class] then + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + table.insert(bars, { + id = name, + name = name, + value = entry._sum or 0, + class = class, + r = r, g = g, b = b, + valueText = tostring(entry._sum or 0), + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local total = 0 + for _, bar in ipairs(bars) do total = total + bar.value end + for _, bar in ipairs(bars) do + bar.percent = total > 0 and (bar.value / total * 100) or 0 + end + + return bars +end + +function Dispels:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.dispels[playerName] then return end + local entry = segment.data.dispels[playerName] + + tooltip:AddLine("|cffffd100" .. playerName .. " - " .. L["Dispels"]) + tooltip:AddDoubleLine("|cffffffff" .. L["Dispels"], "|cffffffff" .. (entry._sum or 0)) + + if entry.spells then + tooltip:AddLine(" ") + tooltip:AddLine("|cffffd100" .. L["Details"] .. ":") + local sorted = {} + for spell, count in pairs(entry.spells) do + table.insert(sorted, { spell = spell, count = count }) + end + table.sort(sorted, function(a, b) return a.count > b.count end) + for _, s in ipairs(sorted) do + tooltip:AddDoubleLine("|cffffffff" .. s.spell, "|cffffffff" .. s.count) + end + end +end + +function Dispels:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %d dispels", i, d.name, d.value)) + end + return lines +end + +NanamiDPS:RegisterModule("Dispels", Dispels) diff --git a/Modules/EnemyDamageDone.lua b/Modules/EnemyDamageDone.lua new file mode 100644 index 0000000..e8bf60e --- /dev/null +++ b/Modules/EnemyDamageDone.lua @@ -0,0 +1,90 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local EnemyDamageDone = {} + +function EnemyDamageDone:GetName() + return L["Enemy Damage Done"] +end + +function EnemyDamageDone:GetBars(segment) + if not segment or not segment.data or not segment.data.damageTaken then return {} end + + -- Aggregate damage by source across all targets + local bySource = {} + for targetName, entry in pairs(segment.data.damageTaken) do + if entry.spells then + for spell, amount in pairs(entry.spells) do + -- Extract real source from spell name if available + -- Otherwise group by spell + if not bySource[spell] then + bySource[spell] = 0 + end + bySource[spell] = bySource[spell] + amount + end + end + end + + local bars = {} + for spell, total in pairs(bySource) do + local r, g, b = NanamiDPS.str2rgb(spell) + r = r * 0.5 + 0.5 + g = g * 0.3 + 0.2 + b = b * 0.3 + 0.2 + + table.insert(bars, { + id = spell, + name = spell, + value = total, + r = r, g = g, b = b, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local grandTotal = 0 + for _, bar in ipairs(bars) do grandTotal = grandTotal + bar.value end + for _, bar in ipairs(bars) do + bar.percent = grandTotal > 0 and (bar.value / grandTotal * 100) or 0 + end + + return bars +end + +function EnemyDamageDone:GetTooltip(spellName, segment, tooltip) + tooltip:AddLine("|cffffd100" .. (spellName or L["Unknown"])) + + -- Find all targets hit by this spell + if segment and segment.data.damageTaken then + local targets = {} + for targetName, entry in pairs(segment.data.damageTaken) do + if entry.spells and entry.spells[spellName] then + table.insert(targets, { name = targetName, amount = entry.spells[spellName] }) + end + end + table.sort(targets, function(a, b) return a.amount > b.amount end) + + if table.getn(targets) > 0 then + tooltip:AddLine(" ") + tooltip:AddLine("|cffffd100" .. L["Targets"] .. ":") + for _, t in ipairs(targets) do + tooltip:AddDoubleLine("|cffffffff" .. t.name, "|cffffffff" .. NanamiDPS.formatNumber(t.amount)) + end + end + end +end + +function EnemyDamageDone:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %s (%.1f%%)", + i, d.name, NanamiDPS.formatNumber(d.value), d.percent)) + end + return lines +end + +NanamiDPS:RegisterModule("EnemyDamageDone", EnemyDamageDone) diff --git a/Modules/HPS.lua b/Modules/HPS.lua new file mode 100644 index 0000000..cb6941a --- /dev/null +++ b/Modules/HPS.lua @@ -0,0 +1,80 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local HPS = {} + +function HPS:GetName() + return L["HPS"] +end + +function HPS:GetBars(segment) + if not segment or not segment.data or not segment.data.healing then return {} end + + local bars = {} + for name, entry in pairs(segment.data.healing) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + if not NanamiDPS.validClasses[class] then + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + local effectiveVal = entry._esum or entry._sum + local hps = effectiveVal / math.max(entry._ctime, 1) + + table.insert(bars, { + id = name, + name = name, + value = hps, + class = class, + r = r, g = g, b = b, + valueText = NanamiDPS.round(hps, 1) .. " HPS", + effectiveHeal = effectiveVal, + totalHeal = entry._sum, + ctime = entry._ctime, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local best = bars[1] and bars[1].value or 0 + for _, bar in ipairs(bars) do + bar.percent = best > 0 and (bar.value / best * 100) or 0 + end + + return bars +end + +function HPS:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.healing[playerName] then return end + local entry = segment.data.healing[playerName] + local effectiveVal = entry._esum or entry._sum + local hps = effectiveVal / math.max(entry._ctime, 1) + + tooltip:AddLine("|cffffd100" .. playerName) + tooltip:AddDoubleLine("|cffffffff" .. L["HPS"], "|cffffffff" .. NanamiDPS.round(hps, 1)) + tooltip:AddDoubleLine("|cffffffff" .. L["Healing Done"], "|cffffffff" .. NanamiDPS.formatNumber(effectiveVal)) + tooltip:AddDoubleLine("|cffaaaaaa" .. L["Overheal"], + "|cffcc8888+" .. NanamiDPS.formatNumber(entry._sum - effectiveVal)) + tooltip:AddDoubleLine("|cffffffff" .. L["Active Time"], + "|cffffffff" .. NanamiDPS.formatTime(entry._ctime)) + + NanamiDPS.Tooltip:ShowSpellDetail(playerName, entry.spells, entry.effective, entry._sum, effectiveVal, tooltip) +end + +function HPS:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %.1f HPS (%s)", + i, d.name, d.value, NanamiDPS.formatNumber(d.effectiveHeal))) + end + return lines +end + +NanamiDPS:RegisterModule("HPS", HPS) diff --git a/Modules/HealingBySpell.lua b/Modules/HealingBySpell.lua new file mode 100644 index 0000000..76198e1 --- /dev/null +++ b/Modules/HealingBySpell.lua @@ -0,0 +1,119 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local HealingBySpell = {} + +function HealingBySpell:GetName() + return L["Healing by Spell"] +end + +function HealingBySpell:GetBars(segment) + if not segment or not segment.data or not segment.data.healing then return {} end + + local spellTotals = {} + local spellEffective = {} + for name, entry in pairs(segment.data.healing) do + if entry.spells then + for spell, amount in pairs(entry.spells) do + spellTotals[spell] = (spellTotals[spell] or 0) + amount + if entry.effective and entry.effective[spell] then + spellEffective[spell] = (spellEffective[spell] or 0) + entry.effective[spell] + end + end + end + end + + local bars = {} + for spell, total in pairs(spellTotals) do + local effective = spellEffective[spell] or total + local overheal = total - effective + + local r, g, b = NanamiDPS.str2rgb(spell) + r = r * 0.4 + 0.3 + g = g * 0.5 + 0.4 + b = b * 0.4 + 0.3 + + local valText = NanamiDPS.formatNumber(effective) + if overheal > 0 then + valText = valText .. " |cffcc8888+" .. NanamiDPS.formatNumber(overheal) + end + + table.insert(bars, { + id = spell, + name = spell, + value = effective, + r = r, g = g, b = b, + valueText = valText, + totalHeal = total, + effectiveHeal = effective, + overheal = overheal, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local grandTotal = 0 + for _, bar in ipairs(bars) do grandTotal = grandTotal + bar.value end + for _, bar in ipairs(bars) do + bar.percent = grandTotal > 0 and (bar.value / grandTotal * 100) or 0 + end + + return bars +end + +function HealingBySpell:GetTooltip(spellName, segment, tooltip) + tooltip:AddLine("|cffffd100" .. (spellName or L["Unknown"])) + + if segment and segment.data.healing then + local users = {} + local totalAmount = 0 + local totalEffective = 0 + for name, entry in pairs(segment.data.healing) do + if entry.spells and entry.spells[spellName] then + local eff = (entry.effective and entry.effective[spellName]) or entry.spells[spellName] + table.insert(users, { + name = name, + amount = entry.spells[spellName], + effective = eff, + }) + totalAmount = totalAmount + entry.spells[spellName] + totalEffective = totalEffective + eff + end + end + table.sort(users, function(a, b) return a.effective > b.effective end) + + tooltip:AddDoubleLine("|cffffffff" .. L["Healing Done"], "|cffffffff" .. NanamiDPS.formatNumber(totalEffective)) + if totalAmount - totalEffective > 0 then + tooltip:AddDoubleLine("|cffaaaaaa" .. L["Overheal"], + "|cffcc8888+" .. NanamiDPS.formatNumber(totalAmount - totalEffective)) + end + + if table.getn(users) > 0 then + tooltip:AddLine(" ") + tooltip:AddLine("|cffffd100" .. L["Players"] .. ":") + for _, u in ipairs(users) do + local oh = u.amount - u.effective + local rightStr = NanamiDPS.formatNumber(u.effective) + if oh > 0 then + rightStr = rightStr .. " |cffcc8888+" .. NanamiDPS.formatNumber(oh) + end + tooltip:AddDoubleLine("|cffffffff" .. u.name, "|cffffffff" .. rightStr) + end + end + end +end + +function HealingBySpell:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %s (%.1f%%)", + i, d.name, NanamiDPS.formatNumber(d.value), d.percent)) + end + return lines +end + +NanamiDPS:RegisterModule("HealingBySpell", HealingBySpell) diff --git a/Modules/HealingDone.lua b/Modules/HealingDone.lua new file mode 100644 index 0000000..ab232ab --- /dev/null +++ b/Modules/HealingDone.lua @@ -0,0 +1,83 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local HealingDone = {} + +function HealingDone:GetName() + return L["Healing Done"] +end + +function HealingDone:GetBars(segment) + if not segment or not segment.data or not segment.data.healing then return {} end + + local bars = {} + for name, entry in pairs(segment.data.healing) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + if not NanamiDPS.validClasses[class] then + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + local effectiveVal = entry._esum or entry._sum + table.insert(bars, { + id = name, + name = name, + value = effectiveVal, + totalHeal = entry._sum, + effectiveHeal = effectiveVal, + overheal = entry._sum - effectiveVal, + class = class, + r = r, g = g, b = b, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local total = 0 + for _, bar in ipairs(bars) do total = total + bar.value end + for _, bar in ipairs(bars) do + bar.percent = total > 0 and (bar.value / total * 100) or 0 + bar.valueText = NanamiDPS.formatNumber(bar.effectiveHeal) + if bar.overheal > 0 then + bar.valueText = bar.valueText .. " |cffcc8888+" .. NanamiDPS.formatNumber(bar.overheal) + end + end + + return bars +end + +function HealingDone:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.healing[playerName] then return end + local entry = segment.data.healing[playerName] + local effectiveVal = entry._esum or entry._sum + local overhealVal = entry._sum - effectiveVal + + tooltip:AddLine("|cffffd100" .. playerName) + tooltip:AddDoubleLine("|cffffffff" .. L["Healing Done"], "|cffffffff" .. NanamiDPS.formatNumber(effectiveVal)) + tooltip:AddDoubleLine("|cffaaaaaa" .. L["Overheal"], "|cffcc8888+" .. NanamiDPS.formatNumber(overhealVal)) + tooltip:AddDoubleLine("|cffffffff" .. L["HPS"], + "|cffffffff" .. NanamiDPS.round(effectiveVal / math.max(entry._ctime, 1), 1)) + tooltip:AddDoubleLine("|cffffffff" .. L["Active Time"], + "|cffffffff" .. NanamiDPS.formatTime(entry._ctime)) + + NanamiDPS.Tooltip:ShowSpellDetail(playerName, entry.spells, entry.effective, entry._sum, effectiveVal, tooltip) +end + +function HealingDone:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + local oh = d.overheal > 0 and (" [+" .. NanamiDPS.formatNumber(d.overheal) .. "]") or "" + table.insert(lines, string.format("%d. %s - %s%s (%.1f%%)", + i, d.name, NanamiDPS.formatNumber(d.effectiveHeal), oh, d.percent)) + end + return lines +end + +NanamiDPS:RegisterModule("HealingDone", HealingDone) diff --git a/Modules/Interrupts.lua b/Modules/Interrupts.lua new file mode 100644 index 0000000..6386eeb --- /dev/null +++ b/Modules/Interrupts.lua @@ -0,0 +1,78 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local Interrupts = {} + +function Interrupts:GetName() + return L["Interrupts"] +end + +function Interrupts:GetBars(segment) + if not segment or not segment.data or not segment.data.interrupts then return {} end + + local bars = {} + for name, entry in pairs(segment.data.interrupts) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + if not NanamiDPS.validClasses[class] then + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + table.insert(bars, { + id = name, + name = name, + value = entry._sum or 0, + class = class, + r = r, g = g, b = b, + valueText = tostring(entry._sum or 0), + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local total = 0 + for _, bar in ipairs(bars) do total = total + bar.value end + for _, bar in ipairs(bars) do + bar.percent = total > 0 and (bar.value / total * 100) or 0 + end + + return bars +end + +function Interrupts:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.interrupts[playerName] then return end + local entry = segment.data.interrupts[playerName] + + tooltip:AddLine("|cffffd100" .. playerName .. " - " .. L["Interrupts"]) + tooltip:AddDoubleLine("|cffffffff" .. L["Interrupts"], "|cffffffff" .. (entry._sum or 0)) + + if entry.spells then + tooltip:AddLine(" ") + tooltip:AddLine("|cffffd100" .. L["Details"] .. ":") + local sorted = {} + for spell, count in pairs(entry.spells) do + table.insert(sorted, { spell = spell, count = count }) + end + table.sort(sorted, function(a, b) return a.count > b.count end) + for _, s in ipairs(sorted) do + tooltip:AddDoubleLine("|cffffffff" .. s.spell, "|cffffffff" .. s.count) + end + end +end + +function Interrupts:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %d interrupts", i, d.name, d.value)) + end + return lines +end + +NanamiDPS:RegisterModule("Interrupts", Interrupts) diff --git a/Modules/Overhealing.lua b/Modules/Overhealing.lua new file mode 100644 index 0000000..217aba7 --- /dev/null +++ b/Modules/Overhealing.lua @@ -0,0 +1,100 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local Overhealing = {} + +function Overhealing:GetName() + return L["Overhealing"] +end + +function Overhealing:GetBars(segment) + if not segment or not segment.data or not segment.data.healing then return {} end + + local bars = {} + for name, entry in pairs(segment.data.healing) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + if not NanamiDPS.validClasses[class] then + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + local effectiveVal = entry._esum or entry._sum + local overhealVal = entry._sum - effectiveVal + local overhealPct = entry._sum > 0 and (overhealVal / entry._sum * 100) or 0 + + if overhealVal > 0 then + table.insert(bars, { + id = name, + name = name, + value = overhealVal, + class = class, + r = r * 0.7 + 0.3, g = g * 0.5, b = b * 0.5, + valueText = NanamiDPS.formatNumber(overhealVal) .. " (" .. NanamiDPS.round(overhealPct, 1) .. "%)", + overhealPct = overhealPct, + totalHeal = entry._sum, + effectiveHeal = effectiveVal, + }) + end + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local total = 0 + for _, bar in ipairs(bars) do total = total + bar.value end + for _, bar in ipairs(bars) do + bar.percent = total > 0 and (bar.value / total * 100) or 0 + end + + return bars +end + +function Overhealing:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.healing[playerName] then return end + local entry = segment.data.healing[playerName] + local effectiveVal = entry._esum or entry._sum + local overhealVal = entry._sum - effectiveVal + local overhealPct = entry._sum > 0 and NanamiDPS.round(overhealVal / entry._sum * 100, 1) or 0 + + tooltip:AddLine("|cffffd100" .. playerName) + tooltip:AddDoubleLine("|cffcc8888" .. L["Overhealing"], "|cffcc8888" .. NanamiDPS.formatNumber(overhealVal)) + tooltip:AddDoubleLine("|cffcc8888" .. L["Overheal %"], "|cffcc8888" .. overhealPct .. "%") + tooltip:AddDoubleLine("|cffffffff" .. L["Healing Done"], "|cffffffff" .. NanamiDPS.formatNumber(entry._sum)) + tooltip:AddDoubleLine("|cffffffff" .. L["Effective"], "|cffffffff" .. NanamiDPS.formatNumber(effectiveVal)) + + if entry.spells and entry.effective then + tooltip:AddLine(" ") + tooltip:AddLine("|cffffd100" .. L["Details"] .. ":") + local sorted = {} + for spell, amount in pairs(entry.spells) do + table.insert(sorted, { spell = spell, amount = amount }) + end + table.sort(sorted, function(a, b) return a.amount > b.amount end) + for _, s in ipairs(sorted) do + local eff = entry.effective[s.spell] or s.amount + local oh = s.amount - eff + if oh > 0 then + local pct = s.amount > 0 and NanamiDPS.round(oh / s.amount * 100, 1) or 0 + tooltip:AddDoubleLine("|cffffffff" .. s.spell, + "|cffcc8888+" .. NanamiDPS.formatNumber(oh) .. " (" .. pct .. "%)") + end + end + end +end + +function Overhealing:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %s (%.1f%%)", + i, d.name, NanamiDPS.formatNumber(d.value), d.overhealPct)) + end + return lines +end + +NanamiDPS:RegisterModule("Overhealing", Overhealing) diff --git a/Modules/ThreatEstimate.lua b/Modules/ThreatEstimate.lua new file mode 100644 index 0000000..61b12b2 --- /dev/null +++ b/Modules/ThreatEstimate.lua @@ -0,0 +1,110 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local ThreatEstimate = {} + +function ThreatEstimate:GetName() + return L["Threat (Est.)"] +end + +local function ResolvePetOwner(name) + local stored = DataStore:GetClass(name) + if stored and not NanamiDPS.validClasses[stored] and stored ~= "__other__" then + local ownerClass = DataStore:GetClass(stored) + if ownerClass and NanamiDPS.validClasses[ownerClass] then + return stored, ownerClass + end + end + return nil, nil +end + +function ThreatEstimate:GetBars(segment) + if not segment or not segment.data or not segment.data.threat then return {} end + + local bars = {} + for name, entry in pairs(segment.data.threat) do + local class = DataStore:GetClass(name) + local r, g, b = NanamiDPS.GetClassColor(class) + local displayName = name + local ownerName, ownerClass = ResolvePetOwner(name) + + if NanamiDPS.validClasses[class] then + -- player: use class color as-is + elseif ownerName and ownerClass then + r, g, b = NanamiDPS.GetClassColor(ownerClass) + r, g, b = r * 0.7, g * 0.7, b * 0.7 + displayName = name .. " <" .. ownerName .. ">" + else + r, g, b = NanamiDPS.str2rgb(name) + r = r * 0.6 + 0.4 + g = g * 0.6 + 0.4 + b = b * 0.6 + 0.4 + end + + table.insert(bars, { + id = name, + name = displayName, + value = entry._sum or 0, + class = ownerClass or class, + r = r, g = g, b = b, + }) + end + + table.sort(bars, function(a, b) return a.value > b.value end) + + local best = bars[1] and bars[1].value or 0 + for _, bar in ipairs(bars) do + bar.percent = best > 0 and (bar.value / best * 100) or 0 + end + + return bars +end + +function ThreatEstimate:GetTooltip(playerName, segment, tooltip) + if not segment or not segment.data.threat[playerName] then return end + local entry = segment.data.threat[playerName] + local ownerName, ownerClass = ResolvePetOwner(playerName) + + tooltip:AddLine("|cffffd100" .. playerName) + + if ownerName then + tooltip:AddDoubleLine("|cffffffff" .. L["Owner"], "|cffffffff" .. ownerName) + end + + tooltip:AddDoubleLine("|cffffffff" .. L["Threat (Est.)"], "|cffffffff" .. NanamiDPS.formatNumber(entry._sum)) + + tooltip:AddLine(" ") + + if ownerName then + tooltip:AddLine("|cffaaaaaa" .. L["Threat Note"]) + else + local dmgEntry = segment.data.damage[playerName] + local healEntry = segment.data.healing[playerName] + + if dmgEntry then + tooltip:AddDoubleLine("|cffffffff" .. L["Damage Done"], + "|cffffffff" .. NanamiDPS.formatNumber(dmgEntry._sum)) + end + if healEntry then + tooltip:AddDoubleLine("|cffffffff" .. L["Healing Done"], + "|cffffffff" .. NanamiDPS.formatNumber(healEntry._sum) .. " (x0.5)") + end + + tooltip:AddLine("|cffaaaaaa" .. L["Threat Note"]) + end +end + +function ThreatEstimate:GetReportLines(segment, count) + local bars = self:GetBars(segment) + local lines = {} + count = count or 5 + for i = 1, math.min(count, table.getn(bars)) do + local d = bars[i] + table.insert(lines, string.format("%d. %s - %s (%.1f%%)", + i, d.name, NanamiDPS.formatNumber(d.value), d.percent)) + end + return lines +end + +NanamiDPS:RegisterModule("ThreatEstimate", ThreatEstimate) diff --git a/Nanami-DPS.toc b/Nanami-DPS.toc new file mode 100644 index 0000000..c1827e6 --- /dev/null +++ b/Nanami-DPS.toc @@ -0,0 +1,34 @@ +## Interface: 11200 +## Title: |cffFF88AANanami|r DPS +## Author: Nanami +## Notes: Comprehensive combat data meter with Nanami UI integration +## Notes-zhCN: Nanami 综合战斗数据监测插件 +## Version: 1.0.0 +## Dependencies: Nanami-UI +## SavedVariablesPerCharacter: NanamiDPS_DB + +Core.lua +Locale.lua +Utils.lua +DataStore.lua +Parser.lua +ParserVanilla.lua +Modules\DamageDone.lua +Modules\DamageTaken.lua +Modules\DPS.lua +Modules\HealingDone.lua +Modules\HPS.lua +Modules\Overhealing.lua +Modules\Deaths.lua +Modules\Dispels.lua +Modules\Interrupts.lua +Modules\ThreatEstimate.lua +Modules\Activity.lua +Modules\EnemyDamageDone.lua +Modules\DamageBySpell.lua +Modules\HealingBySpell.lua +BarDisplay.lua +Tooltip.lua +Window.lua +DetailView.lua +Options.lua diff --git a/Options.lua b/Options.lua new file mode 100644 index 0000000..4611e19 --- /dev/null +++ b/Options.lua @@ -0,0 +1,679 @@ +local NanamiDPS = NanamiDPS +local L = NanamiDPS.L +local DataStore = NanamiDPS.DataStore +local Window = NanamiDPS.Window + +local Options = {} +NanamiDPS.Options = Options + +local optionsFrame = nil +local activeTabPage = nil + +----------------------------------------------------------------------- +-- UI builder helpers +----------------------------------------------------------------------- +local function ApplyThemeColors(frame, category) + local A = SFrames.ActiveTheme + if not A then return end + if category == "header" and A.headerBg then + frame:SetBackdropColor(A.headerBg[1], A.headerBg[2], A.headerBg[3], A.headerBg[4] or 0.98) + elseif category == "panel" and A.panelBg then + frame:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], A.panelBg[4] or 0.9) + elseif category == "button" and A.buttonBg then + frame:SetBackdropColor(A.buttonBg[1], A.buttonBg[2], A.buttonBg[3], A.buttonBg[4] or 0.9) + if A.buttonBorder then + frame:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + end + elseif category == "section" and A.sectionBg then + frame:SetBackdropColor(A.sectionBg[1], A.sectionBg[2], A.sectionBg[3], 0.5) + end +end + +local function CreateSectionHeader(parent, text, yOffset) + local A = SFrames.ActiveTheme + local header = CreateFrame("Frame", nil, parent) + header:SetHeight(20) + header:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, yOffset) + header:SetPoint("TOPRIGHT", parent, "TOPRIGHT", 0, yOffset) + header:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + if A and A.accent then + header:SetBackdropColor(A.accent[1], A.accent[2], A.accent[3], 0.12) + else + header:SetBackdropColor(0.3, 0.3, 0.3, 0.12) + end + + local accent = header:CreateTexture(nil, "ARTWORK") + accent:SetTexture("Interface\\Buttons\\WHITE8X8") + accent:SetWidth(3) + accent:SetHeight(14) + accent:SetPoint("LEFT", header, "LEFT", 2, 0) + if A and A.accent then + accent:SetVertexColor(A.accent[1], A.accent[2], A.accent[3], 0.9) + else + accent:SetVertexColor(0.7, 0.7, 0.7, 0.9) + end + + local label = SFrames:CreateFontString(header, 10, "LEFT") + label:SetPoint("LEFT", accent, "RIGHT", 6, 0) + if A and A.sectionTitle then + label:SetTextColor(A.sectionTitle[1], A.sectionTitle[2], A.sectionTitle[3]) + end + label:SetText(text) + + return header +end + +local function CreateStyledCheckbox(parent, label, x, y, getValue, setValue) + local A = SFrames.ActiveTheme + + local container = CreateFrame("Frame", nil, parent) + container:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + container:SetWidth(240) + container:SetHeight(20) + container:EnableMouse(true) + + local check = CreateFrame("CheckButton", nil, container) + check:SetWidth(14) + check:SetHeight(14) + check:SetPoint("LEFT", container, "LEFT", 0, 0) + + check:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + if A and A.checkBg then + check:SetBackdropColor(A.checkBg[1], A.checkBg[2], A.checkBg[3], A.checkBg[4] or 0.9) + else + check:SetBackdropColor(0.12, 0.06, 0.09, 0.9) + end + if A and A.checkBorder then + check:SetBackdropBorderColor(A.checkBorder[1], A.checkBorder[2], A.checkBorder[3], A.checkBorder[4] or 0.8) + else + check:SetBackdropBorderColor(0.45, 0.3, 0.38, 0.8) + end + + local checkMark = check:CreateTexture(nil, "OVERLAY") + checkMark:SetTexture("Interface\\Buttons\\WHITE8X8") + checkMark:SetWidth(8) + checkMark:SetHeight(8) + checkMark:SetPoint("CENTER", 0, 0) + if A and A.checkFill then + checkMark:SetVertexColor(A.checkFill[1], A.checkFill[2], A.checkFill[3], A.checkFill[4] or 1) + else + checkMark:SetVertexColor(0.7, 0.7, 0.7, 1) + end + + local checked = getValue() and true or false + check:SetChecked(checked) + checkMark:SetAlpha(checked and 1 or 0) + + local text = SFrames:CreateFontString(container, 10, "LEFT") + text:SetPoint("LEFT", check, "RIGHT", 6, 0) + text:SetText(label) + if A and A.text then + text:SetTextColor(A.text[1], A.text[2], A.text[3]) + end + + local function ToggleCheck() + local val = not check.isChecked + check.isChecked = val + check:SetChecked(val) + checkMark:SetAlpha(val and 1 or 0) + setValue(val) + Window:RefreshAll(true) + end + + check.isChecked = checked + check:SetScript("OnClick", ToggleCheck) + container:SetScript("OnMouseUp", ToggleCheck) + + container:SetScript("OnEnter", function() + if A and A.checkHoverBorder then + check:SetBackdropBorderColor(A.checkHoverBorder[1], A.checkHoverBorder[2], A.checkHoverBorder[3], A.checkHoverBorder[4] or 0.9) + else + check:SetBackdropBorderColor(0.7, 0.7, 0.7, 0.9) + end + end) + container:SetScript("OnLeave", function() + if A and A.checkBorder then + check:SetBackdropBorderColor(A.checkBorder[1], A.checkBorder[2], A.checkBorder[3], A.checkBorder[4] or 0.8) + else + check:SetBackdropBorderColor(0.45, 0.3, 0.38, 0.8) + end + end) + + return container +end + +local function CreateStyledSlider(parent, label, x, y, minVal, maxVal, step, getValue, setValue, formatFn) + local A = SFrames.ActiveTheme + + local container = CreateFrame("Frame", nil, parent) + container:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + container:SetWidth(260) + container:SetHeight(36) + + local titleText = SFrames:CreateFontString(container, 9, "LEFT") + titleText:SetPoint("TOPLEFT", container, "TOPLEFT", 0, 0) + titleText:SetText(label) + if A and A.text then + titleText:SetTextColor(A.text[1], A.text[2], A.text[3]) + end + + local slider = CreateFrame("Slider", nil, container) + slider:SetPoint("TOPLEFT", container, "TOPLEFT", 0, -14) + slider:SetWidth(200) + slider:SetHeight(12) + slider:SetOrientation("HORIZONTAL") + slider:SetMinMaxValues(minVal, maxVal) + slider:SetValueStep(step) + slider:SetValue(getValue()) + slider:EnableMouseWheel(1) + + slider:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + if A and A.sliderTrack then + slider:SetBackdropColor(A.sliderTrack[1], A.sliderTrack[2], A.sliderTrack[3], A.sliderTrack[4] or 0.9) + else + slider:SetBackdropColor(0.12, 0.06, 0.09, 0.9) + end + if A and A.buttonBorder then + slider:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + else + slider:SetBackdropBorderColor(0.35, 0.25, 0.3, 0.8) + end + + slider:SetThumbTexture("Interface\\Buttons\\WHITE8X8") + local thumb = slider:GetThumbTexture() + thumb:SetWidth(8) + thumb:SetHeight(12) + if A and A.sliderThumb then + thumb:SetVertexColor(A.sliderThumb[1], A.sliderThumb[2], A.sliderThumb[3], A.sliderThumb[4] or 0.95) + else + thumb:SetVertexColor(0.7, 0.7, 0.7, 0.95) + end + + local valText = SFrames:CreateFontString(container, 10, "LEFT") + valText:SetPoint("LEFT", slider, "RIGHT", 10, 0) + valText:SetWidth(40) + + local function UpdateDisplay(val) + if formatFn then + valText:SetText(formatFn(val)) + else + valText:SetText(tostring(math.floor(val + 0.5))) + end + end + UpdateDisplay(getValue()) + if A and A.accent then + valText:SetTextColor(A.accent[1], A.accent[2], A.accent[3], 1) + end + + slider:SetScript("OnValueChanged", function() + local val = this:GetValue() + UpdateDisplay(val) + setValue(val) + end) + + slider:SetScript("OnMouseUp", function() + Window:RefreshAll(true) + end) + + slider:SetScript("OnMouseWheel", function() + local val = slider:GetValue() + if arg1 > 0 then + slider:SetValue(math.min(val + step, maxVal)) + else + slider:SetValue(math.max(val - step, minVal)) + end + end) + + return container +end + +local function CreateActionButton(parent, text, x, y, width, onClick) + local A = SFrames.ActiveTheme + + local btn = CreateFrame("Button", nil, parent) + btn:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + btn:SetWidth(width or 120) + btn:SetHeight(22) + + btn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + if A and A.buttonBg then + btn:SetBackdropColor(A.buttonBg[1], A.buttonBg[2], A.buttonBg[3], A.buttonBg[4] or 0.9) + else + btn:SetBackdropColor(0.2, 0.1, 0.15, 0.9) + end + if A and A.buttonBorder then + btn:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + else + btn:SetBackdropBorderColor(0.5, 0.35, 0.42, 0.8) + end + + local label = SFrames:CreateFontString(btn, 10, "CENTER") + label:SetAllPoints() + label:SetText(text) + + btn:SetScript("OnClick", onClick) + btn:SetScript("OnEnter", function() + if A and A.buttonHoverBg then + this:SetBackdropColor(A.buttonHoverBg[1], A.buttonHoverBg[2], A.buttonHoverBg[3], A.buttonHoverBg[4] or 0.3) + else + this:SetBackdropColor(0.3, 0.3, 0.3, 0.2) + end + if A and A.accent then + this:SetBackdropBorderColor(A.accent[1], A.accent[2], A.accent[3], 0.9) + else + this:SetBackdropBorderColor(0.7, 0.7, 0.7, 0.9) + end + end) + btn:SetScript("OnLeave", function() + if A and A.buttonBg then + this:SetBackdropColor(A.buttonBg[1], A.buttonBg[2], A.buttonBg[3], A.buttonBg[4] or 0.9) + else + this:SetBackdropColor(0.2, 0.1, 0.15, 0.9) + end + if A and A.buttonBorder then + this:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + else + this:SetBackdropBorderColor(0.5, 0.35, 0.42, 0.8) + end + end) + + return btn +end + +----------------------------------------------------------------------- +-- Tab system +----------------------------------------------------------------------- +local function CreateTab(parent, tabIndex, text, tabBar, contentParent, totalTabs) + local A = SFrames.ActiveTheme + local tabWidth = math.floor((parent:GetWidth() - 2) / totalTabs) + + local tab = CreateFrame("Button", nil, tabBar) + tab:SetWidth(tabWidth) + tab:SetHeight(20) + + if tabIndex == 1 then + tab:SetPoint("TOPLEFT", tabBar, "TOPLEFT", 1, 0) + else + tab:SetPoint("LEFT", tabBar.tabs[tabIndex - 1], "RIGHT", 0, 0) + end + + tab:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + if A and A.tabBg then + tab:SetBackdropColor(A.tabBg[1], A.tabBg[2], A.tabBg[3], A.tabBg[4] or 0.6) + else + tab:SetBackdropColor(0.12, 0.06, 0.09, 0.6) + end + + local label = SFrames:CreateFontString(tab, 9, "CENTER") + label:SetAllPoints() + label:SetText(text) + if A and A.tabText then + label:SetTextColor(A.tabText[1], A.tabText[2], A.tabText[3]) + end + tab.label = label + + tab.tabIndex = tabIndex + tab.page = CreateFrame("Frame", nil, contentParent) + tab.page:SetAllPoints(contentParent) + tab.page:Hide() + + tab:SetScript("OnClick", function() + Options:SwitchTab(parent, tabIndex) + end) + + tab:SetScript("OnEnter", function() + if activeTabPage ~= this.page then + if A and A.buttonHoverBg then + this:SetBackdropColor(A.buttonHoverBg[1], A.buttonHoverBg[2], A.buttonHoverBg[3], 0.3) + else + this:SetBackdropColor(0.3, 0.3, 0.3, 0.15) + end + end + end) + tab:SetScript("OnLeave", function() + if activeTabPage ~= this.page then + if A and A.tabBg then + this:SetBackdropColor(A.tabBg[1], A.tabBg[2], A.tabBg[3], A.tabBg[4] or 0.6) + else + this:SetBackdropColor(0.12, 0.06, 0.09, 0.6) + end + end + end) + + return tab +end + +function Options:SwitchTab(frame, tabIndex) + local A = SFrames.ActiveTheme + local tabBar = frame.tabBar + if not tabBar or not tabBar.tabs then return end + + for _, tab in ipairs(tabBar.tabs) do + if A and A.tabBg then + tab:SetBackdropColor(A.tabBg[1], A.tabBg[2], A.tabBg[3], A.tabBg[4] or 0.6) + else + tab:SetBackdropColor(0.12, 0.06, 0.09, 0.6) + end + if A and A.tabText then + tab.label:SetTextColor(A.tabText[1], A.tabText[2], A.tabText[3]) + else + tab.label:SetTextColor(0.7, 0.7, 0.7) + end + tab.page:Hide() + end + + local active = tabBar.tabs[tabIndex] + if active then + if A and A.tabActiveBg then + active:SetBackdropColor(A.tabActiveBg[1], A.tabActiveBg[2], A.tabActiveBg[3], A.tabActiveBg[4] or 0.3) + else + active:SetBackdropColor(0.3, 0.3, 0.3, 0.2) + end + if A and A.tabActiveText then + active.label:SetTextColor(A.tabActiveText[1], A.tabActiveText[2], A.tabActiveText[3]) + else + active.label:SetTextColor(1, 0.85, 0.9) + end + active.page:Show() + activeTabPage = active.page + end +end + +----------------------------------------------------------------------- +-- Build the options panel +----------------------------------------------------------------------- +function Options:CreatePanel() + if optionsFrame then return optionsFrame end + + local A = SFrames.ActiveTheme + + optionsFrame = CreateFrame("Frame", "NanamiDPSOptions", UIParent) + optionsFrame:SetWidth(340) + optionsFrame:SetHeight(460) + optionsFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 0) + optionsFrame:SetMovable(true) + optionsFrame:EnableMouse(true) + optionsFrame:SetClampedToScreen(true) + optionsFrame:SetFrameStrata("DIALOG") + + SFrames:CreateRoundBackdrop(optionsFrame) + + -- Title bar + local titleBar = CreateFrame("Frame", nil, optionsFrame) + titleBar:SetHeight(24) + titleBar:SetPoint("TOPLEFT", optionsFrame, "TOPLEFT", 4, -4) + titleBar:SetPoint("TOPRIGHT", optionsFrame, "TOPRIGHT", -4, -4) + titleBar:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + ApplyThemeColors(titleBar, "header") + if not (A and A.headerBg) then + titleBar:SetBackdropColor(0.06, 0.06, 0.08, 0.98) + end + titleBar:EnableMouse(true) + titleBar:RegisterForDrag("LeftButton") + titleBar:SetScript("OnDragStart", function() optionsFrame:StartMoving() end) + titleBar:SetScript("OnDragStop", function() optionsFrame:StopMovingOrSizing() end) + + local titleText = SFrames:CreateFontString(titleBar, 11, "CENTER") + titleText:SetPoint("LEFT", titleBar, "LEFT", 8, 0) + titleText:SetPoint("RIGHT", titleBar, "RIGHT", -28, 0) + local accentHex = (A and A.accentHex) or "ffFF88AA" + titleText:SetText("|c" .. accentHex .. "Nanami|r DPS - " .. L["Settings"]) + + local verText = SFrames:CreateFontString(titleBar, 8, "RIGHT") + verText:SetPoint("RIGHT", titleBar, "RIGHT", -24, -4) + verText:SetText("|cff888888v" .. NanamiDPS.version) + + local closeBtn = CreateFrame("Button", nil, titleBar) + closeBtn:SetWidth(18) + closeBtn:SetHeight(18) + closeBtn:SetPoint("RIGHT", titleBar, "RIGHT", -3, 0) + local closeIcon = SFrames:CreateIcon(closeBtn, "close", 12) + closeIcon:SetPoint("CENTER", 0, 0) + closeBtn:SetScript("OnClick", function() optionsFrame:Hide() end) + closeBtn:SetScript("OnEnter", function() + closeIcon:SetVertexColor(1, 0.3, 0.3) + end) + closeBtn:SetScript("OnLeave", function() + closeIcon:SetVertexColor(1, 1, 1) + end) + + -- Tab bar + local tabBar = CreateFrame("Frame", nil, optionsFrame) + tabBar:SetHeight(20) + tabBar:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 0, 0) + tabBar:SetPoint("TOPRIGHT", titleBar, "BOTTOMRIGHT", 0, 0) + tabBar:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + if A and A.sectionBg then + tabBar:SetBackdropColor(A.sectionBg[1], A.sectionBg[2], A.sectionBg[3], A.sectionBg[4] or 0.95) + else + tabBar:SetBackdropColor(0.05, 0.03, 0.04, 0.95) + end + tabBar.tabs = {} + optionsFrame.tabBar = tabBar + + local contentArea = CreateFrame("Frame", nil, optionsFrame) + contentArea:SetPoint("TOPLEFT", tabBar, "BOTTOMLEFT", 12, -8) + contentArea:SetPoint("BOTTOMRIGHT", optionsFrame, "BOTTOMRIGHT", -12, 14) + + local tabLabels = { L["Display Settings"], L["Data Settings"], L["Window Settings"] } + for i, name in ipairs(tabLabels) do + local tab = CreateTab(optionsFrame, i, name, tabBar, contentArea, table.getn(tabLabels)) + tabBar.tabs[i] = tab + end + + local cfg = NanamiDPS.config or {} + + --------------------------------------------------------------- + -- Tab 1: Display Settings + --------------------------------------------------------------- + local page1 = tabBar.tabs[1].page + local y = 0 + + CreateSectionHeader(page1, L["Bar Height"], y) + y = y - 24 + CreateStyledSlider(page1, "", 8, y, 10, 30, 1, + function() return cfg.barHeight or 16 end, + function(v) cfg.barHeight = math.floor(v + 0.5) end) + y = y - 34 + + CreateSectionHeader(page1, L["Bar Spacing"], y) + y = y - 24 + CreateStyledSlider(page1, "", 8, y, 0, 5, 1, + function() return cfg.barSpacing or 1 end, + function(v) cfg.barSpacing = math.floor(v + 0.5) end) + y = y - 34 + + CreateSectionHeader(page1, L["Font Size"], y) + y = y - 24 + CreateStyledSlider(page1, "", 8, y, 7, 16, 1, + function() return cfg.fontSize or 10 end, + function(v) cfg.fontSize = math.floor(v + 0.5) end) + y = y - 34 + + CreateSectionHeader(page1, L["Backdrop Alpha"], y) + y = y - 24 + CreateStyledSlider(page1, "", 8, y, 0, 100, 5, + function() return (cfg.backdropAlpha or 0.92) * 100 end, + function(v) cfg.backdropAlpha = v / 100 end, + function(v) return math.floor(v + 0.5) .. "%" end) + y = y - 40 + + CreateStyledCheckbox(page1, L["Show Class Icons"], 8, y, + function() return cfg.showClassIcons end, + function(v) cfg.showClassIcons = v end) + + --------------------------------------------------------------- + -- Tab 2: Data Settings + --------------------------------------------------------------- + local page2 = tabBar.tabs[2].page + y = 0 + + CreateSectionHeader(page2, L["Data Settings"], y) + y = y - 28 + + CreateStyledCheckbox(page2, L["Track All Units"], 8, y, + function() return cfg.trackAllUnits end, + function(v) cfg.trackAllUnits = v end) + y = y - 26 + + CreateStyledCheckbox(page2, L["Merge Pets"], 8, y, + function() return cfg.mergePets end, + function(v) cfg.mergePets = v end) + y = y - 36 + + CreateSectionHeader(page2, L["Max Segments"], y) + y = y - 24 + CreateStyledSlider(page2, "", 8, y, 3, 30, 1, + function() return cfg.maxSegments or 10 end, + function(v) cfg.maxSegments = math.floor(v + 0.5) end) + y = y - 50 + + CreateSectionHeader(page2, L["Reset"], y) + y = y - 28 + + CreateActionButton(page2, "|cffff4444" .. L["Reset All Data"], 8, y, 150, function() + local dialog = StaticPopupDialogs["NANAMI_DPS_CONFIRM"] + dialog.text = L["Reset Data?"] + dialog.OnAccept = function() DataStore:ResetAll() end + StaticPopup_Show("NANAMI_DPS_CONFIRM") + end) + + --------------------------------------------------------------- + -- Tab 3: Window Settings + --------------------------------------------------------------- + local page3 = tabBar.tabs[3].page + y = 0 + + CreateSectionHeader(page3, L["Window Settings"], y) + y = y - 28 + + CreateStyledCheckbox(page3, L["Lock Windows"], 8, y, + function() return cfg.locked end, + function(v) cfg.locked = v end) + y = y - 36 + + CreateSectionHeader(page3, L["Create New Window"], y) + y = y - 28 + + CreateActionButton(page3, L["Create New Window"], 8, y, 150, function() + Window:CreateNewWindow() + end) + + -- Activate first tab + self:SwitchTab(optionsFrame, 1) + + optionsFrame:Hide() + return optionsFrame +end + +function Options:Toggle() + local panel = self:CreatePanel() + if panel:IsShown() then + panel:Hide() + else + panel:Show() + end +end + +----------------------------------------------------------------------- +-- Slash commands +----------------------------------------------------------------------- +SLASH_NANAMIDPS1 = "/nanami" +SLASH_NANAMIDPS2 = "/ndps" + +SlashCmdList["NANAMIDPS"] = function(msg) + msg = string.lower(msg or "") + msg = NanamiDPS.trim(msg) + + if msg == "" or msg == "toggle" then + Window:ToggleVisibility() + elseif msg == "reset" then + DataStore:ResetAll() + local chatHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + DEFAULT_CHAT_FRAME:AddMessage("|c" .. chatHex .. "Nanami DPS|r: " .. L["Data Reset"]) + elseif msg == "config" or msg == "options" or msg == "settings" then + Options:Toggle() + elseif msg == "lock" then + local cfg = NanamiDPS.config or {} + cfg.locked = not cfg.locked + local chatHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + DEFAULT_CHAT_FRAME:AddMessage("|c" .. chatHex .. "Nanami DPS|r: " .. (cfg.locked and L["Windows Locked"] or L["Windows Unlocked"])) + elseif msg == "report" then + local win = NanamiDPS.windows and NanamiDPS.windows[1] + if win then + local mod = NanamiDPS.modules[win.activeModuleName] + local seg = DataStore:GetSegment(win.segmentIndex or 1) + if mod and mod.GetReportLines then + local lines = mod:GetReportLines(seg, 5) + local modName = mod:GetName() + local segName = (win.segmentIndex == 0) and L["Total"] or L["Current"] + local chatHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + DEFAULT_CHAT_FRAME:AddMessage("|c" .. chatHex .. "Nanami DPS|r - " .. segName .. " " .. modName .. ":") + for _, line in ipairs(lines) do + DEFAULT_CHAT_FRAME:AddMessage(" " .. line) + end + end + end + elseif string.find(msg, "^report ") then + local _, _, channel, countStr = string.find(msg, "^report%s+(%a+)%s*(%d*)") + local count = tonumber(countStr) or 5 + local win = NanamiDPS.windows and NanamiDPS.windows[1] + if win and channel then + local mod = NanamiDPS.modules[win.activeModuleName] + local seg = DataStore:GetSegment(win.segmentIndex or 1) + if mod and mod.GetReportLines then + local lines = mod:GetReportLines(seg, count) + local modName = mod:GetName() + local segName = (win.segmentIndex == 0) and L["Total"] or L["Current"] + channel = string.upper(channel) + SendChatMessage("Nanami DPS - " .. segName .. " " .. modName .. ":", channel) + for _, line in ipairs(lines) do + SendChatMessage(line, channel) + end + end + end + elseif msg == "new" or msg == "newwindow" then + Window:CreateNewWindow() + else + local chatHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + DEFAULT_CHAT_FRAME:AddMessage("|c" .. chatHex .. "Nanami DPS|r " .. L["Commands"] .. ":") + DEFAULT_CHAT_FRAME:AddMessage(" /ndps toggle - " .. L["Show/Hide"]) + DEFAULT_CHAT_FRAME:AddMessage(" /ndps reset - " .. L["Reset All Data"]) + DEFAULT_CHAT_FRAME:AddMessage(" /ndps config - " .. L["Open Settings"]) + DEFAULT_CHAT_FRAME:AddMessage(" /ndps lock - " .. L["Lock/Unlock"]) + DEFAULT_CHAT_FRAME:AddMessage(" /ndps report - " .. L["Report to chat"]) + DEFAULT_CHAT_FRAME:AddMessage(" /ndps report [channel] [count]") + DEFAULT_CHAT_FRAME:AddMessage(" /ndps new - " .. L["Create New Window"]) + end +end + +NanamiDPS:RegisterCallback("INIT", "Options", function() + Options:CreatePanel() +end) diff --git a/Parser.lua b/Parser.lua new file mode 100644 index 0000000..dd9649b --- /dev/null +++ b/Parser.lua @@ -0,0 +1,452 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore + +local Parser = CreateFrame("Frame") +NanamiDPS.Parser = Parser + +local validUnits = { ["player"] = true } +for i = 1, 4 do validUnits["party" .. i] = true end +for i = 1, 40 do validUnits["raid" .. i] = true end + +local validPets = { ["pet"] = true } +for i = 1, 4 do validPets["partypet" .. i] = true end +for i = 1, 40 do validPets["raidpet" .. i] = true end + +Parser.validUnits = validUnits +Parser.validPets = validPets + +----------------------------------------------------------------------- +-- Spell threat coefficients (vanilla / classic values, max rank) +-- Reference: classic-warrior wiki, warcrafttavern.com +-- bonus = fixed threat added ON TOP of damage +-- mult = multiplier applied to the DAMAGE portion (default 1.0) +----------------------------------------------------------------------- +local spellThreatData = { + ["Heroic Strike"] = { bonus = 173 }, + ["Shield Slam"] = { bonus = 254 }, + ["Revenge"] = { bonus = 270, mult = 2.25 }, + ["Shield Bash"] = { bonus = 156, mult = 1.5 }, + ["Cleave"] = { bonus = 100 }, + ["Execute"] = { mult = 1.25 }, + ["Hamstring"] = { bonus = 135, mult = 1.25 }, + ["Thunder Clap"] = { mult = 2.5 }, + ["Overpower"] = { mult = 0.75 }, + ["Disarm"] = { bonus = 99 }, + ["Sunder Armor"] = { bonus = 261 }, + ["Maul"] = { mult = 1.75 }, + ["Swipe"] = { mult = 1.75 }, + ["Faerie Fire (Feral)"] = { bonus = 108 }, + ["Mind Blast"] = { mult = 2.0 }, + ["Holy Nova"] = { mult = 0 }, + ["Earth Shock"] = { mult = 2.0 }, + ["Searing Pain"] = { mult = 2.0 }, + ["Distracting Shot"] = { bonus = 600 }, + ["Scorpid Poison"] = { bonus = 5 }, + ["Intimidation"] = { bonus = 580 }, + ["Life Tap"] = { mult = 0 }, + ["Counterspell"] = { bonus = 300 }, + ["Mocking Blow"] = { bonus = 250 }, +} + +do + local cnAliases = { + ["\232\139\177\229\139\135\230\137\147\229\135\187"] = "Heroic Strike", + ["\231\155\190\231\137\140\231\140\155\229\135\187"] = "Shield Slam", + ["\229\164\141\228\187\135"] = "Revenge", + ["\231\155\190\229\135\187"] = "Shield Bash", + ["\233\161\186\229\138\136\230\150\169"] = "Cleave", + ["\230\150\169\230\157\128"] = "Execute", + ["\230\150\173\231\173\139"] = "Hamstring", + ["\233\155\183\233\156\134\228\184\128\229\135\187"] = "Thunder Clap", + ["\229\142\139\229\136\182"] = "Overpower", + ["\231\188\180\230\162\176"] = "Disarm", + ["\231\160\180\231\148\178\230\148\187\229\135\187"] = "Sunder Armor", + ["\230\167\152\229\135\187"] = "Maul", + ["\230\140\165\229\135\187"] = "Swipe", + ["\231\178\190\231\129\181\228\185\139\231\129\171(\233\135\142\233\135\145)"] = "Faerie Fire (Feral)", + ["\229\191\131\231\129\181\233\156\135\231\136\134"] = "Mind Blast", + ["\231\165\158\229\156\163\230\150\176\230\152\159"] = "Holy Nova", + ["\229\156\176\233\156\135\230\156\175"] = "Earth Shock", + ["\231\129\188\231\131\173\228\185\139\231\151\155"] = "Searing Pain", + ["\230\137\176\228\185\177\229\176\132\229\135\187"] = "Distracting Shot", + ["\232\157\157\230\175\146"] = "Scorpid Poison", + ["\232\131\129\232\191\171"] = "Intimidation", + ["\231\148\159\229\145\189\229\136\134\230\181\129"] = "Life Tap", + ["\229\143\141\229\136\182\233\173\148\230\179\149"] = "Counterspell", + ["\229\152\178\229\188\132\230\137\147\229\135\187"] = "Mocking Blow", + } + for cn, en in pairs(cnAliases) do + if spellThreatData[en] then + spellThreatData[cn] = spellThreatData[en] + end + end +end + +local classThreatMod = { + ROGUE = 0.8, +} + +----------------------------------------------------------------------- +-- Stance / form / buff threat modifier detection +-- Scans UnitBuff textures to detect known threat-altering states. +-- Cached per-unit for 2 seconds to avoid excessive buff scanning. +----------------------------------------------------------------------- +local stanceScanPatterns = { + { pat = "DefensiveStance", mod = 1.3 }, + { pat = "OffensiveStance", mod = 0.8 }, + { pat = "Racial_Avatar", mod = 0.8 }, + { pat = "BerserkStance", mod = 0.8 }, + { pat = "BearForm", mod = 1.3 }, + { pat = "CatForm", mod = 0.8 }, +} + +local buffScanPatterns = { + { pat = "SealOfSalvation", mod = 0.7 }, +} + +local function ScanUnitThreatMod(unit) + if not unit or not UnitExists(unit) then return 1.0 end + local stanceMod = 1.0 + local buffMod = 1.0 + for i = 1, 32 do + local texture = UnitBuff(unit, i) + if not texture then break end + for _, s in ipairs(stanceScanPatterns) do + if string.find(texture, s.pat) then + stanceMod = s.mod + end + end + for _, b in ipairs(buffScanPatterns) do + if string.find(texture, b.pat) then + buffMod = buffMod * b.mod + end + end + end + return stanceMod * buffMod +end + +local stanceModCache = {} +local STANCE_CACHE_TTL = 2 + +function Parser:GetUnitThreatMod(name) + local now = GetTime() + local cached = stanceModCache[name] + if cached and (now - cached.time) < STANCE_CACHE_TTL then + return cached.mod + end + local unit = self:UnitByName(name) + local mod = ScanUnitThreatMod(unit) + stanceModCache[name] = { mod = mod, time = now } + return mod +end + +function Parser:CalculateSpellThreat(source, spell, damage) + if not damage or damage <= 0 then return 0 end + + local threat = damage + local coeff = spell and spellThreatData[spell] + + if coeff then + threat = damage * (coeff.mult or 1.0) + (coeff.bonus or 0) + end + + local class = DataStore:GetClass(source) + if class and classThreatMod[class] then + threat = threat * classThreatMod[class] + end + + threat = threat * self:GetUnitThreatMod(source) + + return math.max(threat, 0) +end + +function Parser:CalculateHealThreat(source, amount) + if not amount or amount <= 0 then return 0 end + + local threat = amount * 0.5 + + local class = DataStore:GetClass(source) + if class and classThreatMod[class] then + threat = threat * classThreatMod[class] + end + + threat = threat * self:GetUnitThreatMod(source) + + return threat +end + +local unit_cache = {} +function Parser:UnitByName(name) + if unit_cache[name] and UnitName(unit_cache[name]) == name then + return unit_cache[name] + end + for unit in pairs(validUnits) do + if UnitName(unit) == name then + unit_cache[name] = unit + return unit + end + end + for unit in pairs(validPets) do + if UnitName(unit) == name then + unit_cache[name] = unit + return unit + end + end + return nil +end + +function Parser:ScanName(name) + if not name then return nil end + + for unit in pairs(validUnits) do + if UnitExists(unit) and UnitName(unit) == name then + if UnitIsPlayer(unit) then + local _, class = UnitClass(unit) + DataStore:SetClass(name, class) + return "PLAYER" + end + end + end + + -- SuperWoW pet format: "PetName (OwnerName)" + local match, _, owner = string.find(name, "%((.*)%)", 1) + if match and owner then + if Parser:ScanName(owner) == "PLAYER" then + DataStore:SetClass(name, owner) + return "PET" + end + end + + for unit in pairs(validPets) do + if UnitExists(unit) and UnitName(unit) == name then + if strsub(unit, 0, 3) == "pet" then + DataStore:SetClass(name, UnitName("player")) + elseif strsub(unit, 0, 8) == "partypet" then + DataStore:SetClass(name, UnitName("party" .. strsub(unit, 9))) + elseif strsub(unit, 0, 7) == "raidpet" then + DataStore:SetClass(name, UnitName("raid" .. strsub(unit, 8))) + end + return "PET" + end + end + + if NanamiDPS.config and NanamiDPS.config.trackAllUnits then + DataStore:SetClass(name, DataStore:GetClass(name) or "__other__") + return "OTHER" + end + + return nil +end + +function Parser:ResolveSource(source) + source = NanamiDPS.trim(source) + + local sourceType = self:ScanName(source) + if not sourceType then return nil, nil end + + if NanamiDPS.config and NanamiDPS.config.mergePets and sourceType == "PET" then + local ownerClass = DataStore:GetClass(source) + if ownerClass and ownerClass ~= "__other__" and NanamiDPS.validClasses[DataStore:GetClass(ownerClass)] then + return ownerClass, "Pet: " .. source + elseif ownerClass and ownerClass ~= "__other__" then + return ownerClass, "Pet: " .. source + end + end + + return source, nil +end + +function Parser:ProcessDamage(source, spell, target, amount, school) + if type(source) ~= "string" or not tonumber(amount) then return end + source = NanamiDPS.trim(source) + amount = tonumber(amount) + + if source == target then return end + + local resolvedSource, petPrefix = self:ResolveSource(source) + if not resolvedSource then return end + + local finalSpell = petPrefix and (petPrefix .. ": " .. (spell or "")) or spell + + if not DataStore.inCombat then + DataStore:StartCombat() + end + + DataStore:AddDamage(resolvedSource, finalSpell, target, amount, school) + + local targetType = self:ScanName(target) + if targetType == "PLAYER" then + DataStore:AddDamageTaken(target, finalSpell, resolvedSource, amount, school) + end + + DataStore:AddThreat(source, self:CalculateSpellThreat(source, spell, amount)) + DataStore:UpdateActivity(resolvedSource, GetTime()) + + if targetType == "PLAYER" then + self:FeedDeathLog(target, { + time = GetTime(), + source = resolvedSource, + spell = finalSpell, + amount = -amount, + type = "damage", + hp = self:GetUnitHP(target), + }) + end + + self:ThrottledRefresh() +end + +function Parser:ProcessHealing(source, spell, target, amount, school) + if type(source) ~= "string" or not tonumber(amount) then return end + source = NanamiDPS.trim(source) + amount = tonumber(amount) + + local resolvedSource, petPrefix = self:ResolveSource(source) + if not resolvedSource then return end + + local finalSpell = petPrefix and (petPrefix .. ": " .. (spell or "")) or spell + + local effective = amount + local unitstr = self:UnitByName(target) + if unitstr then + effective = math.min(UnitHealthMax(unitstr) - UnitHealth(unitstr), amount) + end + + if not DataStore.inCombat then + DataStore:StartCombat() + end + + DataStore:AddHealing(resolvedSource, finalSpell, target, amount, effective) + + DataStore:AddThreat(source, self:CalculateHealThreat(source, amount)) + DataStore:UpdateActivity(resolvedSource, GetTime()) + + local targetType = self:ScanName(target) + if targetType == "PLAYER" then + self:FeedDeathLog(target, { + time = GetTime(), + source = resolvedSource, + spell = finalSpell, + amount = effective, + type = "heal", + hp = self:GetUnitHP(target), + }) + end + + self:ThrottledRefresh() +end + +function Parser:ProcessDeath(name) + if not name then return end + local nameType = self:ScanName(name) + if nameType ~= "PLAYER" then return end + + local log = self:FlushDeathLog(name) + DataStore:AddDeath(name, { + time = GetTime(), + timeStr = date("%H:%M:%S"), + events = log, + }) + + self:ThrottledRefresh() +end + +function Parser:ProcessDispel(source, spell, target, aura) + if type(source) ~= "string" then return end + source = NanamiDPS.trim(source) + + local resolvedSource = self:ResolveSource(source) + if not resolvedSource then return end + + DataStore:AddDispel(resolvedSource, spell or NanamiDPS.L["Unknown"], target, aura) + DataStore:UpdateActivity(resolvedSource, GetTime()) + + self:ThrottledRefresh() +end + +function Parser:ProcessInterrupt(source, spell, target, interrupted) + if type(source) ~= "string" then return end + source = NanamiDPS.trim(source) + + local resolvedSource = self:ResolveSource(source) + if not resolvedSource then return end + + DataStore:AddInterrupt(resolvedSource, spell or NanamiDPS.L["Unknown"], target, interrupted) + DataStore:UpdateActivity(resolvedSource, GetTime()) + + self:ThrottledRefresh() +end + +function Parser:GetUnitHP(name) + local unit = self:UnitByName(name) + if unit then + return UnitHealth(unit), UnitHealthMax(unit) + end + return 0, 0 +end + +-- Throttle refresh callbacks to avoid excessive UI updates +Parser.lastRefreshTime = 0 +local REFRESH_INTERVAL = 0.25 + +function Parser:ThrottledRefresh() + local now = GetTime() + if now - self.lastRefreshTime >= REFRESH_INTERVAL then + self.lastRefreshTime = now + NanamiDPS:FireCallback("refresh") + end +end + +-- Death log buffer: last 15 seconds of events per player +Parser.deathLogBuffer = {} +local DEATH_LOG_WINDOW = 15 + +function Parser:FeedDeathLog(name, entry) + if not self.deathLogBuffer[name] then + self.deathLogBuffer[name] = {} + end + table.insert(self.deathLogBuffer[name], entry) + + -- Trim old entries + local now = GetTime() + local buf = self.deathLogBuffer[name] + while table.getn(buf) > 0 and (now - buf[1].time) > DEATH_LOG_WINDOW do + table.remove(buf, 1) + end +end + +function Parser:FlushDeathLog(name) + local log = self.deathLogBuffer[name] or {} + self.deathLogBuffer[name] = {} + return log +end + +-- Combat state detection +local combatFrame = CreateFrame("Frame", "NanamiDPSCombatState", UIParent) +combatFrame:RegisterEvent("PLAYER_REGEN_DISABLED") +combatFrame:RegisterEvent("PLAYER_REGEN_ENABLED") + +local pendingCombatEnd = false +local combatEndTimer = 0 + +combatFrame:SetScript("OnEvent", function() + if event == "PLAYER_REGEN_DISABLED" then + pendingCombatEnd = false + if not DataStore.inCombat then + DataStore:StartCombat() + end + elseif event == "PLAYER_REGEN_ENABLED" then + pendingCombatEnd = true + combatEndTimer = GetTime() + 2 + end +end) + +combatFrame:SetScript("OnUpdate", function() + if (this.tick or 1) > GetTime() then return end + this.tick = GetTime() + 1 + + if pendingCombatEnd and GetTime() >= combatEndTimer then + pendingCombatEnd = false + DataStore:StopCombat() + end +end) diff --git a/ParserVanilla.lua b/ParserVanilla.lua new file mode 100644 index 0000000..785fa96 --- /dev/null +++ b/ParserVanilla.lua @@ -0,0 +1,465 @@ +local NanamiDPS = NanamiDPS +local Parser = NanamiDPS.Parser + +-- Pattern utility functions (from ShaguDPS) +local sanitize_cache = {} +local function sanitize(pattern) + if not sanitize_cache[pattern] then + local ret = pattern + ret = gsub(ret, "([%+%-%*%(%)%?%[%]%^])", "%%%1") + ret = gsub(ret, "%d%$", "") + ret = gsub(ret, "(%%%a)", "%(%1+%)") + ret = gsub(ret, "%%s%+", ".+") + ret = gsub(ret, "%(.%+%)%(%%d%+%)", "%(.-%)%(%%d%+%)") + sanitize_cache[pattern] = ret + end + return sanitize_cache[pattern] +end + +local capture_cache = {} +local function captures(pat) + local r = capture_cache + if not r[pat] then + r[pat] = { nil, nil, nil, nil, nil } + for a, b, c, d, e in string.gfind(gsub(pat, "%((.+)%)", "%1"), gsub(pat, "%d%$", "%%(.-)$")) do + r[pat][1] = tonumber(a) + r[pat][2] = tonumber(b) + r[pat][3] = tonumber(c) + r[pat][4] = tonumber(d) + r[pat][5] = tonumber(e) + end + end + return r[pat][1], r[pat][2], r[pat][3], r[pat][4], r[pat][5] +end + +local ra, rb, rc, rd, re, a, b, c, d, e, match, num, va, vb, vc, vd, ve +local function cfind(str, pat) + a, b, c, d, e = captures(pat) + match, num, va, vb, vc, vd, ve = string.find(str, sanitize(pat)) + ra = e == 1 and ve or d == 1 and vd or c == 1 and vc or b == 1 and vb or va + rb = e == 2 and ve or d == 2 and vd or c == 2 and vc or a == 2 and va or vb + rc = e == 3 and ve or d == 3 and vd or a == 3 and va or b == 3 and vb or vc + rd = e == 4 and ve or a == 4 and va or c == 4 and vc or b == 4 and vb or vd + re = a == 5 and va or d == 5 and vd or c == 5 and vc or b == 5 and vb or ve + return match, num, ra, rb, rc, rd, re +end + +----------------------------------------------------------------------- +-- Combat log string groups +----------------------------------------------------------------------- +local combatlog_strings = { + -- Damage: melee + ["Hit Damage (self vs. other)"] = { + COMBATHITSELFOTHER, COMBATHITSCHOOLSELFOTHER, + COMBATHITCRITSELFOTHER, COMBATHITCRITSCHOOLSELFOTHER, + }, + ["Hit Damage (other vs. self)"] = { + COMBATHITOTHERSELF, COMBATHITCRITOTHERSELF, + COMBATHITSCHOOLOTHERSELF, COMBATHITCRITSCHOOLOTHERSELF, + }, + ["Hit Damage (other vs. other)"] = { + COMBATHITOTHEROTHER, COMBATHITCRITOTHEROTHER, + COMBATHITSCHOOLOTHEROTHER, COMBATHITCRITSCHOOLOTHEROTHER, + }, + -- Damage: spell + ["Spell Damage (self vs. self/other)"] = { + SPELLLOGSCHOOLSELFSELF, SPELLLOGCRITSCHOOLSELFSELF, + SPELLLOGSELFSELF, SPELLLOGCRITSELFSELF, + SPELLLOGSCHOOLSELFOTHER, SPELLLOGCRITSCHOOLSELFOTHER, + SPELLLOGSELFOTHER, SPELLLOGCRITSELFOTHER, + }, + ["Spell Damage (other vs. self)"] = { + SPELLLOGSCHOOLOTHERSELF, SPELLLOGCRITSCHOOLOTHERSELF, + SPELLLOGOTHERSELF, SPELLLOGCRITOTHERSELF, + }, + ["Spell Damage (other vs. other)"] = { + SPELLLOGSCHOOLOTHEROTHER, SPELLLOGCRITSCHOOLOTHEROTHER, + SPELLLOGOTHEROTHER, SPELLLOGCRITOTHEROTHER, + }, + -- Damage: shields + ["Shield Damage (self vs. other)"] = { + DAMAGESHIELDSELFOTHER, + }, + ["Shield Damage (other vs. self/other)"] = { + DAMAGESHIELDOTHERSELF, DAMAGESHIELDOTHEROTHER, + }, + -- Damage: periodic + ["Periodic Damage (self/other vs. other)"] = { + PERIODICAURADAMAGESELFOTHER, PERIODICAURADAMAGEOTHEROTHER, + }, + ["Periodic Damage (self/other vs. self)"] = { + PERIODICAURADAMAGESELFSELF, PERIODICAURADAMAGEOTHERSELF, + }, + -- Healing + ["Heal (self vs. self/other)"] = { + HEALEDCRITSELFSELF, HEALEDSELFSELF, + HEALEDCRITSELFOTHER, HEALEDSELFOTHER, + }, + ["Heal (other vs. self/other)"] = { + HEALEDCRITOTHERSELF, HEALEDOTHERSELF, + HEALEDCRITOTHEROTHER, HEALEDOTHEROTHER, + }, + ["Periodic Heal (self/other vs. other)"] = { + PERIODICAURAHEALSELFOTHER, PERIODICAURAHEALOTHEROTHER, + }, + ["Periodic Heal (other vs. self/other)"] = { + PERIODICAURAHEALSELFSELF, PERIODICAURAHEALOTHERSELF, + }, +} + +----------------------------------------------------------------------- +-- Event -> pattern mapping +----------------------------------------------------------------------- +local combatlog_events = { + -- Damage: melee + ["CHAT_MSG_COMBAT_SELF_HITS"] = combatlog_strings["Hit Damage (self vs. other)"], + ["CHAT_MSG_COMBAT_CREATURE_VS_SELF_HITS"] = combatlog_strings["Hit Damage (other vs. self)"], + ["CHAT_MSG_COMBAT_PARTY_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_COMBAT_HOSTILEPLAYER_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_COMBAT_CREATURE_VS_PARTY_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + ["CHAT_MSG_COMBAT_PET_HITS"] = combatlog_strings["Hit Damage (other vs. other)"], + -- Damage: spell + ["CHAT_MSG_SPELL_SELF_DAMAGE"] = combatlog_strings["Spell Damage (self vs. self/other)"], + ["CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE"] = combatlog_strings["Spell Damage (other vs. self)"], + ["CHAT_MSG_SPELL_PARTY_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_FRIENDLYPLAYER_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + ["CHAT_MSG_SPELL_PET_DAMAGE"] = combatlog_strings["Spell Damage (other vs. other)"], + -- Damage: shields + ["CHAT_MSG_SPELL_DAMAGESHIELDS_ON_SELF"] = combatlog_strings["Shield Damage (self vs. other)"], + ["CHAT_MSG_SPELL_DAMAGESHIELDS_ON_OTHERS"] = combatlog_strings["Shield Damage (other vs. self/other)"], + -- Damage: periodic + ["CHAT_MSG_SPELL_PERIODIC_PARTY_DAMAGE"] = combatlog_strings["Periodic Damage (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE"] = combatlog_strings["Periodic Damage (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_DAMAGE"] = combatlog_strings["Periodic Damage (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE"] = combatlog_strings["Periodic Damage (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE"] = combatlog_strings["Periodic Damage (self/other vs. self)"], + -- Healing + ["CHAT_MSG_SPELL_SELF_BUFF"] = combatlog_strings["Heal (self vs. self/other)"], + ["CHAT_MSG_SPELL_FRIENDLYPLAYER_BUFF"] = combatlog_strings["Heal (other vs. self/other)"], + ["CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF"] = combatlog_strings["Heal (other vs. self/other)"], + ["CHAT_MSG_SPELL_PARTY_BUFF"] = combatlog_strings["Heal (other vs. self/other)"], + ["CHAT_MSG_SPELL_PERIODIC_PARTY_BUFFS"] = combatlog_strings["Periodic Heal (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_FRIENDLYPLAYER_BUFFS"] = combatlog_strings["Periodic Heal (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_BUFFS"] = combatlog_strings["Periodic Heal (self/other vs. other)"], + ["CHAT_MSG_SPELL_PERIODIC_SELF_BUFFS"] = combatlog_strings["Periodic Heal (other vs. self/other)"], +} + +----------------------------------------------------------------------- +-- Pattern -> parser function mapping +----------------------------------------------------------------------- +local combatlog_parser = { + -- Spell damage: self vs self + [SPELLLOGSCHOOLSELFSELF] = function(d, attack, value, school) + return d.source, attack, d.target, value, school, "damage" + end, + [SPELLLOGCRITSCHOOLSELFSELF] = function(d, attack, value, school) + return d.source, attack, d.target, value, school, "damage" + end, + [SPELLLOGSELFSELF] = function(d, attack, value) + return d.source, attack, d.target, value, d.school, "damage" + end, + [SPELLLOGCRITSELFSELF] = function(d, attack, value) + return d.source, attack, d.target, value, d.school, "damage" + end, + [PERIODICAURADAMAGESELFSELF] = function(d, value, school, attack) + return d.source, attack, d.target, value, school, "damage" + end, + -- Spell damage: self vs other + [SPELLLOGSCHOOLSELFOTHER] = function(d, attack, target, value, school) + return d.source, attack, target, value, school, "damage" + end, + [SPELLLOGCRITSCHOOLSELFOTHER] = function(d, attack, target, value, school) + return d.source, attack, target, value, school, "damage" + end, + [SPELLLOGSELFOTHER] = function(d, attack, target, value) + return d.source, attack, target, value, d.school, "damage" + end, + [SPELLLOGCRITSELFOTHER] = function(d, attack, target, value) + return d.source, attack, target, value, d.school, "damage" + end, + [PERIODICAURADAMAGESELFOTHER] = function(d, target, value, school, attack) + return d.source, attack, target, value, school, "damage" + end, + -- Melee damage: self vs other + [COMBATHITSELFOTHER] = function(d, target, value) + return d.source, d.attack, target, value, d.school, "damage" + end, + [COMBATHITCRITSELFOTHER] = function(d, target, value) + return d.source, d.attack, target, value, d.school, "damage" + end, + [COMBATHITSCHOOLSELFOTHER] = function(d, target, value, school) + return d.source, d.attack, target, value, school, "damage" + end, + [COMBATHITCRITSCHOOLSELFOTHER] = function(d, target, value, school) + return d.source, d.attack, target, value, school, "damage" + end, + -- Shield damage: self vs other + [DAMAGESHIELDSELFOTHER] = function(d, value, school, target) + return d.source, "Reflect (" .. school .. ")", target, value, school, "damage" + end, + -- Spell damage: other vs self + [SPELLLOGSCHOOLOTHERSELF] = function(d, source, attack, value, school) + return source, attack, d.target, value, school, "damage" + end, + [SPELLLOGCRITSCHOOLOTHERSELF] = function(d, source, attack, value, school) + return source, attack, d.target, value, school, "damage" + end, + [SPELLLOGOTHERSELF] = function(d, source, attack, value) + return source, attack, d.target, value, d.school, "damage" + end, + [SPELLLOGCRITOTHERSELF] = function(d, source, attack, value) + return source, attack, d.target, value, d.school, "damage" + end, + [PERIODICAURADAMAGEOTHERSELF] = function(d, value, school, source, attack) + return source, attack, d.target, value, school, "damage" + end, + -- Melee damage: other vs self + [COMBATHITOTHERSELF] = function(d, source, value) + return source, d.attack, d.target, value, d.school, "damage" + end, + [COMBATHITCRITOTHERSELF] = function(d, source, value) + return source, d.attack, d.target, value, d.school, "damage" + end, + [COMBATHITSCHOOLOTHERSELF] = function(d, source, value, school) + return source, d.attack, d.target, value, school, "damage" + end, + [COMBATHITCRITSCHOOLOTHERSELF] = function(d, source, value, school) + return source, d.attack, d.target, value, school, "damage" + end, + -- Spell damage: other vs other + [SPELLLOGSCHOOLOTHEROTHER] = function(d, source, attack, target, value, school) + return source, attack, target, value, school, "damage" + end, + [SPELLLOGCRITSCHOOLOTHEROTHER] = function(d, source, attack, target, value, school) + return source, attack, target, value, school, "damage" + end, + [SPELLLOGOTHEROTHER] = function(d, source, attack, target, value) + return source, attack, target, value, d.school, "damage" + end, + [SPELLLOGCRITOTHEROTHER] = function(d, source, attack, target, value, school) + return source, attack, target, value, school, "damage" + end, + [PERIODICAURADAMAGEOTHEROTHER] = function(d, target, value, school, source, attack) + return source, attack, target, value, school, "damage" + end, + -- Melee damage: other vs other + [COMBATHITOTHEROTHER] = function(d, source, target, value) + return source, d.attack, target, value, d.school, "damage" + end, + [COMBATHITCRITOTHEROTHER] = function(d, source, target, value) + return source, d.attack, target, value, d.school, "damage" + end, + [COMBATHITSCHOOLOTHEROTHER] = function(d, source, target, value, school) + return source, d.attack, target, value, school, "damage" + end, + [COMBATHITCRITSCHOOLOTHEROTHER] = function(d, source, target, value, school) + return source, d.attack, target, value, school, "damage" + end, + -- Shield damage: other + [DAMAGESHIELDOTHERSELF] = function(d, source, value, school) + return source, "Reflect (" .. school .. ")", d.target, value, school, "damage" + end, + [DAMAGESHIELDOTHEROTHER] = function(d, source, value, school, target) + return source, "Reflect (" .. school .. ")", target, value, school, "damage" + end, + -- Healing: other vs self + [HEALEDCRITOTHERSELF] = function(d, source, spell, value) + return source, spell, d.target, value, d.school, "heal" + end, + [HEALEDOTHERSELF] = function(d, source, spell, value) + return source, spell, d.target, value, d.school, "heal" + end, + [PERIODICAURAHEALOTHERSELF] = function(d, value, source, spell) + return source, spell, d.target, value, d.school, "heal" + end, + -- Healing: self vs self + [HEALEDCRITSELFSELF] = function(d, spell, value) + return d.source, spell, d.target, value, d.school, "heal" + end, + [HEALEDSELFSELF] = function(d, spell, value) + return d.source, spell, d.target, value, d.school, "heal" + end, + [PERIODICAURAHEALSELFSELF] = function(d, value, spell) + return d.source, spell, d.target, value, d.school, "heal" + end, + -- Healing: self vs other + [HEALEDCRITSELFOTHER] = function(d, spell, target, value) + return d.source, spell, target, value, d.school, "heal" + end, + [HEALEDSELFOTHER] = function(d, spell, target, value) + return d.source, spell, target, value, d.school, "heal" + end, + [PERIODICAURAHEALSELFOTHER] = function(d, target, value, spell) + return d.source, spell, target, value, d.school, "heal" + end, + -- Healing: other vs other + [HEALEDCRITOTHEROTHER] = function(d, source, spell, target, value) + return source, spell, target, value, d.school, "heal" + end, + [HEALEDOTHEROTHER] = function(d, source, spell, target, value) + return source, spell, target, value, d.school, "heal" + end, + [PERIODICAURAHEALOTHEROTHER] = function(d, target, value, source, spell) + return source, spell, target, value, d.school, "heal" + end, +} + +----------------------------------------------------------------------- +-- Register combat log events +----------------------------------------------------------------------- +for evt in pairs(combatlog_events) do + Parser:RegisterEvent(evt) +end + +-- Preload patterns +for pattern in pairs(combatlog_parser) do + sanitize(pattern) +end + +----------------------------------------------------------------------- +-- Death events +----------------------------------------------------------------------- +Parser:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLY_DEATH") +Parser:RegisterEvent("CHAT_MSG_COMBAT_HOSTILE_DEATH") + +----------------------------------------------------------------------- +-- Interrupt patterns +----------------------------------------------------------------------- +local interrupt_strings = {} +if SPELLINTERRUPTSELFOTHER then + table.insert(interrupt_strings, SPELLINTERRUPTSELFOTHER) +end +if SPELLINTERRUPTOTHERSELF then + table.insert(interrupt_strings, SPELLINTERRUPTOTHERSELF) +end +if SPELLINTERRUPTOTHEROTHER then + table.insert(interrupt_strings, SPELLINTERRUPTOTHEROTHER) +end + +local interrupt_parser = {} +if SPELLINTERRUPTSELFOTHER then + interrupt_parser[SPELLINTERRUPTSELFOTHER] = function(d, spell, target, interrupted) + return d.source, spell, target, interrupted + end +end +if SPELLINTERRUPTOTHERSELF then + interrupt_parser[SPELLINTERRUPTOTHERSELF] = function(d, source, spell, interrupted) + return source, spell, d.target, interrupted + end +end +if SPELLINTERRUPTOTHEROTHER then + interrupt_parser[SPELLINTERRUPTOTHEROTHER] = function(d, source, spell, target, interrupted) + return source, spell, target, interrupted + end +end + +for pat in pairs(interrupt_parser) do + sanitize(pat) +end + +----------------------------------------------------------------------- +-- Dispel patterns +----------------------------------------------------------------------- +local dispel_strings = {} +local dispel_parser = {} + +if AURADISPELSELF2 then + table.insert(dispel_strings, AURADISPELSELF2) + dispel_parser[AURADISPELSELF2] = function(d, spell, aura) + return d.source, spell, d.target, aura + end +end +if AURADISPELOTHER2 then + table.insert(dispel_strings, AURADISPELOTHER2) + dispel_parser[AURADISPELOTHER2] = function(d, source, spell, target, aura) + return source, spell, target, aura + end +end + +for pat in pairs(dispel_parser) do + sanitize(pat) +end + +----------------------------------------------------------------------- +-- Main event handler +----------------------------------------------------------------------- +local defaults = {} +local absorb = ABSORB_TRAILER and sanitize(ABSORB_TRAILER) or nil +local resist = RESIST_TRAILER and sanitize(RESIST_TRAILER) or nil +local empty, physical, autohit = "", "physical", "Auto Hit" +local player = UnitName("player") + +Parser:SetScript("OnEvent", function() + if not arg1 then return end + + -- Death events + if event == "CHAT_MSG_COMBAT_FRIENDLY_DEATH" or event == "CHAT_MSG_COMBAT_HOSTILE_DEATH" then + if UNITDIESOTHER then + local deathPat = sanitize(UNITDIESOTHER) + local _, _, deadName = string.find(arg1, deathPat) + if deadName then + Parser:ProcessDeath(deadName) + end + end + if UNITDIESSELF then + local selfPat = sanitize(UNITDIESSELF) + if string.find(arg1, selfPat) then + Parser:ProcessDeath(player) + end + end + return + end + + -- Strip absorb/resist suffixes + if absorb then arg1 = string.gsub(arg1, absorb, empty) end + if resist then arg1 = string.gsub(arg1, resist, empty) end + + defaults.source = player + defaults.target = player + defaults.school = physical + defaults.attack = autohit + defaults.spell = UNKNOWN or "Unknown" + defaults.value = 0 + + -- Check interrupt patterns first (they share spell damage events) + for _, pat in ipairs(interrupt_strings) do + local result, num, a1, a2, a3, a4, a5 = cfind(arg1, pat) + if result then + local src, spell, tgt, interrupted = interrupt_parser[pat](defaults, a1, a2, a3, a4, a5) + Parser:ProcessInterrupt(src, spell, tgt, interrupted) + return + end + end + + -- Check dispel patterns + for _, pat in ipairs(dispel_strings) do + local result, num, a1, a2, a3, a4, a5 = cfind(arg1, pat) + if result then + local src, spell, tgt, aura = dispel_parser[pat](defaults, a1, a2, a3, a4, a5) + Parser:ProcessDispel(src, spell, tgt, aura) + return + end + end + + -- Standard combat log patterns + local patterns = combatlog_events[event] + if not patterns then return end + + for _, pattern in pairs(patterns) do + local result, num, a1, a2, a3, a4, a5 = cfind(arg1, pattern) + if result then + local source, spell, target, value, school, dtype = combatlog_parser[pattern](defaults, a1, a2, a3, a4, a5) + if dtype == "damage" then + Parser:ProcessDamage(source, spell, target, value, school) + elseif dtype == "heal" then + Parser:ProcessHealing(source, spell, target, value, school) + end + return + end + end +end) diff --git a/Tooltip.lua b/Tooltip.lua new file mode 100644 index 0000000..de5a232 --- /dev/null +++ b/Tooltip.lua @@ -0,0 +1,69 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local L = NanamiDPS.L + +local Tooltip = {} +NanamiDPS.Tooltip = Tooltip + +function Tooltip:ShowBar(bar, window) + if not bar or not bar.barData then return end + local data = bar.barData + local moduleName = window.activeModuleName + local mod = NanamiDPS.modules[moduleName] + + GameTooltip:SetOwner(bar, "ANCHOR_RIGHT") + + if mod and mod.GetTooltip then + local seg = DataStore:GetSegment(window.segmentIndex or 1) + mod:GetTooltip(data.id or data.name, seg, GameTooltip) + else + GameTooltip:AddLine(data.name or L["Unknown"]) + if data.value then + GameTooltip:AddDoubleLine("|cffffffff" .. L["Total Amount"], "|cffffffff" .. NanamiDPS.formatNumber(data.value)) + end + if data.perSecond then + GameTooltip:AddDoubleLine("|cffffffff" .. L["Per Second"], "|cffffffff" .. NanamiDPS.round(data.perSecond, 1)) + end + end + + GameTooltip:AddLine(" ") + GameTooltip:AddLine("|cff888888" .. L["Click to view details"]) + GameTooltip:Show() +end + +function Tooltip:Hide() + GameTooltip:Hide() +end + +function Tooltip:ShowSpellDetail(playerName, spells, effectiveSpells, totalSum, effectiveSum, tooltip) + tooltip = tooltip or GameTooltip + + if not spells then return end + + tooltip:AddLine(" ") + tooltip:AddLine("|cffffd100" .. L["Details"] .. ":") + + local sorted = {} + for spell, amount in pairs(spells) do + table.insert(sorted, { spell = spell, amount = amount }) + end + table.sort(sorted, function(a, b) return a.amount > b.amount end) + + for _, entry in ipairs(sorted) do + local pct = totalSum > 0 and NanamiDPS.round(entry.amount / totalSum * 100, 1) or 0 + local rightStr + + if effectiveSpells and effectiveSpells[entry.spell] then + local eff = effectiveSpells[entry.spell] + local overheal = entry.amount - eff + rightStr = string.format("|cffcc8888+%s |cffffffff%s (%.1f%%)", + NanamiDPS.formatNumber(overheal), + NanamiDPS.formatNumber(eff), pct) + else + rightStr = string.format("|cffffffff%s (%.1f%%)", + NanamiDPS.formatNumber(entry.amount), pct) + end + + tooltip:AddDoubleLine("|cffffffff" .. entry.spell, rightStr) + end +end diff --git a/Utils.lua b/Utils.lua new file mode 100644 index 0000000..54a584f --- /dev/null +++ b/Utils.lua @@ -0,0 +1,87 @@ +local NanamiDPS = NanamiDPS + +function NanamiDPS.round(input, places) + if not places then places = 0 end + if type(input) == "number" and type(places) == "number" then + local pow = 1 + for i = 1, places do pow = pow * 10 end + return floor(input * pow + 0.5) / pow + end + return 0 +end + +function NanamiDPS.trim(str) + if not str then return "" end + return gsub(str, "^%s*(.-)%s*$", "%1") +end + +function NanamiDPS.spairs(t, order) + local keys = {} + for k in pairs(t) do + table.insert(keys, k) + end + if order then + table.sort(keys, function(a, b) return order(t, a, b) end) + else + table.sort(keys) + end + local i = 0 + return function() + i = i + 1 + if keys[i] then + return keys[i], t[keys[i]] + end + end +end + +local rgbcache = {} +function NanamiDPS.str2rgb(text) + if not text then return 1, 1, 1 end + if rgbcache[text] then return unpack(rgbcache[text]) end + local counter = 1 + local l = string.len(text) + for i = 1, l, 3 do + counter = mod(counter * 8161, 4294967279) + + (string.byte(text, i) * 16776193) + + ((string.byte(text, i + 1) or (l - i + 256)) * 8372226) + + ((string.byte(text, i + 2) or (l - i + 256)) * 3932164) + end + local hash = mod(mod(counter, 4294967291), 16777216) + local r = (hash - (mod(hash, 65536))) / 65536 + local g = ((hash - r * 65536) - (mod((hash - r * 65536), 256))) / 256 + local b = hash - r * 65536 - g * 256 + rgbcache[text] = { r / 255, g / 255, b / 255 } + return unpack(rgbcache[text]) +end + +function NanamiDPS.formatNumber(num) + if not num then return "0" end + if num >= 1000000 then + return NanamiDPS.round(num / 1000000, 1) .. "M" + elseif num >= 1000 then + return NanamiDPS.round(num / 1000, 1) .. "K" + end + return tostring(math.floor(num)) +end + +function NanamiDPS.formatTime(seconds) + if not seconds or seconds <= 0 then return "0:00" end + local m = math.floor(seconds / 60) + local s = math.floor(seconds - m * 60) + if s < 10 then + return m .. ":0" .. s + end + return m .. ":" .. s +end + +function NanamiDPS.GetClassColor(class) + if class and RAID_CLASS_COLORS and RAID_CLASS_COLORS[class] then + return RAID_CLASS_COLORS[class].r, RAID_CLASS_COLORS[class].g, RAID_CLASS_COLORS[class].b + end + return 0.6, 0.6, 0.6 +end + +NanamiDPS.validClasses = { + WARRIOR = true, MAGE = true, ROGUE = true, DRUID = true, HUNTER = true, + SHAMAN = true, PRIEST = true, WARLOCK = true, PALADIN = true, +} diff --git a/Window.lua b/Window.lua new file mode 100644 index 0000000..9dab9a0 --- /dev/null +++ b/Window.lua @@ -0,0 +1,1349 @@ +local NanamiDPS = NanamiDPS +local DataStore = NanamiDPS.DataStore +local BarDisplay = NanamiDPS.BarDisplay +local TooltipMod = NanamiDPS.Tooltip +local L = NanamiDPS.L + +local Window = {} +NanamiDPS.Window = Window + +local MAX_WINDOWS = 5 +local activeWindows = {} + +local SNAP_THRESHOLD = 12 + +----------------------------------------------------------------------- +-- Scrollable dropdown menu +----------------------------------------------------------------------- +local MAX_DROPDOWN_VISIBLE = 8 + +local function ShowDropdown(parent, items, onClick, anchorPoint, relTo, relPoint, xOff, yOff) + if parent.dropdown and parent.dropdown:IsShown() then + parent.dropdown:Hide() + return + end + + if not parent.dropdown then + parent.dropdown = CreateFrame("Frame", nil, parent) + parent.dropdown:SetFrameStrata("TOOLTIP") + SFrames:CreateBackdrop(parent.dropdown) + parent.dropdown:EnableMouseWheel(1) + end + + local dd = parent.dropdown + if dd.buttons then + for _, btn in pairs(dd.buttons) do btn:Hide() end + end + dd.buttons = {} + dd.allItems = items + dd.onClickFn = onClick + dd.scrollOffset = 0 + + local itemH = 18 + local padding = 4 + local maxW = 80 + local totalItems = table.getn(items) + local visibleCount = math.min(totalItems, MAX_DROPDOWN_VISIBLE) + + for i = 1, visibleCount do + local btn = CreateFrame("Button", nil, dd) + btn:SetHeight(itemH) + btn.slotIndex = i + + btn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + btn:SetBackdropColor(0, 0, 0, 0) + + local fs = SFrames:CreateFontString(btn, 9, "LEFT") + fs:SetPoint("LEFT", 4, 0) + fs:SetPoint("RIGHT", -4, 0) + btn.label = fs + + local A = SFrames.ActiveTheme + btn:SetScript("OnEnter", function() + if A and A.buttonHoverBg then + this:SetBackdropColor(A.buttonHoverBg[1], A.buttonHoverBg[2], A.buttonHoverBg[3], 0.6) + else + this:SetBackdropColor(0.3, 0.3, 0.3, 0.6) + end + end) + btn:SetScript("OnLeave", function() + this:SetBackdropColor(0, 0, 0, 0) + end) + btn:SetScript("OnClick", function() + if this.itemData then + dd.onClickFn(this.itemData) + dd:Hide() + end + end) + + table.insert(dd.buttons, btn) + end + + local function RefreshDropdownButtons() + local offset = dd.scrollOffset + for idx, btn in ipairs(dd.buttons) do + local dataIdx = idx + offset + if dataIdx <= totalItems then + local item = dd.allItems[dataIdx] + btn.itemData = item + btn.label:SetText(item.text or item.name or "") + btn:ClearAllPoints() + btn:SetPoint("TOPLEFT", dd, "TOPLEFT", padding, -padding - (idx - 1) * itemH) + btn:SetPoint("TOPRIGHT", dd, "TOPRIGHT", -padding, -padding - (idx - 1) * itemH) + btn:Show() + + local strW = btn.label:GetStringWidth() + if strW and strW + 20 > maxW then maxW = strW + 20 end + else + btn:Hide() + end + end + dd:SetWidth(math.max(maxW, 90)) + end + + RefreshDropdownButtons() + + dd:SetScript("OnMouseWheel", function() + local maxOffset = math.max(0, totalItems - visibleCount) + if arg1 > 0 then + dd.scrollOffset = math.max(0, dd.scrollOffset - 1) + else + dd.scrollOffset = math.min(maxOffset, dd.scrollOffset + 1) + end + RefreshDropdownButtons() + end) + + local ddHeight = visibleCount * itemH + padding * 2 + if totalItems > MAX_DROPDOWN_VISIBLE then + ddHeight = ddHeight + 8 + end + dd:SetHeight(ddHeight) + dd:ClearAllPoints() + dd:SetPoint(anchorPoint or "TOPLEFT", relTo or parent, relPoint or "BOTTOMLEFT", xOff or 0, yOff or -2) + + -- Scroll indicator for long lists + if not dd.scrollUpIndicator then + dd.scrollUpIndicator = SFrames:CreateFontString(dd, 8, "CENTER") + dd.scrollUpIndicator:SetPoint("TOP", dd, "TOP", 0, -1) + dd.scrollUpIndicator:SetText("|cff888888▲") + end + if not dd.scrollDownIndicator then + dd.scrollDownIndicator = SFrames:CreateFontString(dd, 8, "CENTER") + dd.scrollDownIndicator:SetPoint("BOTTOM", dd, "BOTTOM", 0, 1) + dd.scrollDownIndicator:SetText("|cff888888▼") + end + + if totalItems > MAX_DROPDOWN_VISIBLE then + dd.scrollUpIndicator:Show() + dd.scrollDownIndicator:Show() + else + dd.scrollUpIndicator:Hide() + dd.scrollDownIndicator:Hide() + end + + dd:Show() +end + +----------------------------------------------------------------------- +-- Magnetic snapping +----------------------------------------------------------------------- +local function SnapWindow(frame) + local left = frame:GetLeft() + local bottom = frame:GetBottom() + local right = frame:GetRight() + local top = frame:GetTop() + local w = frame:GetWidth() + local h = frame:GetHeight() + + if not left or not bottom then return end + + local snapX, snapY = nil, nil + local bestDistX, bestDistY = SNAP_THRESHOLD + 1, SNAP_THRESHOLD + 1 + + for _, other in pairs(activeWindows) do + if other and other ~= frame and other:IsShown() then + local oL = other:GetLeft() + local oB = other:GetBottom() + local oR = other:GetRight() + local oT = other:GetTop() + if oL and oB then + -- Snap right edge to other's left edge + local d = math.abs(right - oL) + if d < bestDistX then bestDistX = d; snapX = oL - w end + -- Snap left edge to other's right edge + d = math.abs(left - oR) + if d < bestDistX then bestDistX = d; snapX = oR end + -- Snap left to left + d = math.abs(left - oL) + if d < bestDistX then bestDistX = d; snapX = oL end + -- Snap right to right + d = math.abs(right - oR) + if d < bestDistX then bestDistX = d; snapX = oR - w end + + -- Snap top to top + d = math.abs(top - oT) + if d < bestDistY then bestDistY = d; snapY = oT - h end + -- Snap bottom to bottom + d = math.abs(bottom - oB) + if d < bestDistY then bestDistY = d; snapY = oB end + -- Snap top to other's bottom + d = math.abs(top - oB) + if d < bestDistY then bestDistY = d; snapY = oB - h end + -- Snap bottom to other's top + d = math.abs(bottom - oT) + if d < bestDistY then bestDistY = d; snapY = oT end + end + end + end + + -- Screen edge snapping + local screenW = GetScreenWidth() + local screenH = GetScreenHeight() + local uiScale = UIParent:GetEffectiveScale() + + local d = math.abs(left) + if d < bestDistX then bestDistX = d; snapX = 0 end + d = math.abs(right - screenW) + if d < bestDistX then bestDistX = d; snapX = screenW - w end + + d = math.abs(bottom) + if d < bestDistY then bestDistY = d; snapY = 0 end + d = math.abs(top - screenH) + if d < bestDistY then bestDistY = d; snapY = screenH - h end + + if bestDistX <= SNAP_THRESHOLD or bestDistY <= SNAP_THRESHOLD then + local finalX = (bestDistX <= SNAP_THRESHOLD and snapX) or left + local finalY = (bestDistY <= SNAP_THRESHOLD and snapY) or bottom + frame:ClearAllPoints() + frame:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", finalX, finalY) + end +end + +----------------------------------------------------------------------- +-- Window creation +----------------------------------------------------------------------- +function Window:Create(wid) + local cfg = NanamiDPS.config or {} + local barHeight = cfg.barHeight or 16 + + local frame = CreateFrame("Frame", "NanamiDPSWindow" .. wid, UIParent) + frame:SetWidth(220) + frame:SetHeight(barHeight * 8 + 26) + frame:SetClampedToScreen(true) + frame:SetMovable(true) + frame:SetResizable(true) + frame:SetMinResize(160, 50) + frame:EnableMouse(true) + frame:EnableMouseWheel(1) + + SFrames:CreateRoundBackdrop(frame) + local A = SFrames.ActiveTheme + if A and A.panelBg then + frame:SetBackdropColor(A.panelBg[1], A.panelBg[2], A.panelBg[3], cfg.backdropAlpha or 0.92) + end + if A and A.panelBorder then + frame:SetBackdropBorderColor(A.panelBorder[1], A.panelBorder[2], A.panelBorder[3], A.panelBorder[4] or 0.9) + end + + frame:SetID(wid) + frame.scroll = 0 + frame.segmentIndex = 1 + frame.activeModuleName = "DamageDone" + frame.bars = {} + + --------------------------------------------------------------- + -- Title bar + --------------------------------------------------------------- + frame.titleBar = CreateFrame("Frame", nil, frame) + frame.titleBar:SetHeight(20) + frame.titleBar:SetPoint("TOPLEFT", frame, "TOPLEFT", 4, -4) + frame.titleBar:SetPoint("TOPRIGHT", frame, "TOPRIGHT", -4, -4) + frame.titleBar:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + if A and A.headerBg then + frame.titleBar:SetBackdropColor(A.headerBg[1], A.headerBg[2], A.headerBg[3], A.headerBg[4] or 0.98) + else + frame.titleBar:SetBackdropColor(0.06, 0.06, 0.08, 0.98) + end + frame.titleBar:EnableMouse(true) + frame.titleBar:RegisterForDrag("LeftButton") + frame.titleBar:SetScript("OnDragStart", function() + if not cfg.locked then frame:StartMoving() end + end) + frame.titleBar:SetScript("OnDragStop", function() + frame:StopMovingOrSizing() + SnapWindow(frame) + Window:SavePosition(frame) + end) + + -- Title label + frame.titleText = SFrames:CreateFontString(frame.titleBar, 10, "LEFT") + frame.titleText:SetPoint("LEFT", frame.titleBar, "LEFT", 6, 0) + local accentHex = (A and A.accentHex) or "ffFF88AA" + frame.titleText:SetText("|c" .. accentHex .. "Nanami|r DPS") + if A and A.title then + frame.titleText:SetTextColor(A.title[1], A.title[2], A.title[3]) + end + + --------------------------------------------------------------- + -- Title bar buttons (right side) + --------------------------------------------------------------- + -- Close / Hide button + local closeBtn = CreateFrame("Button", nil, frame.titleBar) + closeBtn:SetWidth(16) + closeBtn:SetHeight(16) + closeBtn:SetPoint("RIGHT", frame.titleBar, "RIGHT", -4, 0) + local closeIcon = SFrames:CreateIcon(closeBtn, "close", 12) + closeIcon:SetPoint("CENTER", 0, 0) + closeBtn:SetScript("OnClick", function() + if wid > 1 then + Window:DestroyWindow(wid) + else + frame:Hide() + end + end) + closeBtn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine(L["Hide Window"]) + GameTooltip:Show() + end) + closeBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) + + -- New window button (+) + local newWinBtn = CreateFrame("Button", nil, frame.titleBar) + newWinBtn:SetWidth(16) + newWinBtn:SetHeight(16) + newWinBtn:SetPoint("RIGHT", closeBtn, "LEFT", -1, 0) + local newWinIcon = SFrames:CreateIcon(newWinBtn, "save", 12) + newWinIcon:SetPoint("CENTER", 0, 0) + newWinBtn:SetScript("OnClick", function() + Window:CreateNewWindow() + end) + newWinBtn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine(L["Create New Window"]) + GameTooltip:Show() + if A and A.accent then + newWinIcon:SetVertexColor(A.accent[1], A.accent[2], A.accent[3]) + end + end) + newWinBtn:SetScript("OnLeave", function() + GameTooltip:Hide() + newWinIcon:SetVertexColor(1, 1, 1) + end) + + -- Settings button + local settingsBtn = CreateFrame("Button", nil, frame.titleBar) + settingsBtn:SetWidth(16) + settingsBtn:SetHeight(16) + settingsBtn:SetPoint("RIGHT", newWinBtn, "LEFT", -1, 0) + local settingsIcon = SFrames:CreateIcon(settingsBtn, "settings", 12) + settingsIcon:SetPoint("CENTER", 0, 0) + settingsBtn:SetScript("OnClick", function() + if NanamiDPS.Options then NanamiDPS.Options:Toggle() end + end) + settingsBtn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine(L["Settings"]) + GameTooltip:Show() + end) + settingsBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) + + -- Reset button + local resetBtn = CreateFrame("Button", nil, frame.titleBar) + resetBtn:SetWidth(16) + resetBtn:SetHeight(16) + resetBtn:SetPoint("RIGHT", settingsBtn, "LEFT", -1, 0) + local resetIcon = SFrames:CreateIcon(resetBtn, "close", 10) + resetIcon:SetPoint("CENTER", 0, 0) + if A and A.accent then + resetIcon:SetVertexColor(A.accent[1], A.accent[2], A.accent[3]) + else + resetIcon:SetVertexColor(1, 0.4, 0.4) + end + resetBtn:SetScript("OnClick", function() + if IsShiftKeyDown() then + DataStore:ResetAll() + else + local dialog = StaticPopupDialogs["NANAMI_DPS_CONFIRM"] + dialog.text = L["Reset Data?"] + dialog.OnAccept = function() DataStore:ResetAll() end + StaticPopup_Show("NANAMI_DPS_CONFIRM") + end + end) + resetBtn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine(L["Reset"]) + GameTooltip:AddLine("|cffffffff" .. L["Shift-click to reset"]) + GameTooltip:Show() + end) + resetBtn:SetScript("OnLeave", function() GameTooltip:Hide() end) + + -- Report button + local reportBtn = CreateFrame("Button", nil, frame.titleBar) + reportBtn:SetWidth(16) + reportBtn:SetHeight(16) + reportBtn:SetPoint("RIGHT", resetBtn, "LEFT", -1, 0) + local reportIcon = SFrames:CreateFontString(reportBtn, 9, "CENTER") + reportIcon:SetAllPoints() + reportIcon:SetText("R") + if A and A.text then + reportIcon:SetTextColor(A.text[1], A.text[2], A.text[3]) + end + reportBtn:SetScript("OnClick", function() + Window:ShowReport(frame) + end) + reportBtn:SetScript("OnEnter", function() + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:AddLine(L["Report to Chat"]) + GameTooltip:Show() + if A and A.accent then + reportIcon:SetTextColor(A.accent[1], A.accent[2], A.accent[3]) + end + end) + reportBtn:SetScript("OnLeave", function() + GameTooltip:Hide() + if A and A.text then + reportIcon:SetTextColor(A.text[1], A.text[2], A.text[3]) + else + reportIcon:SetTextColor(1, 1, 1) + end + end) + + --------------------------------------------------------------- + -- Segment / Mode selectors (below title) + --------------------------------------------------------------- + frame.selectorBar = CreateFrame("Frame", nil, frame) + frame.selectorBar:SetHeight(16) + frame.selectorBar:SetPoint("TOPLEFT", frame.titleBar, "BOTTOMLEFT", 0, 0) + frame.selectorBar:SetPoint("TOPRIGHT", frame.titleBar, "BOTTOMRIGHT", 0, 0) + frame.selectorBar:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + if A and A.sectionBg then + frame.selectorBar:SetBackdropColor(A.sectionBg[1], A.sectionBg[2], A.sectionBg[3], A.sectionBg[4] or 0.8) + else + frame.selectorBar:SetBackdropColor(0.08, 0.04, 0.06, 0.8) + end + + -- Segment button + frame.btnSegment = CreateFrame("Button", nil, frame.selectorBar) + frame.btnSegment:SetHeight(14) + frame.btnSegment:SetPoint("LEFT", frame.selectorBar, "LEFT", 4, 0) + frame.btnSegment:SetWidth(70) + frame.btnSegment:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + if A and A.buttonBg then + frame.btnSegment:SetBackdropColor(A.buttonBg[1], A.buttonBg[2], A.buttonBg[3], A.buttonBg[4] or 0.9) + frame.btnSegment:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + else + frame.btnSegment:SetBackdropColor(0.15, 0.08, 0.12, 0.9) + frame.btnSegment:SetBackdropBorderColor(0.4, 0.3, 0.35, 0.8) + end + + frame.btnSegment.text = SFrames:CreateFontString(frame.btnSegment, 9, "CENTER") + frame.btnSegment.text:SetAllPoints() + frame.btnSegment.text:SetText(L["Current"]) + + frame.btnSegment:SetScript("OnClick", function() + local items = {} + local segList = DataStore:GetSegmentList() + for _, seg in ipairs(segList) do + table.insert(items, { text = seg.name, index = seg.index }) + end + ShowDropdown(frame.btnSegment, items, function(item) + frame.segmentIndex = item.index + frame.btnSegment.text:SetText(item.text) + Window:RefreshWindow(frame) + end, "TOPLEFT", frame.btnSegment, "BOTTOMLEFT", 0, -2) + end) + + -- Mode button + frame.btnMode = CreateFrame("Button", nil, frame.selectorBar) + frame.btnMode:SetHeight(14) + frame.btnMode:SetPoint("LEFT", frame.btnSegment, "RIGHT", 4, 0) + frame.btnMode:SetPoint("RIGHT", frame.selectorBar, "RIGHT", -4, 0) + frame.btnMode:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + if A and A.buttonBg then + frame.btnMode:SetBackdropColor(A.buttonBg[1], A.buttonBg[2], A.buttonBg[3], A.buttonBg[4] or 0.9) + frame.btnMode:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + else + frame.btnMode:SetBackdropColor(0.15, 0.08, 0.12, 0.9) + frame.btnMode:SetBackdropBorderColor(0.4, 0.3, 0.35, 0.8) + end + + frame.btnMode.text = SFrames:CreateFontString(frame.btnMode, 9, "CENTER") + frame.btnMode.text:SetAllPoints() + frame.btnMode.text:SetText(L["Damage Done"]) + + frame.btnMode:SetScript("OnClick", function() + local items = {} + for _, name in ipairs(NanamiDPS.moduleOrder) do + local mod = NanamiDPS.modules[name] + if mod then + table.insert(items, { text = mod:GetName(), moduleName = name }) + end + end + ShowDropdown(frame.btnMode, items, function(item) + frame.activeModuleName = item.moduleName + frame.btnMode.text:SetText(item.text) + frame.scroll = 0 + Window:RefreshWindow(frame) + end, "TOPLEFT", frame.btnMode, "BOTTOMLEFT", 0, -2) + end) + + --------------------------------------------------------------- + -- Content area (bars go here) + --------------------------------------------------------------- + frame.content = CreateFrame("Frame", nil, frame) + frame.content:SetPoint("TOPLEFT", frame.selectorBar, "BOTTOMLEFT", 0, -1) + frame.content:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -4, 4) + + --------------------------------------------------------------- + -- Scroll wheel + --------------------------------------------------------------- + frame:SetScript("OnMouseWheel", function() + local delta = arg1 + frame.scroll = frame.scroll + (delta > 0 and -1 or 1) + frame.scroll = math.max(frame.scroll, 0) + Window:RefreshWindow(frame) + end) + + --------------------------------------------------------------- + -- Resize handle + --------------------------------------------------------------- + frame.resizeBtn = CreateFrame("Frame", nil, frame) + frame.resizeBtn:SetWidth(16) + frame.resizeBtn:SetHeight(16) + frame.resizeBtn:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -3, 3) + frame.resizeBtn:EnableMouse(true) + frame.resizeBtn:SetFrameLevel(frame:GetFrameLevel() + 10) + + frame.resizeBtn.tex = frame.resizeBtn:CreateTexture(nil, "OVERLAY") + frame.resizeBtn.tex:SetAllPoints() + frame.resizeBtn.tex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up") + if A and A.accent then + frame.resizeBtn.tex:SetVertexColor(A.accent[1], A.accent[2], A.accent[3], 0.6) + else + frame.resizeBtn.tex:SetVertexColor(0.6, 0.6, 0.6, 0.5) + end + + frame.resizeBtn:SetScript("OnEnter", function() + if not cfg.locked then + this.tex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight") + if A and A.accent then + this.tex:SetVertexColor(A.accent[1], A.accent[2], A.accent[3], 1) + else + this.tex:SetVertexColor(1, 1, 1, 0.9) + end + GameTooltip:SetOwner(this, "ANCHOR_TOPLEFT") + GameTooltip:AddLine(L["Drag to Resize"]) + GameTooltip:Show() + end + end) + frame.resizeBtn:SetScript("OnLeave", function() + this.tex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Up") + if A and A.accent then + this.tex:SetVertexColor(A.accent[1], A.accent[2], A.accent[3], 0.6) + else + this.tex:SetVertexColor(0.6, 0.6, 0.6, 0.5) + end + GameTooltip:Hide() + end) + frame.resizeBtn:SetScript("OnMouseDown", function() + if not cfg.locked then + frame.sizing = true + this.tex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Down") + frame:StartSizing("BOTTOMRIGHT") + end + end) + frame.resizeBtn:SetScript("OnMouseUp", function() + frame.sizing = nil + this.tex:SetTexture("Interface\\ChatFrame\\UI-ChatIM-SizeGrabber-Highlight") + frame:StopMovingOrSizing() + SnapWindow(frame) + Window:SavePosition(frame) + Window:RefreshWindow(frame, true) + end) + + --------------------------------------------------------------- + -- Periodic refresh + --------------------------------------------------------------- + frame:SetScript("OnUpdate", function() + if (this.tick or 0) > GetTime() then return end + this.tick = GetTime() + 0.3 + if this.needsRefresh then + this.needsRefresh = nil + Window:RefreshWindow(this) + end + if cfg.locked then + this.resizeBtn:SetAlpha(0) + this.resizeBtn:EnableMouse(false) + else + this.resizeBtn:EnableMouse(true) + if MouseIsOver(this) then + this.resizeBtn:SetAlpha(1) + else + this.resizeBtn:SetAlpha(0.4) + end + end + end) + + --------------------------------------------------------------- + -- Position loading + --------------------------------------------------------------- + frame:RegisterEvent("PLAYER_LOGIN") + frame:SetScript("OnEvent", function() + Window:LoadPosition(frame) + end) + + return frame +end + +function Window:SavePosition(frame) + if not NanamiDPS_DB then return end + NanamiDPS_DB.windowPositions = NanamiDPS_DB.windowPositions or {} + local wid = frame:GetID() + + local l = frame:GetLeft() + local b = frame:GetBottom() + local screenW = UIParent:GetRight() or UIParent:GetWidth() + local screenH = UIParent:GetTop() or UIParent:GetHeight() + if not l or not b then return end + + local fw = frame:GetWidth() or 0 + local fh = frame:GetHeight() or 0 + local cx = l + fw / 2 + local cy = b + fh / 2 + + local point, relPoint, xOfs, yOfs + if cx < screenW / 3 then + if cy > screenH * 2 / 3 then + point = "TOPLEFT"; relPoint = "TOPLEFT" + xOfs = l; yOfs = -(screenH - (b + fh)) + elseif cy < screenH / 3 then + point = "BOTTOMLEFT"; relPoint = "BOTTOMLEFT" + xOfs = l; yOfs = b + else + point = "LEFT"; relPoint = "LEFT" + xOfs = l; yOfs = cy - screenH / 2 + end + elseif cx > screenW * 2 / 3 then + local r = frame:GetRight() or 0 + if cy > screenH * 2 / 3 then + point = "TOPRIGHT"; relPoint = "TOPRIGHT" + xOfs = r - screenW; yOfs = -(screenH - (b + fh)) + elseif cy < screenH / 3 then + point = "BOTTOMRIGHT"; relPoint = "BOTTOMRIGHT" + xOfs = r - screenW; yOfs = b + else + point = "RIGHT"; relPoint = "RIGHT" + xOfs = r - screenW; yOfs = cy - screenH / 2 + end + else + if cy > screenH * 2 / 3 then + point = "TOP"; relPoint = "TOP" + xOfs = cx - screenW / 2; yOfs = -(screenH - (b + fh)) + elseif cy < screenH / 3 then + point = "BOTTOM"; relPoint = "BOTTOM" + xOfs = cx - screenW / 2; yOfs = b + else + point = "CENTER"; relPoint = "CENTER" + xOfs = cx - screenW / 2; yOfs = cy - screenH / 2 + end + end + + NanamiDPS_DB.windowPositions[wid] = { + point = point, relativePoint = relPoint, + xOfs = xOfs, yOfs = yOfs, + w = fw, h = fh, + } + + frame:ClearAllPoints() + frame:SetPoint(point, UIParent, relPoint, xOfs, yOfs) +end + +function Window:LoadPosition(frame) + if not NanamiDPS_DB or not NanamiDPS_DB.windowPositions then + frame:ClearAllPoints() + frame:SetPoint("RIGHT", UIParent, "RIGHT", -80, -50) + return + end + local wid = frame:GetID() + local pos = NanamiDPS_DB.windowPositions[wid] + if pos and pos.point and pos.relativePoint then + frame:ClearAllPoints() + frame:SetPoint(pos.point, UIParent, pos.relativePoint, pos.xOfs or 0, pos.yOfs or 0) + if pos.w then frame:SetWidth(pos.w) end + if pos.h then frame:SetHeight(pos.h) end + elseif pos and pos.left and pos.bottom then + frame:ClearAllPoints() + frame:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", pos.left, pos.bottom) + if pos.w then frame:SetWidth(pos.w) end + if pos.h then frame:SetHeight(pos.h) end + elseif pos and pos.x and pos.y then + frame:ClearAllPoints() + frame:SetPoint("CENTER", UIParent, "BOTTOMLEFT", pos.x, pos.y) + if pos.w then frame:SetWidth(pos.w) end + if pos.h then frame:SetHeight(pos.h) end + else + frame:ClearAllPoints() + frame:SetPoint("RIGHT", UIParent, "RIGHT", -80, -50) + end +end + +----------------------------------------------------------------------- +-- Refresh logic +----------------------------------------------------------------------- +function Window:RefreshWindow(frame, force) + if not frame then return end + local mod = NanamiDPS.modules[frame.activeModuleName] + if not mod then return end + + local seg = DataStore:GetSegment(frame.segmentIndex or 1) + if not seg then return end + + local barData = mod:GetBars(seg) + if not barData then barData = {} end + + local maxBars = BarDisplay:GetMaxBars(frame) + local totalEntries = table.getn(barData) + + frame.scroll = math.min(frame.scroll, math.max(0, totalEntries - maxBars)) + + local maxVal = 0 + for _, d in ipairs(barData) do + if (d.value or 0) > maxVal then maxVal = d.value end + end + + local barsNeeded = math.max(0, math.min(maxBars, totalEntries - frame.scroll)) + while table.getn(frame.bars) < barsNeeded do + local bar = BarDisplay:CreateBar(frame, table.getn(frame.bars) + 1) + table.insert(frame.bars, bar) + end + + for i = 1, math.max(table.getn(frame.bars), barsNeeded) do + local dataIdx = i + frame.scroll + local bar = frame.bars[i] + if not bar then break end + + if dataIdx <= totalEntries then + local d = barData[dataIdx] + BarDisplay:UpdateBar(bar, d, maxVal, dataIdx) + BarDisplay:SetBarCallbacks(bar, + function(b) TooltipMod:ShowBar(b, frame) end, + function(b) TooltipMod:Hide() end, + function(b, btn) + if b.barData and NanamiDPS.DetailView then + NanamiDPS.DetailView:Show(b.barData, frame.activeModuleName, frame.segmentIndex or 1) + end + end + ) + else + bar:Hide() + end + end + + BarDisplay:LayoutBars(frame) +end + +function Window:RefreshAll(force) + for _, win in pairs(activeWindows) do + if win and win:IsShown() then + self:RefreshWindow(win, force) + end + end +end + +----------------------------------------------------------------------- +-- Initialization +----------------------------------------------------------------------- +NanamiDPS:RegisterCallback("INIT", "Window", function() + local win = Window:Create(1) + activeWindows[1] = win + NanamiDPS.windows = activeWindows + + NanamiDPS:RegisterCallback("refresh", "WindowRefresh", function() + for _, w in pairs(activeWindows) do + if w then w.needsRefresh = true end + end + end) + + Window:LoadPosition(win) + + local cfg = NanamiDPS.config or {} + if cfg.visible ~= false then + win:Show() + else + win:Hide() + end + + Window:RefreshWindow(win, true) +end) + +----------------------------------------------------------------------- +-- Multi-window API +----------------------------------------------------------------------- +function Window:CreateNewWindow() + for i = 2, MAX_WINDOWS do + if not activeWindows[i] then + local win = self:Create(i) + activeWindows[i] = win + + local prev = activeWindows[i - 1] + if prev and prev:IsShown() then + win:ClearAllPoints() + win:SetPoint("TOPLEFT", prev, "TOPRIGHT", 0, 0) + win:SetWidth(prev:GetWidth()) + win:SetHeight(prev:GetHeight()) + end + + win:Show() + self:RefreshWindow(win, true) + self:SavePosition(win) + return win + end + end + local chatHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + DEFAULT_CHAT_FRAME:AddMessage("|c" .. chatHex .. "Nanami DPS|r: " .. L["Max Segments"] .. " (" .. MAX_WINDOWS .. ")") +end + +function Window:DestroyWindow(wid) + if wid == 1 then return end + if activeWindows[wid] then + activeWindows[wid]:Hide() + activeWindows[wid] = nil + end +end + +function Window:ToggleVisibility() + for _, win in pairs(activeWindows) do + if win then + if win:IsShown() then win:Hide() else win:Show() end + end + end +end + +----------------------------------------------------------------------- +-- Report to Chat Panel +----------------------------------------------------------------------- +local reportFrame = nil +local reportChannel = "SAY" +local reportCount = 5 +local reportSourceWin = nil +local reportHeaderFn = nil +local reportLinesFn = nil + +local CHANNEL_LIST = { + { id = "SAY", key = "Say" }, + { id = "YELL", key = "Yell" }, + { id = "PARTY", key = "Party" }, + { id = "RAID", key = "Raid" }, + { id = "GUILD", key = "Guild" }, + { id = "OFFICER", key = "Officer" }, + { id = "WHISPER", key = "Whisper" }, +} + +local function UpdateReportChannelButtons() + if not reportFrame or not reportFrame.channelBtns then return end + local A = SFrames.ActiveTheme + + for _, btn in ipairs(reportFrame.channelBtns) do + if btn.channelId == reportChannel then + if A and A.accent then + btn:SetBackdropColor(A.accent[1] * 0.3, A.accent[2] * 0.3, A.accent[3] * 0.3, 0.9) + btn:SetBackdropBorderColor(A.accent[1], A.accent[2], A.accent[3], 0.9) + else + btn:SetBackdropColor(0.3, 0.15, 0.2, 0.9) + btn:SetBackdropBorderColor(1, 0.53, 0.67, 0.9) + end + if A and A.accent then + btn.label:SetTextColor(A.accent[1], A.accent[2], A.accent[3]) + else + btn.label:SetTextColor(1, 0.85, 0.9) + end + else + if A and A.buttonBg then + btn:SetBackdropColor(A.buttonBg[1], A.buttonBg[2], A.buttonBg[3], A.buttonBg[4] or 0.9) + else + btn:SetBackdropColor(0.15, 0.08, 0.12, 0.9) + end + if A and A.buttonBorder then + btn:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + else + btn:SetBackdropBorderColor(0.4, 0.3, 0.35, 0.8) + end + if A and A.text then + btn.label:SetTextColor(A.text[1], A.text[2], A.text[3]) + else + btn.label:SetTextColor(0.8, 0.8, 0.8) + end + end + end + + if reportFrame.whisperRow then + if reportChannel == "WHISPER" then + reportFrame.whisperRow:Show() + else + reportFrame.whisperRow:Hide() + end + end + + -- Reposition elements below whisper row + local linesY = (reportChannel == "WHISPER") and -118 or -92 + if reportFrame.linesRow then + reportFrame.linesRow:ClearAllPoints() + reportFrame.linesRow:SetPoint("TOPLEFT", reportFrame.titleBar, "BOTTOMLEFT", 8, linesY) + reportFrame.linesRow:SetPoint("RIGHT", reportFrame, "RIGHT", -12, 0) + end + if reportFrame.sendBtn then + reportFrame.sendBtn:ClearAllPoints() + reportFrame.sendBtn:SetPoint("TOPLEFT", reportFrame.titleBar, "BOTTOMLEFT", 60, linesY - 36) + end + + local frameH = (reportChannel == "WHISPER") and 230 or 200 + reportFrame:SetHeight(frameH) +end + +local function GetSegmentName(win) + if not win then return "" end + local idx = win.segmentIndex or 1 + if idx == 0 then return L["Total"] end + if idx == 1 then return L["Current"] end + local segList = DataStore:GetSegmentList() + for _, s in ipairs(segList) do + if s.index == idx then return s.name end + end + return "Segment " .. idx +end + +local function DoSendReport() + if not reportLinesFn then return end + + local lines = reportLinesFn(reportCount) + if not lines or table.getn(lines) == 0 then + local chatHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + DEFAULT_CHAT_FRAME:AddMessage("|c" .. chatHex .. "Nanami DPS|r: " .. L["No Report Data"]) + return + end + + local channel = reportChannel + local target = nil + if channel == "WHISPER" then + target = reportFrame.whisperEdit:GetText() + if not target or NanamiDPS.trim(target) == "" then + reportFrame.whisperEdit:SetFocus() + return + end + target = NanamiDPS.trim(target) + end + + local header = reportHeaderFn and reportHeaderFn() or "Nanami DPS:" + SendChatMessage(header, channel, nil, target) + for _, line in ipairs(lines) do + SendChatMessage(line, channel, nil, target) + end + + local chatHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + DEFAULT_CHAT_FRAME:AddMessage("|c" .. chatHex .. "Nanami DPS|r: " .. L["Report Sent"]) + reportFrame:Hide() +end + +local function EnsureReportFrame() + if reportFrame then return reportFrame end + + local A = SFrames.ActiveTheme + + reportFrame = CreateFrame("Frame", "NanamiDPSReportFrame", UIParent) + reportFrame:SetWidth(280) + reportFrame:SetHeight(200) + reportFrame:SetPoint("CENTER", UIParent, "CENTER", 0, 50) + reportFrame:SetMovable(true) + reportFrame:EnableMouse(true) + reportFrame:SetClampedToScreen(true) + reportFrame:SetFrameStrata("DIALOG") + + SFrames:CreateRoundBackdrop(reportFrame) + + -- Title bar + local titleBar = CreateFrame("Frame", nil, reportFrame) + titleBar:SetHeight(22) + titleBar:SetPoint("TOPLEFT", reportFrame, "TOPLEFT", 4, -4) + titleBar:SetPoint("TOPRIGHT", reportFrame, "TOPRIGHT", -4, -4) + titleBar:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 0, + }) + if A and A.headerBg then + titleBar:SetBackdropColor(A.headerBg[1], A.headerBg[2], A.headerBg[3], A.headerBg[4] or 0.98) + else + titleBar:SetBackdropColor(0.06, 0.06, 0.08, 0.98) + end + titleBar:EnableMouse(true) + titleBar:RegisterForDrag("LeftButton") + titleBar:SetScript("OnDragStart", function() reportFrame:StartMoving() end) + titleBar:SetScript("OnDragStop", function() reportFrame:StopMovingOrSizing() end) + reportFrame.titleBar = titleBar + + local accentHex = (A and A.accentHex) or "ffFF88AA" + local titleText = SFrames:CreateFontString(titleBar, 11, "LEFT") + titleText:SetPoint("LEFT", titleBar, "LEFT", 8, 0) + titleText:SetPoint("RIGHT", titleBar, "RIGHT", -28, 0) + titleText:SetText("|c" .. accentHex .. "Nanami|r DPS - " .. L["Report to Chat"]) + + local closeBtn = CreateFrame("Button", nil, titleBar) + closeBtn:SetWidth(18) + closeBtn:SetHeight(18) + closeBtn:SetPoint("RIGHT", titleBar, "RIGHT", -3, 0) + local closeIcon = SFrames:CreateIcon(closeBtn, "close", 12) + closeIcon:SetPoint("CENTER", 0, 0) + closeBtn:SetScript("OnClick", function() reportFrame:Hide() end) + closeBtn:SetScript("OnEnter", function() closeIcon:SetVertexColor(1, 0.3, 0.3) end) + closeBtn:SetScript("OnLeave", function() closeIcon:SetVertexColor(1, 1, 1) end) + + -- Info line: module + segment + reportFrame.infoText = SFrames:CreateFontString(reportFrame, 9, "LEFT") + reportFrame.infoText:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 8, -8) + reportFrame.infoText:SetPoint("RIGHT", reportFrame, "RIGHT", -12, 0) + if A and A.text then + reportFrame.infoText:SetTextColor(A.text[1], A.text[2], A.text[3]) + end + + -- Channel section header + local channelLabel = SFrames:CreateFontString(reportFrame, 9, "LEFT") + channelLabel:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 8, -28) + if A and A.sectionTitle then + channelLabel:SetTextColor(A.sectionTitle[1], A.sectionTitle[2], A.sectionTitle[3]) + end + channelLabel:SetText(L["Channel"]) + + -- Channel buttons (4 per row, 2 rows) + reportFrame.channelBtns = {} + local btnW = 60 + local btnH = 18 + local btnGap = 3 + local col, row = 0, 0 + + for _, ch in ipairs(CHANNEL_LIST) do + local channelId = ch.id + local channelKey = ch.key + + local btn = CreateFrame("Button", nil, reportFrame) + btn:SetWidth(btnW) + btn:SetHeight(btnH) + btn:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", + 8 + col * (btnW + btnGap), + -44 - row * (btnH + btnGap)) + + btn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + + btn.label = SFrames:CreateFontString(btn, 9, "CENTER") + btn.label:SetAllPoints() + btn.label:SetText(L[channelKey]) + btn.channelId = channelId + + btn:SetScript("OnClick", function() + reportChannel = channelId + UpdateReportChannelButtons() + end) + + btn:SetScript("OnEnter", function() + if channelId ~= reportChannel then + if A and A.buttonHoverBg then + this:SetBackdropColor(A.buttonHoverBg[1], A.buttonHoverBg[2], A.buttonHoverBg[3], 0.3) + else + this:SetBackdropColor(0.3, 0.3, 0.3, 0.2) + end + end + end) + btn:SetScript("OnLeave", function() + if channelId ~= reportChannel then + if A and A.buttonBg then + this:SetBackdropColor(A.buttonBg[1], A.buttonBg[2], A.buttonBg[3], A.buttonBg[4] or 0.9) + else + this:SetBackdropColor(0.15, 0.08, 0.12, 0.9) + end + end + end) + + table.insert(reportFrame.channelBtns, btn) + col = col + 1 + if col >= 4 then + col = 0 + row = row + 1 + end + end + + -- Whisper target row (hidden by default) + reportFrame.whisperRow = CreateFrame("Frame", nil, reportFrame) + reportFrame.whisperRow:SetHeight(22) + reportFrame.whisperRow:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 8, -88) + reportFrame.whisperRow:SetPoint("RIGHT", reportFrame, "RIGHT", -12, 0) + + local whisperLabel = SFrames:CreateFontString(reportFrame.whisperRow, 9, "LEFT") + whisperLabel:SetPoint("LEFT", reportFrame.whisperRow, "LEFT", 0, 0) + whisperLabel:SetWidth(40) + if A and A.sectionTitle then + whisperLabel:SetTextColor(A.sectionTitle[1], A.sectionTitle[2], A.sectionTitle[3]) + end + whisperLabel:SetText(L["Whisper"] .. ":") + + reportFrame.whisperEdit = CreateFrame("EditBox", "NanamiDPSWhisperTarget", reportFrame.whisperRow) + reportFrame.whisperEdit:SetPoint("LEFT", whisperLabel, "RIGHT", 4, 0) + reportFrame.whisperEdit:SetPoint("RIGHT", reportFrame.whisperRow, "RIGHT", 0, 0) + reportFrame.whisperEdit:SetHeight(18) + reportFrame.whisperEdit:SetFontObject(ChatFontNormal) + reportFrame.whisperEdit:SetAutoFocus(false) + reportFrame.whisperEdit:SetMaxLetters(50) + reportFrame.whisperEdit:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + if A and A.checkBg then + reportFrame.whisperEdit:SetBackdropColor(A.checkBg[1], A.checkBg[2], A.checkBg[3], A.checkBg[4] or 0.9) + else + reportFrame.whisperEdit:SetBackdropColor(0.12, 0.06, 0.09, 0.9) + end + if A and A.buttonBorder then + reportFrame.whisperEdit:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + else + reportFrame.whisperEdit:SetBackdropBorderColor(0.4, 0.3, 0.35, 0.8) + end + reportFrame.whisperEdit:SetTextInsets(4, 4, 0, 0) + reportFrame.whisperEdit:SetScript("OnEscapePressed", function() this:ClearFocus() end) + reportFrame.whisperEdit:SetScript("OnEnterPressed", function() + this:ClearFocus() + DoSendReport() + end) + reportFrame.whisperRow:Hide() + + -- Lines count row + reportFrame.linesRow = CreateFrame("Frame", nil, reportFrame) + reportFrame.linesRow:SetHeight(22) + reportFrame.linesRow:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 8, -92) + reportFrame.linesRow:SetPoint("RIGHT", reportFrame, "RIGHT", -12, 0) + + local linesLabel = SFrames:CreateFontString(reportFrame.linesRow, 9, "LEFT") + linesLabel:SetPoint("LEFT", reportFrame.linesRow, "LEFT", 0, 0) + linesLabel:SetWidth(36) + if A and A.sectionTitle then + linesLabel:SetTextColor(A.sectionTitle[1], A.sectionTitle[2], A.sectionTitle[3]) + end + linesLabel:SetText(L["Report Lines"] .. ":") + + local countText = SFrames:CreateFontString(reportFrame.linesRow, 10, "CENTER") + countText:SetWidth(30) + + local minusBtn = CreateFrame("Button", nil, reportFrame.linesRow) + minusBtn:SetWidth(20) + minusBtn:SetHeight(18) + minusBtn:SetPoint("LEFT", linesLabel, "RIGHT", 4, 0) + minusBtn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + if A and A.buttonBg then + minusBtn:SetBackdropColor(A.buttonBg[1], A.buttonBg[2], A.buttonBg[3], A.buttonBg[4] or 0.9) + else + minusBtn:SetBackdropColor(0.15, 0.08, 0.12, 0.9) + end + if A and A.buttonBorder then + minusBtn:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + else + minusBtn:SetBackdropBorderColor(0.4, 0.3, 0.35, 0.8) + end + local minusLabel = SFrames:CreateFontString(minusBtn, 10, "CENTER") + minusLabel:SetAllPoints() + minusLabel:SetText("-") + + countText:SetPoint("LEFT", minusBtn, "RIGHT", 2, 0) + countText:SetText(tostring(reportCount)) + if A and A.accent then + countText:SetTextColor(A.accent[1], A.accent[2], A.accent[3]) + end + + local plusBtn = CreateFrame("Button", nil, reportFrame.linesRow) + plusBtn:SetWidth(20) + plusBtn:SetHeight(18) + plusBtn:SetPoint("LEFT", countText, "RIGHT", 2, 0) + plusBtn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + if A and A.buttonBg then + plusBtn:SetBackdropColor(A.buttonBg[1], A.buttonBg[2], A.buttonBg[3], A.buttonBg[4] or 0.9) + else + plusBtn:SetBackdropColor(0.15, 0.08, 0.12, 0.9) + end + if A and A.buttonBorder then + plusBtn:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + else + plusBtn:SetBackdropBorderColor(0.4, 0.3, 0.35, 0.8) + end + local plusLabel = SFrames:CreateFontString(plusBtn, 10, "CENTER") + plusLabel:SetAllPoints() + plusLabel:SetText("+") + + minusBtn:SetScript("OnClick", function() + reportCount = math.max(1, reportCount - 1) + countText:SetText(tostring(reportCount)) + end) + plusBtn:SetScript("OnClick", function() + reportCount = math.min(25, reportCount + 1) + countText:SetText(tostring(reportCount)) + end) + + local function StepperHover(btn) + btn:SetScript("OnEnter", function() + if A and A.buttonHoverBg then + this:SetBackdropColor(A.buttonHoverBg[1], A.buttonHoverBg[2], A.buttonHoverBg[3], 0.3) + else + this:SetBackdropColor(0.3, 0.3, 0.3, 0.2) + end + if A and A.accent then + this:SetBackdropBorderColor(A.accent[1], A.accent[2], A.accent[3], 0.9) + end + end) + btn:SetScript("OnLeave", function() + if A and A.buttonBg then + this:SetBackdropColor(A.buttonBg[1], A.buttonBg[2], A.buttonBg[3], A.buttonBg[4] or 0.9) + else + this:SetBackdropColor(0.15, 0.08, 0.12, 0.9) + end + if A and A.buttonBorder then + this:SetBackdropBorderColor(A.buttonBorder[1], A.buttonBorder[2], A.buttonBorder[3], A.buttonBorder[4] or 0.8) + else + this:SetBackdropBorderColor(0.4, 0.3, 0.35, 0.8) + end + end) + end + StepperHover(minusBtn) + StepperHover(plusBtn) + + -- Send Report button + reportFrame.sendBtn = CreateFrame("Button", nil, reportFrame) + reportFrame.sendBtn:SetWidth(160) + reportFrame.sendBtn:SetHeight(24) + reportFrame.sendBtn:SetPoint("TOPLEFT", titleBar, "BOTTOMLEFT", 60, -128) + reportFrame.sendBtn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 }, + }) + if A and A.accent then + reportFrame.sendBtn:SetBackdropColor(A.accent[1] * 0.25, A.accent[2] * 0.25, A.accent[3] * 0.25, 0.95) + reportFrame.sendBtn:SetBackdropBorderColor(A.accent[1], A.accent[2], A.accent[3], 0.8) + else + reportFrame.sendBtn:SetBackdropColor(0.2, 0.1, 0.15, 0.95) + reportFrame.sendBtn:SetBackdropBorderColor(1, 0.53, 0.67, 0.8) + end + + local sendLabel = SFrames:CreateFontString(reportFrame.sendBtn, 10, "CENTER") + sendLabel:SetAllPoints() + sendLabel:SetText(L["Send Report"]) + if A and A.accent then + sendLabel:SetTextColor(A.accent[1], A.accent[2], A.accent[3]) + end + + reportFrame.sendBtn:SetScript("OnClick", function() + DoSendReport() + end) + reportFrame.sendBtn:SetScript("OnEnter", function() + if A and A.accent then + this:SetBackdropColor(A.accent[1] * 0.4, A.accent[2] * 0.4, A.accent[3] * 0.4, 0.95) + else + this:SetBackdropColor(0.35, 0.15, 0.25, 0.95) + end + end) + reportFrame.sendBtn:SetScript("OnLeave", function() + if A and A.accent then + this:SetBackdropColor(A.accent[1] * 0.25, A.accent[2] * 0.25, A.accent[3] * 0.25, 0.95) + else + this:SetBackdropColor(0.2, 0.1, 0.15, 0.95) + end + end) + + UpdateReportChannelButtons() + reportFrame:Hide() + return reportFrame +end + +local function ShowReportPanel(anchorFrame) + local frame = EnsureReportFrame() + + frame:ClearAllPoints() + if anchorFrame and anchorFrame:GetRight() and (anchorFrame:GetRight() + 290) < GetScreenWidth() then + frame:SetPoint("TOPLEFT", anchorFrame, "TOPRIGHT", 4, 0) + else + frame:SetPoint("TOPRIGHT", anchorFrame, "TOPLEFT", -4, 0) + end + + UpdateReportChannelButtons() + + if frame:IsShown() then + frame:Hide() + else + frame:Show() + end +end + +function Window:ShowReport(sourceWindow) + local frame = EnsureReportFrame() + reportSourceWin = sourceWindow + + local mod = NanamiDPS.modules[sourceWindow.activeModuleName] + local modName = mod and mod:GetName() or sourceWindow.activeModuleName + local segName = GetSegmentName(sourceWindow) + local accentHex = (SFrames.ActiveTheme and SFrames.ActiveTheme.accentHex) or "ffFF88AA" + frame.infoText:SetText("|c" .. accentHex .. modName .. "|r - " .. segName) + + reportHeaderFn = function() + return "Nanami DPS - " .. segName .. " " .. modName .. ":" + end + reportLinesFn = function(count) + local seg = DataStore:GetSegment(sourceWindow.segmentIndex or 1) + if mod and mod.GetReportLines then + return mod:GetReportLines(seg, count) + end + return {} + end + + ShowReportPanel(sourceWindow) +end + +function Window:ShowReportCustom(anchorFrame, infoStr, headerFn, linesFn) + local frame = EnsureReportFrame() + reportSourceWin = nil + reportHeaderFn = headerFn + reportLinesFn = linesFn + frame.infoText:SetText(infoStr) + + ShowReportPanel(anchorFrame) +end