From 017e37a365b0d0ed93166f2ab4dd8a69fe73dd6c Mon Sep 17 00:00:00 2001 From: rucky Date: Fri, 20 Mar 2026 10:20:05 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E4=B8=80=E6=AC=A1=E5=8F=91=E7=89=88v0?= =?UTF-8?q?.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Auras.lua | 554 +++++++++++++++++ Castbar.lua | 111 ++++ CombatLog.lua | 293 +++++++++ ComboPoints.lua | 193 ++++++ Config.lua | 228 +++++++ Core.lua | 215 +++++++ Healthbar.lua | 153 +++++ Nanami-Plates.toc | 22 + Options.lua | 1072 ++++++++++++++++++++++++++++++++ Plates.lua | 1402 ++++++++++++++++++++++++++++++++++++++++++ README.md | 107 ++++ Scanner.lua | 63 ++ SpellDB.lua | 1484 +++++++++++++++++++++++++++++++++++++++++++++ Target.lua | 65 ++ Threat.lua | 204 +++++++ img/arrow.tga | Bin 0 -> 262162 bytes img/icon.tga | Bin 0 -> 65554 bytes 17 files changed, 6166 insertions(+) create mode 100644 Auras.lua create mode 100644 Castbar.lua create mode 100644 CombatLog.lua create mode 100644 ComboPoints.lua create mode 100644 Config.lua create mode 100644 Core.lua create mode 100644 Healthbar.lua create mode 100644 Nanami-Plates.toc create mode 100644 Options.lua create mode 100644 Plates.lua create mode 100644 README.md create mode 100644 Scanner.lua create mode 100644 SpellDB.lua create mode 100644 Target.lua create mode 100644 Threat.lua create mode 100644 img/arrow.tga create mode 100644 img/icon.tga diff --git a/Auras.lua b/Auras.lua new file mode 100644 index 0000000..29ac10a --- /dev/null +++ b/Auras.lua @@ -0,0 +1,554 @@ +NanamiPlates_Auras = {} + +local NP = NanamiPlates +local Settings = NP.Settings +local SpellDB = NanamiPlates_SpellDB + +local pairs = pairs +local ipairs = ipairs +local string_find = string.find +local string_format = string.format +local string_gsub = string.gsub +local math_floor = math.floor +local GetTime = GetTime +local UnitDebuff = UnitDebuff +local UnitGUID = UnitGUID +local UnitLevel = UnitLevel +local UnitName = UnitName +local UnitExists = UnitExists +local CreateFrame = CreateFrame + +local _, playerClass = UnitClass("player") +playerClass = playerClass or "" + +local MAX_DEBUFFS = 16 + +local JUDGEMENT_EFFECTS = { + "Judgement of Wisdom", "Judgement of Light", "Judgement of the Crusader", + "Judgement of Justice", "Judgement" +} + +NanamiPlates_Auras.timers = {} +local debuffTimers = NanamiPlates_Auras.timers + +local function FormatTime(remaining) + if not remaining or remaining < 0 then return "", 1, 1, 1, 1 end + if remaining > 3600 then + return math_floor(remaining / 3600 + 0.5) .. "h", 0.5, 0.5, 0.5, 1 + elseif remaining > 60 then + return math_floor(remaining / 60 + 0.5) .. "m", 0.5, 0.5, 0.5, 1 + elseif remaining > 10 then + return math_floor(remaining + 0.5) .. "", 0.7, 0.7, 0.7, 1 + elseif remaining > 5 then + return math_floor(remaining + 0.5) .. "", 1, 1, 0, 1 + elseif remaining > 0 then + return string_format("%.1f", remaining), 1, 0, 0, 1 + end + return "", 1, 1, 1, 1 +end +NanamiPlates_Auras.FormatTime = FormatTime + +local function GetSpellData(unit, name, effect, level) + if not SpellDB or not SpellDB.objects then return nil end + local dataUnit = unit and SpellDB:FindEffectData(unit, level or 0, effect) + local dataName = name and SpellDB:FindEffectData(name, level or 0, effect) + if dataUnit and dataName then + return (dataUnit.start or 0) >= (dataName.start or 0) and dataUnit or dataName + end + return dataUnit or dataName +end + +local function DebuffOnUpdate() + local now = GetTime() + if (this.tick or 0) > now then return else this.tick = now + 0.1 end + if not this:IsShown() then return end + if not this.expirationTime or this.expirationTime <= 0 then + if this.cd then this.cd:SetText("") end + return + end + + local timeLeft = this.expirationTime - now + if timeLeft > 0 then + local text, r, g, b, a = FormatTime(timeLeft) + if this.cd then + this.cd:SetText(text) + if r then this.cd:SetTextColor(r, g, b, a or 1) end + this.cd:SetAlpha(1) + end + else + if this.cd then + this.cd:SetText("") + this.cd:SetAlpha(0) + end + this.expirationTime = 0 + end +end + +function NanamiPlates_Auras:CreateDebuffFrames(nameplate) + nameplate.debuffs = {} + local plateName = nameplate:GetName() or "UnknownPlate" + local size = Settings.debuffIconSize or 20 + + for i = 1, MAX_DEBUFFS do + local debuff = CreateFrame("Frame", plateName .. "Debuff" .. i, nameplate) + debuff:SetWidth(size) + debuff:SetHeight(size) + debuff:SetFrameLevel(nameplate.health:GetFrameLevel() + 5) + debuff:EnableMouse(false) + + debuff.icon = debuff:CreateTexture(nil, "ARTWORK") + debuff.icon:SetAllPoints() + debuff.icon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + debuff.icon:SetDrawLayer("ARTWORK") + + debuff.border = debuff:CreateTexture(nil, "BACKGROUND") + debuff.border:SetTexture(0, 0, 0, 1) + debuff.border:SetPoint("TOPLEFT", debuff, "TOPLEFT", -1, 1) + debuff.border:SetPoint("BOTTOMRIGHT", debuff, "BOTTOMRIGHT", 1, -1) + debuff.border:SetDrawLayer("BACKGROUND") + + debuff.cdframe = CreateFrame("Frame", nil, debuff) + debuff.cdframe:SetAllPoints(debuff) + debuff.cdframe:SetFrameLevel(debuff:GetFrameLevel() + 2) + debuff.cdframe:EnableMouse(false) + + debuff.cd = debuff.cdframe:CreateFontString(nil, "OVERLAY") + debuff.cd:SetFont(NP.GetFont(), 10, NP.GetFontOutline()) + debuff.cd:SetPoint("BOTTOM", debuff, "BOTTOM", 0, -1) + debuff.cd:SetTextColor(1, 1, 0, 1) + debuff.cd:SetText("") + debuff.cd:SetDrawLayer("OVERLAY", 7) + + debuff.count = debuff.cdframe:CreateFontString(nil, "OVERLAY") + debuff.count:SetFont(NP.GetFont(), 9, NP.GetFontOutline()) + debuff.count:SetPoint("TOPRIGHT", debuff, "TOPRIGHT", 2, 2) + debuff.count:SetTextColor(1, 1, 1, 1) + debuff.count:SetText("") + debuff.count:SetDrawLayer("OVERLAY", 7) + + debuff:SetScript("OnUpdate", DebuffOnUpdate) + debuff:Hide() + nameplate.debuffs[i] = debuff + end +end + +function NanamiPlates_Auras:UpdateDebuffs(nameplate, unitstr, plateName, isTarget, hasValidGUID, superwow_active) + local size = Settings.debuffIconSize or 20 + for i = 1, MAX_DEBUFFS do + local debuff = nameplate.debuffs[i] + debuff:SetWidth(size) + debuff:SetHeight(size) + + local cdFontSize = math_floor(size * 0.5 + 0.5) + local countFontSize = math_floor(size * 0.4 + 0.5) + if cdFontSize < 7 then cdFontSize = 7 end + if countFontSize < 6 then countFontSize = 6 end + debuff.cd:SetFont(NP.GetFont(), cdFontSize, NP.GetFontOutline()) + debuff.count:SetFont(NP.GetFont(), countFontSize, NP.GetFontOutline()) + + debuff:Hide() + debuff.count:SetText("") + debuff.expirationTime = 0 + end + + local now = GetTime() + local claimedMyDebuffs = {} + + local effectiveUnit = (isTarget) and "target" or (superwow_active and hasValidGUID and unitstr) or nil + if not effectiveUnit and not plateName then return 0 end + + local scanUnit = effectiveUnit + if not scanUnit and plateName and UnitExists("target") and UnitName("target") == plateName then + scanUnit = "target" + end + if not scanUnit then return 0 end + + -- Collect debuffs + local collectedDebuffs = {} + local ownerBoundCounts = {} + local ownerBoundFirst = {} + + for i = 1, 40 do + local texture, stacks = UnitDebuff(scanUnit, i) + if not texture then break end + + local effect = SpellDB and SpellDB:ScanDebuff(scanUnit, i) + if (not effect or effect == "") and SpellDB and SpellDB.textureToSpell then + effect = SpellDB.textureToSpell[texture] + end + + -- If effect is non-empty but not in DEBUFFS (e.g. Chinese name), try fallbacks + if SpellDB and effect and effect ~= "" and SpellDB.DEBUFFS and not SpellDB.DEBUFFS[effect] then + -- Try texture-based lookup (covers spellbook-scanned entries) + local texEffect = SpellDB.textureToSpell and SpellDB.textureToSpell[texture] + if texEffect and SpellDB.DEBUFFS[texEffect] then + -- Learn this locale mapping for future + if SpellDB.LearnLocale then + SpellDB:LearnLocale(effect, texEffect) + end + effect = texEffect + elseif SpellDB.WARLOCK_DOT_TEXTURES and SpellDB.WARLOCK_DOT_TEXTURES[texture] then + effect = SpellDB.WARLOCK_DOT_TEXTURES[texture] + elseif SpellDB.WARLOCK_CURSE_TEXTURES and SpellDB.WARLOCK_CURSE_TEXTURES[texture] then + effect = SpellDB.WARLOCK_CURSE_TEXTURES[texture] + end + end + + local isOwnerBound = effect and SpellDB and SpellDB.OWNER_BOUND_DEBUFFS and SpellDB.OWNER_BOUND_DEBUFFS[effect] + + if isOwnerBound then + ownerBoundCounts[effect] = (ownerBoundCounts[effect] or 0) + 1 + if not ownerBoundFirst[effect] then + ownerBoundFirst[effect] = { index = i, texture = texture, stacks = stacks } + end + end + + table.insert(collectedDebuffs, { + index = i, texture = texture, stacks = stacks, + effect = effect, isOwnerBound = isOwnerBound + }) + end + + -- Display debuffs + local debuffIndex = 1 + local displayedOwnerBound = {} + local unitlevel = (scanUnit == "target") and UnitLevel("target") or (unitstr and UnitLevel(unitstr)) or 0 + + for _, debuffData in ipairs(collectedDebuffs) do + if debuffIndex > MAX_DEBUFFS then break end + + local effect = debuffData.effect + local texture = debuffData.texture + local stacks = debuffData.stacks + local isOwnerBound = debuffData.isOwnerBound + + local isRoguePoison = false + if playerClass == "ROGUE" then + if effect and SpellDB.ROGUE_POISONS and SpellDB.ROGUE_POISONS[effect] then + isRoguePoison = true + elseif texture and SpellDB.ROGUE_POISON_TEXTURES and SpellDB.ROGUE_POISON_TEXTURES[texture] then + isRoguePoison = true + if not effect or effect == "" then effect = SpellDB.ROGUE_POISON_TEXTURES[texture] end + end + end + + local isHunterTrap = false + if playerClass == "HUNTER" then + if effect and SpellDB.HUNTER_TRAPS and SpellDB.HUNTER_TRAPS[effect] then + isHunterTrap = true + elseif texture and SpellDB.HUNTER_TRAP_TEXTURES and SpellDB.HUNTER_TRAP_TEXTURES[texture] then + isHunterTrap = true + effect = SpellDB.HUNTER_TRAP_TEXTURES[texture] + end + end + + local isHunterSting = false + if playerClass == "HUNTER" then + if effect and SpellDB.HUNTER_STINGS and SpellDB.HUNTER_STINGS[effect] then + isHunterSting = true + elseif texture and SpellDB.HUNTER_STING_TEXTURES and SpellDB.HUNTER_STING_TEXTURES[texture] then + isHunterSting = true + effect = SpellDB.HUNTER_STING_TEXTURES[texture] + end + end + + local isWarlockCurse = false + if playerClass == "WARLOCK" then + if effect and SpellDB.WARLOCK_CURSES and SpellDB.WARLOCK_CURSES[effect] then + isWarlockCurse = true + elseif texture and SpellDB.WARLOCK_CURSE_TEXTURES and SpellDB.WARLOCK_CURSE_TEXTURES[texture] then + isWarlockCurse = true + effect = SpellDB.WARLOCK_CURSE_TEXTURES[texture] + end + end + + local isWarlockDot = false + if playerClass == "WARLOCK" and not isWarlockCurse then + if texture and SpellDB.WARLOCK_DOT_TEXTURES and SpellDB.WARLOCK_DOT_TEXTURES[texture] then + local dotName = SpellDB.WARLOCK_DOT_TEXTURES[texture] + if not effect or effect == "" or not SpellDB.DEBUFFS[effect] then + effect = dotName + end + isWarlockDot = true + elseif effect and (effect == "Corruption" or effect == "Siphon Life" or effect == "Immolate" or effect == "Dark Harvest") then + isWarlockDot = true + end + end + + if isWarlockDot or isWarlockCurse then + isOwnerBound = effect and SpellDB.OWNER_BOUND_DEBUFFS and SpellDB.OWNER_BOUND_DEBUFFS[effect] + end + + local isMyDebuff = false + local duration, timeleft = nil, nil + + if isRoguePoison or isHunterTrap or isHunterSting or isWarlockCurse or isWarlockDot then + isMyDebuff = true + end + + if effect and effect ~= "" then + local data = GetSpellData(unitstr, plateName, effect, unitlevel) + if data and data.start and data.duration then + if data.start + data.duration > now then + duration = data.duration + timeleft = data.duration + data.start - now + if data.isOwn == true and not claimedMyDebuffs[effect] then + isMyDebuff = true + claimedMyDebuffs[effect] = true + end + end + end + + if playerClass == "PALADIN" and (string_find(effect, "Judgement of ") or string_find(effect, "Seal of ") or effect == "Crusader Strike" or effect == "Hammer of Justice" or effect == "Repentance") then + isMyDebuff = true + claimedMyDebuffs[effect] = true + end + + if not timeleft then + local dbDuration = SpellDB:GetDuration(effect, 0) + if dbDuration > 0 then + SpellDB:AddEffect(plateName, unitlevel, effect, dbDuration, isMyDebuff) + if unitstr and unitstr ~= plateName then + SpellDB:AddEffect(unitstr, unitlevel, effect, dbDuration, isMyDebuff) + end + end + end + end + + if effect and effect ~= "" and not duration then + duration = SpellDB:GetDuration(effect, 0) + end + + -- Display logic + if isOwnerBound then + if not displayedOwnerBound[effect] then + local ownerCheckUnit = unitstr or plateName + local isMyOwnerBound = isMyDebuff + + if not isMyOwnerBound and SpellDB.IsOwnerBoundDebuffMine then + isMyOwnerBound = SpellDB:IsOwnerBoundDebuffMine(ownerCheckUnit, effect) + if not isMyOwnerBound and plateName and plateName ~= ownerCheckUnit then + isMyOwnerBound = SpellDB:IsOwnerBoundDebuffMine(plateName, effect) + end + end + + local shouldShowOwnerBound = isMyOwnerBound or not Settings.showOnlyMyDebuffs + + if shouldShowOwnerBound then + displayedOwnerBound[effect] = true + local debuff = nameplate.debuffs[debuffIndex] + debuff.icon:SetTexture(texture) + + local instanceCount = ownerBoundCounts[effect] or 1 + if instanceCount > 1 then + debuff.count:SetText(instanceCount) + debuff.count:SetTextColor(0.3, 0.7, 1, 1) + elseif stacks and stacks > 1 then + debuff.count:SetText(stacks) + else + debuff.count:SetText("") + end + + local debuffKey = (unitstr or plateName) .. "_" .. effect + local displayTimeLeft = nil + + if timeleft and timeleft > 0 then + displayTimeLeft = timeleft + debuffTimers[debuffKey] = { startTime = now - (duration - timeleft), duration = duration, lastSeen = now } + else + local fallbackDuration = duration or SpellDB:GetDuration(effect, 0) + if fallbackDuration <= 0 then fallbackDuration = 30 end + if not debuffTimers[debuffKey] then + debuffTimers[debuffKey] = { startTime = now, duration = fallbackDuration, lastSeen = now } + end + local cached = debuffTimers[debuffKey] + cached.lastSeen = now + if (now - cached.startTime) > cached.duration then + cached.startTime = now + cached.duration = fallbackDuration + end + displayTimeLeft = cached.duration - (now - cached.startTime) + end + + if Settings.showDebuffTimers and displayTimeLeft and displayTimeLeft > 0 then + debuff.expirationTime = now + displayTimeLeft + local text, r, g, b, a = FormatTime(displayTimeLeft) + debuff.cd:SetText(text) + if r then debuff.cd:SetTextColor(r, g, b, a) end + debuff.cdframe:Show() + else + debuff.expirationTime = 0 + debuff.cd:SetText("") + end + + debuff:Show() + debuffIndex = debuffIndex + 1 + end + end + else + local uniqueClass = effect and SpellDB and SpellDB.SHARED_DEBUFFS and SpellDB.SHARED_DEBUFFS[effect] + local isUnique = uniqueClass and (uniqueClass == true or uniqueClass == playerClass) + + local shouldDisplay = true + if Settings.showOnlyMyDebuffs and not isMyDebuff and not isUnique and not isOwnerBound and not isHunterTrap then + shouldDisplay = false + end + + if shouldDisplay then + local debuff = nameplate.debuffs[debuffIndex] + debuff.icon:SetTexture(texture) + debuff.count:SetText((stacks and stacks > 1) and stacks or "") + + local debuffKey = (unitstr or plateName) .. "_" .. (effect or texture) + local displayTimeLeft = nil + + if timeleft and timeleft > 0 then + displayTimeLeft = timeleft + debuffTimers[debuffKey] = { startTime = now - (duration - timeleft), duration = duration, lastSeen = now } + else + local dbDur = (effect and effect ~= "") and SpellDB:GetDuration(effect, 0) or 0 + local fallbackDuration = (duration and duration > 0 and duration) or (dbDur > 0 and dbDur) or 12 + if not debuffTimers[debuffKey] then + debuffTimers[debuffKey] = { startTime = now, duration = fallbackDuration, lastStacks = stacks or 0 } + end + local cached = debuffTimers[debuffKey] + cached.lastSeen = now + + local stacksChanged = stacks and cached.lastStacks and stacks ~= cached.lastStacks + if fallbackDuration > 1 and (cached.duration ~= fallbackDuration or (now - cached.startTime) > cached.duration or stacksChanged) then + cached.duration = fallbackDuration + cached.startTime = now + end + cached.lastStacks = stacks or 0 + displayTimeLeft = cached.duration - (now - cached.startTime) + end + + if Settings.showDebuffTimers and displayTimeLeft and displayTimeLeft > 0 then + debuff.expirationTime = now + displayTimeLeft + local text, r, g, b, a = FormatTime(displayTimeLeft) + debuff.cd:SetText(text) + if r then debuff.cd:SetTextColor(r, g, b, a) end + debuff.cdframe:Show() + else + debuff.expirationTime = 0 + debuff.cd:SetText("") + end + + debuff:Show() + debuffIndex = debuffIndex + 1 + end + end + end + + return debuffIndex - 1 +end + +function NanamiPlates_Auras:UpdateDebuffPositions(nameplate, numDebuffs) + if numDebuffs <= 0 then return end + local anchor = nameplate.debuffAnchor or nameplate.healthBG + for i = 1, numDebuffs do + local debuff = nameplate.debuffs[i] + if debuff then + debuff:ClearAllPoints() + if i == 1 then + debuff:SetPoint("TOPLEFT", anchor, "BOTTOMLEFT", 0, -2) + else + debuff:SetPoint("LEFT", nameplate.debuffs[i - 1], "RIGHT", 1, 0) + end + end + end +end + +local lastDebuffCleanup = 0 +local lastOwnerBoundCleanup = 0 + +function NanamiPlates_Auras:CleanupTimers() + local now = GetTime() + + if now - lastDebuffCleanup > 3 then + lastDebuffCleanup = now + for key, data in pairs(self.timers) do + local expired = false + if data.lastSeen and (now - data.lastSeen > 5) then + expired = true + end + if data.startTime and data.duration and (now - data.startTime > data.duration + 10) then + expired = true + end + if expired then + self.timers[key] = nil + end + end + end + + if now - lastOwnerBoundCleanup > 10 then + lastOwnerBoundCleanup = now + if SpellDB and SpellDB.CleanupOwnerBoundCache then + SpellDB:CleanupOwnerBoundCache() + end + end +end + +-- Paladin judgement refresh +function NanamiPlates_Auras:RefreshJudgementsOnTarget() + if playerClass ~= "PALADIN" then return end + if not UnitExists("target") then return end + if not SpellDB or not SpellDB.objects then return end + + local name = UnitName("target") + local level = UnitLevel("target") or 0 + local guid = UnitGUID and UnitGUID("target") + + local hasNameData = name and SpellDB.objects[name] + local hasGuidData = guid and SpellDB.objects[guid] + if not hasNameData and not hasGuidData then return end + + for _, effect in ipairs(JUDGEMENT_EFFECTS) do + local found = false + local dur = SpellDB:GetDuration(effect, 0) or 10 + + if hasNameData then + for lvl, effects in pairs(SpellDB.objects[name]) do + if effects[effect] then + effects[effect].start = GetTime() + effects[effect].duration = dur + found = true + break + end + end + end + + if hasGuidData and not found then + for lvl, effects in pairs(SpellDB.objects[guid]) do + if effects[effect] then + effects[effect].start = GetTime() + effects[effect].duration = dur + found = true + break + end + end + end + + if found then + debuffTimers[name .. "_" .. effect] = nil + if guid then debuffTimers[guid .. "_" .. effect] = nil end + end + end +end + +function NanamiPlates_Auras:SealHandler(attacker, victim) + if playerClass ~= "PALADIN" then return end + local isOwn = (attacker == "You" or attacker == UnitName("player")) + if not isOwn then return end + self:RefreshJudgementsOnTarget() +end + +function NanamiPlates_Auras:HolyStrikeHandler(msg) + if not msg or playerClass ~= "PALADIN" then return end + local holyStrike = string_find(string.sub(msg, 6, 17), "Holy Strike") + if not holyStrike then return end + if not string_find(msg, "%d+") then return end + self:RefreshJudgementsOnTarget() +end + +NanamiPlates.Auras = NanamiPlates_Auras diff --git a/Castbar.lua b/Castbar.lua new file mode 100644 index 0000000..74051ca --- /dev/null +++ b/Castbar.lua @@ -0,0 +1,111 @@ +NanamiPlates_Castbar = {} + +local GetTime = GetTime +local UnitGUID = UnitGUID +local UnitName = UnitName +local superwow_active = (SpellInfo ~= nil) or (UnitGUID ~= nil) or (SUPERWOW_VERSION ~= nil) + +local castDB + +local function InitReferences() + castDB = NanamiPlates.castDB +end + +local function HandleUnitCastEvent(guid, target, eventType, spellId, timer) + if not castDB then + castDB = NanamiPlates.castDB + end + if not castDB then return false end + + local SpellDB = NanamiPlates_SpellDB + + if eventType == "START" or eventType == "CAST" or eventType == "CHANNEL" then + local spell, icon + if SpellInfo and spellId then + spell, _, icon = SpellInfo(spellId) + end + spell = spell or "Casting" + icon = icon or "Interface\\Icons\\INV_Misc_QuestionMark" + + if SpellDB and eventType == "CAST" then + local effectTarget = target + local isOwn = (guid == (UnitGUID and UnitGUID("player"))) + + if (not effectTarget or effectTarget == "") and isOwn then + if UnitExists("target") then + effectTarget = UnitGUID and UnitGUID("target") or UnitName("target") + end + end + + if effectTarget and effectTarget ~= "" then + local duration = SpellDB:GetDuration(spell, 0) + if duration and duration > 0 then + SpellDB:RefreshEffect(effectTarget, 0, spell, duration, isOwn) + if NanamiPlates_Auras and NanamiPlates_Auras.timers then + NanamiPlates_Auras.timers[effectTarget .. "_" .. spell] = nil + end + if isOwn then + local targetName = UnitExists("target") and UnitName("target") + if targetName and targetName ~= effectTarget then + SpellDB:RefreshEffect(targetName, 0, spell, duration, true) + NanamiPlates_Auras.timers[targetName .. "_" .. spell] = nil + end + if SpellDB.OWNER_BOUND_DEBUFFS and SpellDB.OWNER_BOUND_DEBUFFS[spell] then + SpellDB:TrackOwnerBoundDebuff(effectTarget, spell, duration) + if targetName and targetName ~= effectTarget then + SpellDB:TrackOwnerBoundDebuff(targetName, spell, duration) + end + end + end + end + end + end + + if eventType == "CAST" then + if castDB[guid] and castDB[guid].spell ~= spell then + return true + end + end + + castDB[guid] = { + spell = spell, + startTime = GetTime(), + duration = timer or 2000, + icon = icon, + channel = (eventType == "CHANNEL") + } + elseif eventType == "FAIL" then + if castDB[guid] then + castDB[guid] = nil + end + end + + return false +end + +local function ClearCastData() + if not castDB then castDB = NanamiPlates.castDB end + if castDB then + for k in pairs(castDB) do castDB[k] = nil end + end +end + +local function GetCast(guid) + if not castDB then castDB = NanamiPlates.castDB end + return castDB and castDB[guid] +end + +local function RemoveCast(guid) + if not castDB then castDB = NanamiPlates.castDB end + if castDB and castDB[guid] then castDB[guid] = nil end +end + +NanamiPlates_Castbar = { + InitReferences = InitReferences, + HandleUnitCastEvent = HandleUnitCastEvent, + ClearCastData = ClearCastData, + GetCast = GetCast, + RemoveCast = RemoveCast, +} + +NanamiPlates.Castbar = NanamiPlates_Castbar diff --git a/CombatLog.lua b/CombatLog.lua new file mode 100644 index 0000000..202f511 --- /dev/null +++ b/CombatLog.lua @@ -0,0 +1,293 @@ +NanamiPlates_CombatLog = {} + +local NP = NanamiPlates +local SpellDB = NanamiPlates_SpellDB +local Auras = NanamiPlates_Auras + +local string_gsub = string.gsub +local string_gfind = string.gfind +local string_find = string.find +local string_sub = string.sub +local GetTime = GetTime +local UnitExists = UnitExists +local UnitName = UnitName +local UnitGUID = UnitGUID + +local superwow_active = NP.superwow_active + +local castTracker +local recentMeleeHits + +local function InitReferences() + castTracker = NP.castTracker + recentMeleeHits = NP.recentMeleeHits +end + +local function cmatch(str, pattern) + if not str or not pattern then return nil end + local pat = string_gsub(pattern, "%%%d?%$?s", "(.+)") + pat = string_gsub(pat, "%%%d?%$?d", "(%d+)") + for a, b, c, d in string_gfind(str, pat) do + return a, b, c, d + end + return nil +end + +local castIcons = { + ["Fireball"] = "Interface\\Icons\\Spell_Fire_FlameBolt", + ["Frostbolt"] = "Interface\\Icons\\Spell_Frost_FrostBolt02", + ["Shadow Bolt"] = "Interface\\Icons\\Spell_Shadow_ShadowBolt", + ["Greater Heal"] = "Interface\\Icons\\Spell_Holy_GreaterHeal", + ["Flash Heal"] = "Interface\\Icons\\Spell_Holy_FlashHeal", + ["Lightning Bolt"] = "Interface\\Icons\\Spell_Nature_Lightning", + ["Chain Lightning"] = "Interface\\Icons\\Spell_Nature_ChainLightning", + ["Healing Wave"] = "Interface\\Icons\\Spell_Nature_MagicImmunity", + ["Fear"] = "Interface\\Icons\\Spell_Shadow_Possession", + ["Polymorph"] = "Interface\\Icons\\Spell_Nature_Polymorph", + ["Smite"] = "Interface\\Icons\\Spell_Holy_HolySmite", + ["Mind Blast"] = "Interface\\Icons\\Spell_Shadow_UnholyFrenzy", + ["Holy Light"] = "Interface\\Icons\\Spell_Holy_HolyLight", + ["Starfire"] = "Interface\\Icons\\Spell_Arcane_StarFire", + ["Wrath"] = "Interface\\Icons\\Spell_Nature_AbolishMagic", + ["Entangling Roots"] = "Interface\\Icons\\Spell_Nature_StrangleVines", + ["Moonfire"] = "Interface\\Icons\\Spell_Nature_StarFall", + ["Regrowth"] = "Interface\\Icons\\Spell_Nature_ResistNature", + ["Rejuvenation"] = "Interface\\Icons\\Spell_Nature_Rejuvenation", +} + +local function ParseCastStart(msg) + if not msg then return end + if not castTracker then castTracker = NP.castTracker end + if not castTracker then return end + + local unit, spell = nil, nil + for u, s in string_gfind(msg, "(.+) begins to cast (.+)%.") do + unit, spell = u, s + end + if not unit then + for u, s in string_gfind(msg, "(.+) begins to perform (.+)%.") do + unit, spell = u, s + end + end + + if unit and spell then + if not castTracker[unit] then castTracker[unit] = {} end + table.insert(castTracker[unit], { + spell = spell, + startTime = GetTime(), + duration = 2000, + icon = castIcons[spell], + }) + end + + local interruptedUnit = nil + for u in string_gfind(msg, "(.+)'s .+ is interrupted%.") do interruptedUnit = u end + if not interruptedUnit then + for u in string_gfind(msg, "(.+)'s .+ fails%.") do interruptedUnit = u end + end + if interruptedUnit and castTracker[interruptedUnit] then + table.remove(castTracker[interruptedUnit], 1) + end +end + +local function ParseAttackHit(msg) + if not msg then return end + local attacker, victim = nil, nil + + if string_sub(msg, 1, 8) == "You hit " then + local forPos = string_find(msg, " for ") + if forPos then + victim = string_sub(msg, 9, forPos - 1) + attacker = "You" + end + elseif string_sub(msg, 1, 9) == "You crit " then + local forPos = string_find(msg, " for ") + if forPos then + victim = string_sub(msg, 10, forPos - 1) + attacker = "You" + end + end + + if attacker == "You" and victim and Auras then + Auras:SealHandler(attacker, victim) + end + + if not recentMeleeHits then recentMeleeHits = NP.recentMeleeHits end + if not recentMeleeHits then return end + + if attacker == "You" and victim then + recentMeleeHits[victim] = GetTime() + if superwow_active and UnitExists("target") and UnitName("target") == victim then + local guid = UnitGUID and UnitGUID("target") + if guid then recentMeleeHits[guid] = GetTime() end + end + end + + if not victim then + for a, v in string_gfind(msg, "(.+) hits (.-) for %d+%.") do + attacker, victim = a, v + break + end + end + if not victim then + for a, v in string_gfind(msg, "(.+) crits (.-) for %d+%.") do + attacker, victim = a, v + break + end + end + + if attacker == "You" and victim and recentMeleeHits then + recentMeleeHits[victim] = GetTime() + if superwow_active and UnitExists("target") and UnitName("target") == victim then + local guid = UnitGUID and UnitGUID("target") + if guid then recentMeleeHits[guid] = GetTime() end + end + end + + if attacker and victim and Auras then + Auras:SealHandler(attacker, victim) + end +end + +-- Handle spell-related combat log events +function NanamiPlates_CombatLog.HandleSpellEvent(evnt, msg) + if not msg then return end + + InitReferences() + + if evnt == "CHAT_MSG_SPELL_AURA_GONE_OTHER" or evnt == "CHAT_MSG_SPELL_AURA_GONE_SELF" then + local target, effect + for t, e in string_gfind(msg, "(.+) is no longer afflicted by (.+)%.") do + target, effect = t, e + end + if not target then + for e in string_gfind(msg, "(.+) fades from .+%.") do effect = e end + end + if effect and SpellDB and SpellDB.objects then + for unit, levels in pairs(SpellDB.objects) do + for lvl, effects in pairs(levels) do + if effects[effect] then effects[effect] = nil end + end + end + end + return + end + + if evnt == "CHAT_MSG_SPELL_FAILED_LOCALPLAYER" then + if SpellDB then SpellDB:RemovePending() end + return + end + + -- Check for pending spell resolution + if SpellDB and SpellDB.pending and SpellDB.pending[3] then + local effect = SpellDB.pending[3] + + -- Check if spell was resisted/missed/etc + local removePatterns = NP.REMOVE_PENDING_PATTERNS + if removePatterns then + for _, pattern in ipairs(removePatterns) do + if cmatch(msg, pattern) then + SpellDB:RemovePending() + return + end + end + end + + -- Check for spell hit/application + if string_find(msg, effect) then + local affTarget = nil + for t in string_gfind(msg, "(.+) is afflicted by " .. effect) do + affTarget = t + end + if affTarget or string_find(msg, "Your " .. effect) then + SpellDB:PersistPending(effect) + + if SpellDB.OWNER_BOUND_DEBUFFS and SpellDB.OWNER_BOUND_DEBUFFS[effect] then + local targetName = affTarget or (UnitExists("target") and UnitName("target")) + if targetName then + SpellDB:TrackOwnerBoundDebuff(targetName, effect) + if superwow_active and UnitExists("target") and UnitName("target") == targetName then + local guid = UnitGUID and UnitGUID("target") + if guid then SpellDB:TrackOwnerBoundDebuff(guid, effect) end + end + end + end + end + end + end + + -- Fallback: track debuffs via "afflicted by" messages using recentCasts. + -- Handles instant-cast DOTs (e.g. Corruption) whose pending was overwritten + -- by a subsequent cast before the combat log confirmed application. + do + local affTarget, affEffect + for t, e in string_gfind(msg, "(.+) is afflicted by (.+)%.") do + affTarget, affEffect = t, e + end + if affEffect and SpellDB and SpellDB.recentCasts then + if SpellDB.WARLOCK_CURSES and SpellDB.WARLOCK_CURSES[affEffect] then + local hasMalediction = SpellDB.HasMalediction and SpellDB:HasMalediction() + for otherCurse, _ in pairs(SpellDB.WARLOCK_CURSES) do + if otherCurse ~= affEffect then + local otherRecent = SpellDB.recentCasts[otherCurse] + local thisRecent = SpellDB.recentCasts[affEffect] + if otherRecent and thisRecent and otherRecent.time > thisRecent.time then + if hasMalediction and SpellDB:CanCursesCoexist(affEffect, otherCurse) then + -- skip: these two curses can coexist + else + return + end + end + end + end + end + local recent = SpellDB.recentCasts[affEffect] + if recent and (GetTime() - recent.time) < 4 then + SpellDB:RefreshEffect(affTarget, 0, affEffect, recent.duration, true) + + if superwow_active and UnitExists("target") and UnitName("target") == affTarget then + local guid = UnitGUID and UnitGUID("target") + if guid then + SpellDB:RefreshEffect(guid, 0, affEffect, recent.duration, true) + end + end + + if SpellDB.OWNER_BOUND_DEBUFFS and SpellDB.OWNER_BOUND_DEBUFFS[affEffect] then + SpellDB:TrackOwnerBoundDebuff(affTarget, affEffect, recent.duration) + if superwow_active and UnitExists("target") and UnitName("target") == affTarget then + local guid = UnitGUID and UnitGUID("target") + if guid then SpellDB:TrackOwnerBoundDebuff(guid, affEffect, recent.duration) end + end + end + + if Auras and Auras.timers then + Auras.timers[affTarget .. "_" .. affEffect] = nil + end + end + end + end + + -- Parse cast starts for non-SuperWoW fallback + if NP.SPELL_DAMAGE_EVENTS and NP.SPELL_DAMAGE_EVENTS[evnt] then + ParseCastStart(msg) + end + + -- Holy Strike handler for Paladin + if evnt == "CHAT_MSG_SPELL_SELF_DAMAGE" and Auras then + Auras:HolyStrikeHandler(msg) + end +end + +function NanamiPlates_CombatLog.HandleCombatEvent(evnt, msg) + if not msg then return end + InitReferences() + ParseAttackHit(msg) +end + +NanamiPlates_CombatLog.cmatch = cmatch +NanamiPlates_CombatLog.castIcons = castIcons +NanamiPlates_CombatLog.ParseCastStart = ParseCastStart +NanamiPlates_CombatLog.ParseAttackHit = ParseAttackHit +NanamiPlates_CombatLog.InitReferences = InitReferences + +NanamiPlates.CombatLog = NanamiPlates_CombatLog diff --git a/ComboPoints.lua b/ComboPoints.lua new file mode 100644 index 0000000..26749d6 --- /dev/null +++ b/ComboPoints.lua @@ -0,0 +1,193 @@ +NanamiPlates_ComboPoints = {} + +local NP = NanamiPlates +local Settings = NP.Settings +local GetComboPoints = GetComboPoints +local UnitExists = UnitExists +local CreateFrame = CreateFrame + +local MAX_COMBO_POINTS = 5 +local CP_SIZE = 10 +local CP_SPACING = 3 + +local COMBO_COLORS = { + {0.3, 1.0, 0.3, 1}, + {0.6, 1.0, 0.0, 1}, + {1.0, 0.85, 0.0, 1}, + {1.0, 0.45, 0.0, 1}, + {1.0, 0.1, 0.1, 1}, +} + +local _, playerClass = UnitClass("player") +playerClass = playerClass or "" +local canUseComboPoints = (playerClass == "ROGUE" or playerClass == "DRUID") + +function NanamiPlates_ComboPoints:CanUseComboPoints() + return canUseComboPoints +end + +function NanamiPlates_ComboPoints:CreateComboPointFrames(nameplate) + if not canUseComboPoints then return end + + nameplate.comboPoints = {} + local plateName = nameplate:GetName() or "UnknownPlate" + local size = Settings.comboPointsSize or CP_SIZE + + for i = 1, MAX_COMBO_POINTS do + local cp = CreateFrame("Frame", plateName .. "CP" .. i, nameplate) + cp:SetWidth(size) + cp:SetHeight(size) + cp:SetFrameLevel(nameplate.health:GetFrameLevel() + 6) + cp:EnableMouse(false) + + -- Outer glow/shadow + cp.glow = cp:CreateTexture(nil, "BACKGROUND") + cp.glow:SetTexture("Interface\\Buttons\\WHITE8X8") + cp.glow:SetPoint("CENTER", cp, "CENTER", 0, 0) + cp.glow:SetWidth(size + 4) + cp.glow:SetHeight(size + 4) + cp.glow:SetVertexColor(0, 0, 0, 0.6) + cp.glow:SetDrawLayer("BACKGROUND") + + -- Border (themed) + cp.border = cp:CreateTexture(nil, "BORDER") + cp.border:SetTexture("Interface\\Buttons\\WHITE8X8") + cp.border:SetPoint("CENTER", cp, "CENTER", 0, 0) + cp.border:SetWidth(size + 2) + cp.border:SetHeight(size + 2) + local brR, brG, brB = NP.GetThemeColor("panelBorder", 0.55, 0.30, 0.42, 1) + cp.border:SetVertexColor(brR, brG, brB, 1) + cp.border:SetDrawLayer("BORDER") + + -- Main fill + cp.icon = cp:CreateTexture(nil, "ARTWORK") + cp.icon:SetTexture("Interface\\Buttons\\WHITE8X8") + cp.icon:SetPoint("CENTER", cp, "CENTER", 0, 0) + cp.icon:SetWidth(size) + cp.icon:SetHeight(size) + cp.icon:SetVertexColor(0.12, 0.08, 0.12, 0.6) + cp.icon:SetDrawLayer("ARTWORK") + + -- Number text + cp.text = cp:CreateFontString(nil, "OVERLAY") + cp.text:SetFont(NP.GetFont(), size - 2, NP.GetFontOutline()) + cp.text:SetPoint("CENTER", cp, "CENTER", 0, 0) + cp.text:SetText("") + cp.text:SetDrawLayer("OVERLAY") + + cp.active = false + cp:Hide() + nameplate.comboPoints[i] = cp + end +end + +function NanamiPlates_ComboPoints:UpdateComboPoints(nameplate, isTarget) + if not canUseComboPoints then return 0 end + if not nameplate.comboPoints then return 0 end + if not Settings.showComboPoints then + for i = 1, MAX_COMBO_POINTS do + if nameplate.comboPoints[i] then nameplate.comboPoints[i]:Hide() end + end + return 0 + end + + local numPoints = 0 + if isTarget and UnitExists("target") then + numPoints = GetComboPoints("player", "target") or 0 + end + + local size = Settings.comboPointsSize or CP_SIZE + + for i = 1, MAX_COMBO_POINTS do + local cp = nameplate.comboPoints[i] + if cp then + cp:SetWidth(size) + cp:SetHeight(size) + cp.glow:SetWidth(size + 4) + cp.glow:SetHeight(size + 4) + cp.border:SetWidth(size + 2) + cp.border:SetHeight(size + 2) + cp.icon:SetWidth(size) + cp.icon:SetHeight(size) + cp.text:SetFont(NP.GetFont(), size - 2 > 6 and size - 2 or 6, NP.GetFontOutline()) + + if i <= numPoints then + local color = COMBO_COLORS[i] or COMBO_COLORS[MAX_COMBO_POINTS] + cp.icon:SetVertexColor(color[1], color[2], color[3], 1) + cp.glow:SetVertexColor(color[1] * 0.4, color[2] * 0.4, color[3] * 0.4, 0.7) + cp.text:SetText(i) + cp.text:SetTextColor(1, 1, 1, 0.9) + cp.active = true + cp:Show() + elseif numPoints > 0 then + cp.icon:SetVertexColor(0.12, 0.08, 0.12, 0.5) + cp.glow:SetVertexColor(0, 0, 0, 0.4) + cp.text:SetText("") + cp.active = false + cp:Show() + else + cp:Hide() + end + end + end + + return numPoints +end + +function NanamiPlates_ComboPoints:UpdateComboPointPositions(nameplate, numDebuffs) + if not canUseComboPoints then return end + if not nameplate.comboPoints then return end + if not Settings.showComboPoints then return end + if not nameplate.healthBG then return end + + local numVisible = 0 + for i = 1, MAX_COMBO_POINTS do + if nameplate.comboPoints[i] and nameplate.comboPoints[i]:IsShown() then + numVisible = MAX_COMBO_POINTS + break + end + end + + if numVisible == 0 then + if nameplate.name and nameplate._cpNameOffset then + nameplate.name:ClearAllPoints() + nameplate.name:SetPoint("BOTTOM", nameplate.healthBG, "TOP", 0, 2) + nameplate._cpNameOffset = nil + end + return + end + + local size = Settings.comboPointsSize or CP_SIZE + local spacing = CP_SPACING + local totalWidth = (size * MAX_COMBO_POINTS) + (spacing * (MAX_COMBO_POINTS - 1)) + local startOffset = -totalWidth / 2 + size / 2 + + for i = 1, MAX_COMBO_POINTS do + local cp = nameplate.comboPoints[i] + if cp then + cp:ClearAllPoints() + local xOffset = startOffset + (i - 1) * (size + spacing) + cp:SetPoint("BOTTOM", nameplate.healthBG, "TOP", xOffset, 2) + end + end + + if nameplate.name then + nameplate.name:ClearAllPoints() + nameplate.name:SetPoint("BOTTOM", nameplate.healthBG, "TOP", 0, 2 + size + 2) + nameplate._cpNameOffset = true + end +end + +function NanamiPlates_ComboPoints:HideComboPoints(nameplate) + if not nameplate.comboPoints then return end + for i = 1, MAX_COMBO_POINTS do + if nameplate.comboPoints[i] then nameplate.comboPoints[i]:Hide() end + end + if nameplate.name and nameplate._cpNameOffset then + nameplate.name:ClearAllPoints() + nameplate.name:SetPoint("BOTTOM", nameplate.healthBG, "TOP", 0, 2) + nameplate._cpNameOffset = nil + end +end + +NanamiPlates.ComboPoints = NanamiPlates_ComboPoints diff --git a/Config.lua b/Config.lua new file mode 100644 index 0000000..2512470 --- /dev/null +++ b/Config.lua @@ -0,0 +1,228 @@ +local NP = NanamiPlates + +local function GetThemeColor(key, fallbackR, fallbackG, fallbackB, fallbackA) + if SFrames and SFrames.ActiveTheme and SFrames.ActiveTheme[key] then + local c = SFrames.ActiveTheme[key] + return c[1] or fallbackR, c[2] or fallbackG, c[3] or fallbackB, c[4] or fallbackA or 1 + end + return fallbackR, fallbackG, fallbackB, fallbackA or 1 +end +NP.GetThemeColor = GetThemeColor + +local function GetFont() + if SFrames and SFrames.GetFont then + return SFrames:GetFont() + end + return "Fonts\\ARIALN.TTF" +end +NP.GetFont = GetFont + +local function GetFontOutline() + if SFrames and SFrames.Media and SFrames.Media.fontOutline then + return SFrames.Media.fontOutline + end + return "OUTLINE" +end +NP.GetFontOutline = GetFontOutline + +local function GetTexture() + if SFrames and SFrames.GetTexture then + return SFrames:GetTexture() + end + return "Interface\\TargetingFrame\\UI-StatusBar" +end +NP.GetTexture = GetTexture + +NP.Settings = { + healthbarHeight = 12, + healthbarWidth = 120, + healthFontSize = 9, + healthTextFormat = 4, + + friendHealthbarHeight = 4, + friendHealthbarWidth = 85, + friendHealthFontSize = 8, + friendHealthTextFormat = 1, + + castbarHeight = 10, + castbarWidth = 120, + showCastbarIcon = true, + + friendCastbarHeight = 6, + friendCastbarWidth = 85, + friendShowCastbarIcon = true, + + castbarColor = {1, 0.8, 0, 1}, + + levelFontSize = 9, + nameFontSize = 9, + friendLevelFontSize = 7, + friendNameFontSize = 8, + + raidIconPosition = "LEFT", + namePosition = "BOTTOM", + + showOnlyMyDebuffs = false, + showDebuffTimers = true, + debuffIconSize = 20, + + showComboPoints = true, + comboPointsSize = 12, + + showTargetGlow = true, + targetArrowStyle = 1, + targetArrowSize = 24, + targetArrowOffset = 0, + targetArrowTint = 0, + nonTargetAlpha = 0.35, + + showCritterNameplates = false, + + showManaBar = true, + manabarHeight = 3, + + nameplateYOffset = 15, + + pvpEnemyAsFriendly = false, + pvpEnemyNoClassColors = false, + + showQuestIcon = true, +} + +NP.Colors = { + hostile = {0.85, 0.2, 0.2, 1}, + neutral = {0.9, 0.7, 0.0, 1}, + friendly = {0.2, 0.8, 0.2, 1}, + tapped = {0.5, 0.5, 0.5, 1}, + + class = { + WARRIOR = {0.78, 0.61, 0.43}, + MAGE = {0.41, 0.80, 0.94}, + ROGUE = {1.0, 0.96, 0.41}, + DRUID = {1.0, 0.49, 0.04}, + HUNTER = {0.67, 0.83, 0.45}, + SHAMAN = {0.14, 0.35, 1.0}, + PRIEST = {1.0, 1.0, 1.0}, + WARLOCK = {0.58, 0.51, 0.79}, + PALADIN = {0.96, 0.55, 0.73}, + }, + + power = { + [0] = {0.0, 0.0, 1.0}, + [1] = {1.0, 0.0, 0.0}, + [2] = {1.0, 0.5, 0.0}, + [3] = {1.0, 1.0, 0.0}, + [4] = {0.0, 1.0, 1.0}, + }, +} + +NP.THREAT_COLORS = { + DPS = { + AGGRO = {1.0, 0.2, 0.2, 1}, + HIGH_THREAT = {1.0, 0.6, 0.0, 1}, + NO_AGGRO = {0.85, 0.2, 0.2, 1}, + }, + TANK = { + AGGRO = {0.2, 0.8, 0.2, 1}, + LOSING_AGGRO = {1.0, 0.6, 0.0, 1}, + NO_AGGRO = {1.0, 0.1, 0.1, 1}, + OTHER_TANK = {0.6, 0.8, 1.0, 1}, + }, + TAPPED = {0.5, 0.5, 0.5, 1}, + STUN = {0.376, 0.027, 0.431, 1}, +} + +NP.Critters = { + ["adder"] = true, ["beetle"] = true, ["belfry bat"] = true, + ["biletoad"] = true, ["black rat"] = true, ["brown prairie dog"] = true, + ["caged rabbit"] = true, ["caged sheep"] = true, ["caged squirrel"] = true, + ["caged toad"] = true, ["cat"] = true, ["chicken"] = true, + ["cleo"] = true, ["core rat"] = true, ["cow"] = true, + ["cured deer"] = true, ["cured gazelle"] = true, ["deeprun rat"] = true, + ["deer"] = true, ["dog"] = true, ["effsee"] = true, + ["enthralled deeprun rat"] = true, ["fang"] = true, ["fawn"] = true, + ["fire beetle"] = true, ["fluffy"] = true, ["frog"] = true, + ["gazelle"] = true, ["hare"] = true, ["horse"] = true, + ["huge toad"] = true, ["infected deer"] = true, ["infected squirrel"] = true, + ["jungle toad"] = true, ["krakle's thermometer"] = true, ["lady"] = true, + ["larva"] = true, ["lava crab"] = true, ["maggot"] = true, + ["moccasin"] = true, ["mouse"] = true, ["mr. bigglesworth"] = true, + ["nibbles"] = true, ["noarm"] = true, ["old blanchy"] = true, + ["parrot"] = true, ["pig"] = true, ["pirate treasure trigger mob"] = true, + ["plagued insect"] = true, ["plagued maggot"] = true, ["plagued rat"] = true, + ["plagueland termite"] = true, ["polymorphed chicken"] = true, + ["polymorphed rat"] = true, ["prairie dog"] = true, ["rabbit"] = true, + ["ram"] = true, ["rat"] = true, ["riding ram"] = true, + ["roach"] = true, ["salome"] = true, ["school of fish"] = true, + ["scorpion"] = true, ["sheep"] = true, ["shen'dralar wisp"] = true, + ["sickly deer"] = true, ["sickly gazelle"] = true, ["snake"] = true, + ["spider"] = true, ["spike"] = true, ["squirrel"] = true, + ["swine"] = true, ["tainted cockroach"] = true, ["tainted rat"] = true, + ["toad"] = true, ["transporter malfunction"] = true, ["turtle"] = true, + ["underfoot"] = true, ["voice of elune"] = true, + ["waypoint (only gm can see it)"] = true, ["wisp"] = true, +} + +function NP:DetectTankSpec() + local _, playerClass = UnitClass("player") + if not playerClass then return end + + if playerClass ~= "WARRIOR" and playerClass ~= "PALADIN" and playerClass ~= "DRUID" then + return + end + + if not GetTalentTabInfo then return end + + local _, _, p1 = GetTalentTabInfo(1) + local _, _, p2 = GetTalentTabInfo(2) + local _, _, p3 = GetTalentTabInfo(3) + p1 = p1 or 0 + p2 = p2 or 0 + p3 = p3 or 0 + + local isTank = false + if playerClass == "WARRIOR" then + isTank = (p3 >= p1 and p3 >= p2 and p3 >= 11) + elseif playerClass == "PALADIN" then + isTank = (p2 >= p1 and p2 >= p3 and p2 >= 11) + elseif playerClass == "DRUID" then + isTank = (p2 >= p1 and p2 >= p3 and p2 >= 11) + end + + local newRole = isTank and "TANK" or "DPS" + if newRole ~= self.playerRole then + self.playerRole = newRole + self:SaveSettings() + self.Print("Auto role: " .. newRole) + if NanamiPlates_Threat then + NanamiPlates_Threat.BroadcastTankMode(true) + end + end +end + +function NP:LoadSettings() + if NanamiPlatesDB then + for k, v in pairs(NanamiPlatesDB) do + if self.Settings[k] ~= nil then + self.Settings[k] = v + end + end + if NanamiPlatesDB.playerRole then + self.playerRole = NanamiPlatesDB.playerRole + end + end +end + +function NP:SaveSettings() + NanamiPlatesDB = {} + for k, v in pairs(self.Settings) do + NanamiPlatesDB[k] = v + end + NanamiPlatesDB.playerRole = self.playerRole +end + +if SFrames and SFrames.Config and SFrames.Config.colors and SFrames.Config.colors.class then + for k, v in pairs(SFrames.Config.colors.class) do + NP.Colors.class[k] = {v.r, v.g, v.b} + end +end diff --git a/Core.lua b/Core.lua new file mode 100644 index 0000000..3abedbf --- /dev/null +++ b/Core.lua @@ -0,0 +1,215 @@ +NanamiPlates = {} +NanamiPlates.modules = {} +NanamiPlates.registry = {} + +local pairs = pairs +local tostring = tostring +local CreateFrame = CreateFrame + +local superwow_active = (SpellInfo ~= nil) or (UnitGUID ~= nil) or (SUPERWOW_VERSION ~= nil) +NanamiPlates.superwow_active = superwow_active + +local _, playerClass = UnitClass("player") +playerClass = playerClass or "" +NanamiPlates.playerClass = playerClass + +NanamiPlates.castDB = {} +NanamiPlates.castTracker = {} +NanamiPlates.debuffTracker = {} +NanamiPlates.recentMeleeCrits = {} +NanamiPlates.recentMeleeHits = {} +NanamiPlates.playerClassCache = {} +NanamiPlates.playerRole = "DPS" + +local function Print(msg) + if DEFAULT_CHAT_FRAME then + DEFAULT_CHAT_FRAME:AddMessage("|cffff88cc[Nanami-Plates]|r " .. tostring(msg)) + end +end +NanamiPlates.Print = Print + +local function HookScript(frame, script, func) + local prev = frame:GetScript(script) + frame:SetScript(script, function(a1, a2, a3, a4, a5, a6, a7, a8, a9) + if prev then prev(a1, a2, a3, a4, a5, a6, a7, a8, a9) end + func(a1, a2, a3, a4, a5, a6, a7, a8, a9) + end) +end +NanamiPlates.HookScript = HookScript + +local function DisableShaguTweaksNameplates() + if ShaguTweaks and ShaguTweaks.libnameplate then + ShaguTweaks.libnameplate:SetScript("OnUpdate", nil) + ShaguTweaks.libnameplate.OnInit = {} + ShaguTweaks.libnameplate.OnShow = {} + ShaguTweaks.libnameplate.OnUpdate = {} + ShaguTweaks.libnameplate.disabled_by_nanamiplates = true + return true + end + return false +end + +local function DisablePfUINameplates() + if pfUI then + if pfUI.modules then pfUI.modules["nameplates"] = nil end + if pfNameplates then + pfNameplates:Hide() + pfNameplates:UnregisterAllEvents() + end + if pfUI.nameplates then pfUI.nameplates = nil end + return true + end + return false +end + +DisableShaguTweaksNameplates() +DisablePfUINameplates() + +local function GetPlayerClassByName(name) + if not name then return nil end + local cache = NanamiPlates.playerClassCache + if cache[name] then return cache[name] end + + local playerName = UnitName("player") + if name == playerName then + cache[name] = playerClass + return playerClass + end + + local numRaid = GetNumRaidMembers() + if numRaid > 0 then + for i = 1, numRaid do + local raidName, _, _, _, _, raidClass = GetRaidRosterInfo(i) + if raidName == name then + cache[name] = raidClass + return raidClass + end + end + else + local numParty = GetNumPartyMembers() + for i = 1, numParty do + local partyUnit = "party" .. i + if UnitExists(partyUnit) then + local partyName = UnitName(partyUnit) + if partyName == name then + local _, partyClass = UnitClass(partyUnit) + cache[name] = partyClass + return partyClass + end + end + end + end + return nil +end +NanamiPlates.GetPlayerClassByName = GetPlayerClassByName + +local NP_EventFrame = CreateFrame("Frame", "NanamiPlatesFrame", UIParent) + +NP_EventFrame:RegisterEvent("PLAYER_ENTERING_WORLD") +NP_EventFrame:RegisterEvent("ADDON_LOADED") +NP_EventFrame:RegisterEvent("PLAYER_TARGET_CHANGED") +NP_EventFrame:RegisterEvent("UNIT_AURA") +NP_EventFrame:RegisterEvent("PARTY_MEMBERS_CHANGED") +NP_EventFrame:RegisterEvent("RAID_ROSTER_UPDATE") +NP_EventFrame:RegisterEvent("PLAYER_LEVEL_UP") +NP_EventFrame:RegisterEvent("PLAYER_REGEN_DISABLED") +NP_EventFrame:RegisterEvent("PLAYER_REGEN_ENABLED") +NP_EventFrame:RegisterEvent("UNIT_CASTEVENT") +NP_EventFrame:RegisterEvent("SPELLCAST_STOP") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_FAILED_LOCALPLAYER") +NP_EventFrame:RegisterEvent("CHARACTER_POINTS_CHANGED") +NP_EventFrame:RegisterEvent("QUEST_LOG_UPDATE") + +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_SELF_DAMAGE") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_TRADESKILLS") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_OTHER") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_AURA_GONE_SELF") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_PARTY_BUFF") +NP_EventFrame:RegisterEvent("CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF") + +NP_EventFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_HITS") +NP_EventFrame:RegisterEvent("CHAT_MSG_COMBAT_PARTY_HITS") +NP_EventFrame:RegisterEvent("CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS") +NP_EventFrame:RegisterEvent("CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS") +NP_EventFrame:RegisterEvent("CHAT_MSG_COMBAT_SELF_RANGED_HITS") +NP_EventFrame:RegisterEvent("CHAT_MSG_COMBAT_PARTY_RANGED_HITS") + +NanamiPlates.EventFrame = NP_EventFrame + +NanamiPlates.SPELL_EVENTS = { + ["CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE"] = true, + ["CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE"] = true, + ["CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE"] = true, + ["CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE"] = true, + ["CHAT_MSG_SPELL_HOSTILEPLAYER_BUFF"] = true, + ["CHAT_MSG_SPELL_CREATURE_VS_CREATURE_BUFF"] = true, + ["CHAT_MSG_SPELL_SELF_DAMAGE"] = true, + ["CHAT_MSG_SPELL_TRADESKILLS"] = true, + ["CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE"] = true, + ["CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE"] = true, + ["CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE"] = true, + ["CHAT_MSG_SPELL_AURA_GONE_OTHER"] = true, + ["CHAT_MSG_SPELL_AURA_GONE_SELF"] = true, + ["CHAT_MSG_SPELL_CREATURE_VS_PARTY_BUFF"] = true, + ["CHAT_MSG_SPELL_CREATURE_VS_SELF_BUFF"] = true, + ["CHAT_MSG_SPELL_FAILED_LOCALPLAYER"] = true, +} + +NanamiPlates.SPELL_DAMAGE_EVENTS = { + ["CHAT_MSG_SPELL_HOSTILEPLAYER_DAMAGE"] = true, + ["CHAT_MSG_SPELL_CREATURE_VS_CREATURE_DAMAGE"] = true, + ["CHAT_MSG_SPELL_CREATURE_VS_PARTY_DAMAGE"] = true, + ["CHAT_MSG_SPELL_CREATURE_VS_SELF_DAMAGE"] = true, + ["CHAT_MSG_SPELL_SELF_DAMAGE"] = true, + ["CHAT_MSG_SPELL_PERIODIC_CREATURE_DAMAGE"] = true, + ["CHAT_MSG_SPELL_PERIODIC_HOSTILEPLAYER_DAMAGE"] = true, + ["CHAT_MSG_SPELL_PERIODIC_SELF_DAMAGE"] = true, +} + +NanamiPlates.COMBAT_EVENTS = { + ["CHAT_MSG_COMBAT_SELF_HITS"] = true, + ["CHAT_MSG_COMBAT_PARTY_HITS"] = true, + ["CHAT_MSG_COMBAT_FRIENDLYPLAYER_HITS"] = true, + ["CHAT_MSG_COMBAT_CREATURE_VS_CREATURE_HITS"] = true, + ["CHAT_MSG_COMBAT_SELF_RANGED_HITS"] = true, + ["CHAT_MSG_COMBAT_PARTY_RANGED_HITS"] = true, +} + +NanamiPlates.STUN_EFFECTS = { + "Cheap Shot", "Kidney Shot", "Bash", "Hammer of Justice", + "Charge Stun", "Intercept Stun", "Concussion Blow", + "Gouge", "Sap", "Pounce" +} + +NanamiPlates.REMOVE_PENDING_PATTERNS = { + SPELLIMMUNESELFOTHER or "%s is immune to your %s.", + IMMUNEDAMAGECLASSSELFOTHER or "%s is immune to your %s damage.", + SPELLMISSSELFOTHER or "Your %s missed %s.", + SPELLRESISTSELFOTHER or "Your %s was resisted by %s.", + SPELLEVADEDSELFOTHER or "Your %s was evaded by %s.", + SPELLDODGEDSELFOTHER or "Your %s was dodged by %s.", + SPELLDEFLECTEDSELFOTHER or "Your %s was deflected by %s.", + SPELLREFLECTSELFOTHER or "Your %s was reflected back by %s.", + SPELLPARRIEDSELFOTHER or "Your %s was parried by %s.", + SPELLLOGABSORBSELFOTHER or "Your %s is absorbed by %s.", +} + +NanamiPlates.TANK_CLASSES = { + ["Warrior"] = true, + ["Paladin"] = true, + ["Druid"] = true, + ["Shaman"] = true, +} + +if DEFAULT_CHAT_FRAME then + DEFAULT_CHAT_FRAME:AddMessage("|cffff88cc[Nanami-Plates]|r Loading...") +end diff --git a/Healthbar.lua b/Healthbar.lua new file mode 100644 index 0000000..82cb16c --- /dev/null +++ b/Healthbar.lua @@ -0,0 +1,153 @@ +NanamiPlates_Healthbar = {} + +local NP = NanamiPlates +local NEUTRAL_COLOR = {0.9, 0.7, 0.0, 1} +local HOSTILE_THRESHOLD = {r_min = 0.9, g_max = 0.2, b_max = 0.2} +local NEUTRAL_THRESHOLD = {r_min = 0.9, g_min = 0.9, b_max = 0.2} + +function NanamiPlates_Healthbar.ResetCache(nameplate) + if not nameplate then return end + nameplate.cachedIsFriendly = nil + nameplate.cachedIsHostile = nil + nameplate.cachedIsNeutral = nil + nameplate.wasNeutral = nil + nameplate.lastPlateName = nil + nameplate.lastColorR = nil + nameplate.lastColorG = nil + nameplate.lastColorB = nil +end + +function NanamiPlates_Healthbar.DetectUnitType(nameplate, original) + if not nameplate or not original or not original.healthbar then + return false, false, true, 0, 1, 0 + end + + local r, g, b = original.healthbar:GetStatusBarColor() + + -- Guard: if color is (0,0,0) the engine hasn't set it yet, use previous cache or default hostile + if r == 0 and g == 0 and b == 0 then + if nameplate.cachedIsHostile ~= nil then + return nameplate.cachedIsHostile, nameplate.cachedIsNeutral, nameplate.cachedIsFriendly, r, g, b + end + return true, false, false, 1, 0, 0 + end + + local isHostile, isNeutral, isFriendly + + local lastR, lastG, lastB = nameplate.lastColorR, nameplate.lastColorG, nameplate.lastColorB + + if r == lastR and g == lastG and b == lastB then + isHostile = nameplate.cachedIsHostile + isNeutral = nameplate.cachedIsNeutral + isFriendly = nameplate.cachedIsFriendly + else + isHostile = r > HOSTILE_THRESHOLD.r_min and g < HOSTILE_THRESHOLD.g_max and b < HOSTILE_THRESHOLD.b_max + isNeutral = r > NEUTRAL_THRESHOLD.r_min and g > NEUTRAL_THRESHOLD.g_min and b < NEUTRAL_THRESHOLD.b_max + isFriendly = not isHostile and not isNeutral + + nameplate.lastColorR = r + nameplate.lastColorG = g + nameplate.lastColorB = b + nameplate.cachedIsHostile = isHostile + nameplate.cachedIsNeutral = isNeutral + nameplate.cachedIsFriendly = isFriendly + + if nameplate.wasNeutral == nil then + nameplate.wasNeutral = isNeutral + end + end + + return isHostile, isNeutral, isFriendly, r, g, b +end + +function NanamiPlates_Healthbar.CheckUnitChange(nameplate, plateName, isNeutral) + if not nameplate then return end + if plateName and plateName ~= nameplate.lastPlateName then + nameplate.lastPlateName = plateName + nameplate.wasNeutral = isNeutral + end +end + +function NanamiPlates_Healthbar.IsNeutral(nameplate) + if not nameplate then return false end + return nameplate.cachedIsNeutral or nameplate.wasNeutral or false +end + +function NanamiPlates_Healthbar.WasNeutral(nameplate) + if not nameplate then return false end + return nameplate.wasNeutral or false +end + +function NanamiPlates_Healthbar.IsFriendly(nameplate) + if not nameplate then return false end + return nameplate.cachedIsFriendly or false +end + +function NanamiPlates_Healthbar.IsHostile(nameplate) + if not nameplate then return false end + return nameplate.cachedIsHostile or false +end + +function NanamiPlates_Healthbar.GetNeutralColor() + return NEUTRAL_COLOR[1], NEUTRAL_COLOR[2], NEUTRAL_COLOR[3], NEUTRAL_COLOR[4] +end + +function NanamiPlates_Healthbar.ApplyNeutralColor(nameplate) + if not nameplate or not nameplate.health then return end + nameplate.health:SetStatusBarColor(NEUTRAL_COLOR[1], NEUTRAL_COLOR[2], NEUTRAL_COLOR[3], NEUTRAL_COLOR[4]) +end + +function NanamiPlates_Healthbar.ShouldShowNeutral(nameplate, isNeutral, isAttackingPlayer) + if not nameplate then return false end + return (isNeutral or nameplate.wasNeutral) and not isAttackingPlayer +end + +function NanamiPlates_Healthbar.IsCritter(frame, nameplate, original, unitstr) + if unitstr and UnitCreatureType then + local creatureType = UnitCreatureType(unitstr) + if creatureType == "Critter" then return true end + end + + if NP and NP.Critters then + local plateName = nil + if original and original.name and original.name.GetText then + plateName = original.name:GetText() + end + if not plateName and nameplate and nameplate.name and nameplate.name.GetText then + plateName = nameplate.name:GetText() + end + if plateName and NP.Critters[string.lower(plateName)] then + return true + end + end + + if not unitstr and original and original.healthbar then + local r, g, b = original.healthbar:GetStatusBarColor() + local isNeutralColor = r > 0.9 and g > 0.9 and b < 0.2 + if isNeutralColor then + local levelText = nil + if original.level and original.level.GetText then + levelText = original.level:GetText() + end + local _, hpmax = original.healthbar:GetMinMaxValues() + if levelText == "1" and hpmax and hpmax > 0 and hpmax < 100 then + return true + end + end + end + + return false +end + +function NanamiPlates_Healthbar.ShouldSkipNameplate(frame, nameplate, original, Settings) + if not Settings or Settings.showCritterNameplates then + return false + end + local unitstr = nil + if NP and NP.superwow_active and frame and frame.GetName then + unitstr = frame:GetName(1) + end + return NanamiPlates_Healthbar.IsCritter(frame, nameplate, original, unitstr) +end + +NanamiPlates.Healthbar = NanamiPlates_Healthbar diff --git a/Nanami-Plates.toc b/Nanami-Plates.toc new file mode 100644 index 0000000..74fcac1 --- /dev/null +++ b/Nanami-Plates.toc @@ -0,0 +1,22 @@ +## Interface: 11200 +## Title: Nanami-Plates +## Notes: Nanami-UI style nameplates for Turtle WoW +## Author: Nanami +## Version: 1.0.0 +## Dependencies: Nanami-UI +## OptionalDeps: TWThreat +## SavedVariables: NanamiPlatesDB + +Core.lua +Config.lua +SpellDB.lua +Scanner.lua +Healthbar.lua +Castbar.lua +Auras.lua +ComboPoints.lua +Threat.lua +Target.lua +Plates.lua +CombatLog.lua +Options.lua diff --git a/Options.lua b/Options.lua new file mode 100644 index 0000000..ba65c30 --- /dev/null +++ b/Options.lua @@ -0,0 +1,1072 @@ +local NP = NanamiPlates +local Settings = NP.Settings + +local optionsFrame = nil +local minimapButton = nil + +-- ============================================ +-- THEME HELPERS +-- ============================================ +local function C(key, fr, fg, fb, fa) + return NP.GetThemeColor(key, fr, fg, fb, fa or 1) +end + +local function MakeBackdrop(f, bgKey, brKey) + local bgR, bgG, bgB, bgA = C(bgKey or "panelBg", 0.10, 0.06, 0.10, 0.95) + local brR, brG, brB, brA = C(brKey or "panelBorder", 0.55, 0.30, 0.42, 0.9) + f:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 1, right = 1, top = 1, bottom = 1 } + }) + f:SetBackdropColor(bgR, bgG, bgB, bgA) + f:SetBackdropBorderColor(brR, brG, brB, brA) +end + +local function SectionLabel(parent, text, x, y) + local lbl = parent:CreateFontString(nil, "OVERLAY") + lbl:SetFont(NP.GetFont(), 11, NP.GetFontOutline()) + lbl:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + local acR, acG, acB = C("accent", 1.0, 0.5, 0.8, 1) + lbl:SetTextColor(acR, acG, acB, 0.85) + lbl:SetText(text) + return lbl +end + +-- ============================================ +-- CHECKBOX +-- ============================================ +local checkIdx = 0 +local function CreateCheckbox(parent, label, settingKey, x, y, tooltip) + checkIdx = checkIdx + 1 + local size = 16 + local frame = CreateFrame("Button", "NP_Chk" .. checkIdx, parent) + frame:SetWidth(size) + frame:SetHeight(size) + frame:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + + local cbBgR, cbBgG, cbBgB = C("checkBg", 0.08, 0.04, 0.08, 1) + local cbBrR, cbBrG, cbBrB = C("checkBorder", 0.45, 0.25, 0.38, 1) + frame:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 } + }) + frame:SetBackdropColor(cbBgR, cbBgG, cbBgB, 1) + frame:SetBackdropBorderColor(cbBrR, cbBrG, cbBrB, 1) + + local mark = frame:CreateTexture(nil, "OVERLAY") + mark:SetTexture("Interface\\Buttons\\WHITE8X8") + mark:SetPoint("TOPLEFT", frame, "TOPLEFT", 3, -3) + mark:SetPoint("BOTTOMRIGHT", frame, "BOTTOMRIGHT", -3, 3) + local fillR, fillG, fillB = C("checkOn", 0.9, 0.45, 0.7, 1) + mark:SetVertexColor(fillR, fillG, fillB, 1) + + local text = frame:CreateFontString(nil, "OVERLAY") + text:SetFont(NP.GetFont(), 10, NP.GetFontOutline()) + text:SetPoint("LEFT", frame, "RIGHT", 6, 0) + local txtR, txtG, txtB = C("text", 0.85, 0.85, 0.85, 1) + text:SetTextColor(txtR, txtG, txtB) + text:SetText(label) + + local function Upd() + if Settings[settingKey] then + mark:Show() + local onR, onG, onB = C("checkOn", 0.9, 0.45, 0.7, 1) + frame:SetBackdropBorderColor(onR, onG, onB, 1) + else + mark:Hide() + frame:SetBackdropBorderColor(cbBrR, cbBrG, cbBrB, 1) + end + end + Upd() + + frame:SetScript("OnClick", function() + Settings[settingKey] = not Settings[settingKey] + NP:SaveSettings() + Upd() + end) + local hvR, hvG, hvB = C("checkHoverBorder", 0.75, 0.40, 0.60, 1) + frame:SetScript("OnEnter", function() + if not Settings[settingKey] then frame:SetBackdropBorderColor(hvR, hvG, hvB, 1) end + if tooltip and GameTooltip then + GameTooltip:SetOwner(frame, "ANCHOR_RIGHT") + GameTooltip:SetText(tooltip, 1, 1, 1, 1, true) + GameTooltip:Show() + end + end) + frame:SetScript("OnLeave", function() + Upd() + if GameTooltip then GameTooltip:Hide() end + end) + return frame +end + +-- ============================================ +-- SLIDER (with inline editable value) +-- ============================================ +local sliderIdx = 0 +local function CreateSlider(parent, label, settingKey, minVal, maxVal, step, x, y, fmt, onChange) + sliderIdx = sliderIdx + 1 + fmt = fmt or "%d" + + local EDIT_W = 42 + local ctr = CreateFrame("Frame", nil, parent) + ctr:SetWidth(220) + ctr:SetHeight(32) + ctr:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + + local nameT = ctr:CreateFontString(nil, "OVERLAY") + nameT:SetFont(NP.GetFont(), 10, NP.GetFontOutline()) + nameT:SetPoint("TOPLEFT", ctr, "TOPLEFT", 0, 0) + local lblR, lblG, lblB = C("labelText", 0.75, 0.75, 0.75, 1) + nameT:SetTextColor(lblR, lblG, lblB) + + -- Value display (click to edit) + local valT = ctr:CreateFontString(nil, "OVERLAY") + valT:SetFont(NP.GetFont(), 10, NP.GetFontOutline()) + valT:SetPoint("TOPRIGHT", ctr, "TOPRIGHT", 0, 0) + local valR, valG, valB = C("valueText", 0.9, 0.9, 0.9, 1) + valT:SetTextColor(valR, valG, valB) + + -- EditBox for manual input (hidden by default) + local editBox = CreateFrame("EditBox", "NP_SliderEdit" .. sliderIdx, ctr) + editBox:SetWidth(EDIT_W) + editBox:SetHeight(14) + editBox:SetPoint("TOPRIGHT", ctr, "TOPRIGHT", 2, 2) + editBox:SetFont(NP.GetFont(), 10, NP.GetFontOutline()) + editBox:SetTextColor(1, 1, 1, 1) + editBox:SetJustifyH("RIGHT") + editBox:SetAutoFocus(false) + editBox:SetMaxLetters(8) + local eBgR, eBgG, eBgB = C("panelBg", 0.12, 0.08, 0.12, 1) + local eBrR, eBrG, eBrB = C("accent", 1.0, 0.5, 0.8, 1) + editBox:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 2, right = 2, top = 0, bottom = 0 } + }) + editBox:SetBackdropColor(eBgR, eBgG, eBgB, 1) + editBox:SetBackdropBorderColor(eBrR, eBrG, eBrB, 0.9) + editBox:Hide() + + -- Click region over value text to open editbox + local valBtn = CreateFrame("Button", nil, ctr) + valBtn:SetWidth(EDIT_W + 4) + valBtn:SetHeight(14) + valBtn:SetPoint("TOPRIGHT", ctr, "TOPRIGHT", 2, 2) + valBtn:SetFrameLevel(ctr:GetFrameLevel() + 1) + + local trkH = 6 + local track = CreateFrame("Frame", nil, ctr) + track:SetHeight(trkH) + track:SetPoint("TOPLEFT", ctr, "TOPLEFT", 0, -14) + track:SetPoint("TOPRIGHT", ctr, "TOPRIGHT", 0, -14) + local trkR, trkG, trkB = C("sliderTrack", 0.12, 0.08, 0.12, 1) + track:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 } + }) + track:SetBackdropColor(trkR, trkG, trkB, 1) + local tBrR, tBrG, tBrB = C("panelBorder", 0.35, 0.20, 0.30, 1) + track:SetBackdropBorderColor(tBrR, tBrG, tBrB, 0.5) + + local fill = track:CreateTexture(nil, "ARTWORK") + fill:SetTexture("Interface\\Buttons\\WHITE8X8") + fill:SetPoint("TOPLEFT", track, "TOPLEFT", 1, -1) + fill:SetHeight(trkH - 2) + local fR, fG, fB = C("sliderFill", 0.7, 0.35, 0.55, 1) + fill:SetVertexColor(fR, fG, fB, 0.9) + + local thumb = CreateFrame("Frame", nil, track) + thumb:SetWidth(12) + thumb:SetHeight(12) + thumb:SetFrameLevel(track:GetFrameLevel() + 2) + local thR, thG, thB = C("sliderThumb", 0.9, 0.55, 0.75, 1) + thumb:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 } + }) + thumb:SetBackdropColor(thR, thG, thB, 1) + local thBR, thBG, thBB = C("panelBorder", 0.55, 0.30, 0.42, 1) + thumb:SetBackdropBorderColor(thBR, thBG, thBB, 1) + + local function Norm(v) if maxVal == minVal then return 0 end return (v - minVal) / (maxVal - minVal) end + + local function FormatValue(v) + if fmt == "pct" then + return math.floor(v * 100 + 0.5) .. "%" + else + return string.format(fmt, v) + end + end + + local function Refresh(v) + v = math.floor(v / step + 0.5) * step + if v < minVal then v = minVal end + if v > maxVal then v = maxVal end + nameT:SetText(label) + valT:SetText(FormatValue(v)) + local n = Norm(v) + local tw = track:GetWidth() + if tw <= 0 then tw = 220 end + fill:SetWidth(math.max(1, n * (tw - 2))) + thumb:ClearAllPoints() + thumb:SetPoint("CENTER", track, "LEFT", 1 + n * (tw - 2), 0) + end + + local function ApplyValue(v) + v = math.floor(v / step + 0.5) * step + if v < minVal then v = minVal end + if v > maxVal then v = maxVal end + Settings[settingKey] = v + NP:SaveSettings() + Refresh(v) + if onChange then onChange(v) end + end + + -- EditBox: show on click, commit on Enter/Tab, cancel on Escape + valBtn:SetScript("OnClick", function() + local cur = Settings[settingKey] or minVal + local rawVal + if fmt == "pct" then + rawVal = tostring(math.floor(cur * 100 + 0.5)) + else + rawVal = string.format(fmt, cur) + end + editBox:SetText(rawVal) + valT:Hide() + valBtn:Hide() + editBox:Show() + editBox:SetFocus() + editBox:HighlightText() + end) + local hvUlR, hvUlG, hvUlB = C("accent", 1.0, 0.5, 0.8, 1) + valBtn:SetScript("OnEnter", function() valT:SetTextColor(hvUlR, hvUlG, hvUlB) end) + valBtn:SetScript("OnLeave", function() valT:SetTextColor(valR, valG, valB) end) + + local function CommitEdit() + local txt = editBox:GetText() + editBox:Hide() + valT:Show() + valBtn:Show() + local num = tonumber(txt) + if not num then + Refresh(Settings[settingKey] or minVal) + return + end + if fmt == "pct" then num = num / 100 end + ApplyValue(num) + end + + editBox:SetScript("OnEnterPressed", function() CommitEdit() end) + editBox:SetScript("OnTabPressed", function() CommitEdit() end) + editBox:SetScript("OnEscapePressed", function() + editBox:Hide() + valT:Show() + valBtn:Show() + Refresh(Settings[settingKey] or minVal) + end) + + track:EnableMouse(true) + local function HandleMouse() + local tw = track:GetWidth() + if tw <= 0 then return end + local mx = GetCursorPosition() / track:GetEffectiveScale() + local n = (mx - track:GetLeft()) / tw + if n < 0 then n = 0 end + if n > 1 then n = 1 end + local v = math.floor((minVal + n * (maxVal - minVal)) / step + 0.5) * step + ApplyValue(v) + end + track:SetScript("OnMouseDown", function() HandleMouse(); this.drag = true end) + track:SetScript("OnMouseUp", function() this.drag = false end) + track:SetScript("OnUpdate", function() if this.drag then HandleMouse() end end) + + ctr:SetScript("OnShow", function() Refresh(Settings[settingKey] or minVal) end) + ctr.init = CreateFrame("Frame", nil, ctr) + ctr.init:SetScript("OnUpdate", function() + Refresh(Settings[settingKey] or minVal) + this:SetScript("OnUpdate", nil) + end) + return ctr +end + +-- ============================================ +-- CYCLE BUTTON +-- ============================================ +local cycleIdx = 0 +local function CreateCycleButton(parent, label, settingKey, options, x, y) + cycleIdx = cycleIdx + 1 + local frame = CreateFrame("Button", "NP_Cyc" .. cycleIdx, parent) + frame:SetWidth(220) + frame:SetHeight(20) + frame:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + + local nameT = frame:CreateFontString(nil, "OVERLAY") + nameT:SetFont(NP.GetFont(), 10, NP.GetFontOutline()) + nameT:SetPoint("LEFT", frame, "LEFT", 0, 0) + local lblR, lblG, lblB = C("labelText", 0.75, 0.75, 0.75, 1) + nameT:SetTextColor(lblR, lblG, lblB) + nameT:SetText(label) + + local btn = CreateFrame("Button", nil, frame) + btn:SetWidth(110) + btn:SetHeight(18) + btn:SetPoint("RIGHT", frame, "RIGHT", 0, 0) + MakeBackdrop(btn, "buttonBg", "sepColor") + + local btnT = btn:CreateFontString(nil, "OVERLAY") + btnT:SetFont(NP.GetFont(), 9, NP.GetFontOutline()) + btnT:SetPoint("CENTER", btn, "CENTER", 0, 0) + local bTR, bTG, bTB = C("buttonText", 0.85, 0.85, 0.85, 1) + btnT:SetTextColor(bTR, bTG, bTB) + + local function GetLbl(v) + for _, o in ipairs(options) do if o.value == v then return o.label end end + return tostring(v) + end + local function Upd() btnT:SetText(GetLbl(Settings[settingKey])) end + Upd() + + btn:SetScript("OnClick", function() + local cur = Settings[settingKey] + local ni = 1 + for i, o in ipairs(options) do + if o.value == cur then ni = i + 1; if ni > table.getn(options) then ni = 1 end; break end + end + Settings[settingKey] = options[ni].value + NP:SaveSettings() + Upd() + end) + local hBR, hBG, hBB = C("buttonHoverBg", 0.20, 0.12, 0.18, 1) + local hAR, hAG, hAB = C("accent", 1.0, 0.5, 0.8, 1) + btn:SetScript("OnEnter", function() + btn:SetBackdropColor(hBR, hBG, hBB, 1) + btn:SetBackdropBorderColor(hAR, hAG, hAB, 0.8) + btnT:SetTextColor(1, 1, 1) + end) + btn:SetScript("OnLeave", function() + MakeBackdrop(btn, "buttonBg", "sepColor") + btnT:SetTextColor(bTR, bTG, bTB) + end) + return frame +end + +-- ============================================ +-- ROLE TOGGLE +-- ============================================ +local function CreateRoleButton(parent, x, y) + local SW = 220 + local SH = 26 + local HALF = SW / 2 + + local frame = CreateFrame("Frame", "NP_Role", parent) + frame:SetWidth(SW) + frame:SetHeight(SH) + frame:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + + local bgR, bgG, bgB = C("panelBg", 0.08, 0.04, 0.08, 1) + local brR, brG, brB = C("panelBorder", 0.45, 0.25, 0.38, 1) + frame:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 } + }) + frame:SetBackdropColor(bgR, bgG, bgB, 1) + frame:SetBackdropBorderColor(brR, brG, brB, 0.8) + + local btnTank = CreateFrame("Button", nil, frame) + btnTank:SetWidth(HALF - 2) + btnTank:SetHeight(SH - 4) + btnTank:SetPoint("LEFT", frame, "LEFT", 2, 0) + btnTank:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 } + }) + local tankT = btnTank:CreateFontString(nil, "OVERLAY") + tankT:SetFont(NP.GetFont(), 10, NP.GetFontOutline()) + tankT:SetPoint("CENTER", btnTank, "CENTER", 0, 0) + tankT:SetText("坦克") + + local btnDPS = CreateFrame("Button", nil, frame) + btnDPS:SetWidth(HALF - 2) + btnDPS:SetHeight(SH - 4) + btnDPS:SetPoint("RIGHT", frame, "RIGHT", -2, 0) + btnDPS:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 } + }) + local dpsT = btnDPS:CreateFontString(nil, "OVERLAY") + dpsT:SetFont(NP.GetFont(), 10, NP.GetFontOutline()) + dpsT:SetPoint("CENTER", btnDPS, "CENTER", 0, 0) + dpsT:SetText("输出") + + local function Upd() + if (NP.playerRole or "DPS") == "TANK" then + btnTank:SetBackdropColor(0.15, 0.45, 0.15, 1) + btnTank:SetBackdropBorderColor(0.3, 0.8, 0.3, 0.9) + tankT:SetTextColor(0.4, 1.0, 0.4) + btnDPS:SetBackdropColor(bgR, bgG, bgB, 0.5) + btnDPS:SetBackdropBorderColor(brR, brG, brB, 0.3) + dpsT:SetTextColor(0.5, 0.5, 0.5) + else + btnTank:SetBackdropColor(bgR, bgG, bgB, 0.5) + btnTank:SetBackdropBorderColor(brR, brG, brB, 0.3) + tankT:SetTextColor(0.5, 0.5, 0.5) + btnDPS:SetBackdropColor(0.45, 0.12, 0.12, 1) + btnDPS:SetBackdropBorderColor(0.8, 0.3, 0.3, 0.9) + dpsT:SetTextColor(1.0, 0.4, 0.4) + end + end + Upd() + + local function SetRole(role) + NP.playerRole = role + NP:SaveSettings() + Upd() + if NanamiPlates_Threat then NanamiPlates_Threat.BroadcastTankMode(true) end + NP.Print("角色: " .. role) + end + + btnTank:SetScript("OnClick", function() SetRole("TANK") end) + btnDPS:SetScript("OnClick", function() SetRole("DPS") end) + + return frame +end + +-- ============================================ +-- FORMAT OPTIONS +-- ============================================ +local hpFmtOpts = { + { value = 0, label = "隐藏" }, + { value = 1, label = "百分比" }, + { value = 2, label = "当前值" }, + { value = 3, label = "当前(百分比)" }, + { value = 4, label = "当前/最大" }, + { value = 5, label = "当前/最大 %" }, +} + +-- ============================================ +-- TAB DEFINITIONS +-- ============================================ +local TAB_DEFS = { + { id = "hp", label = "血条" }, + { id = "cast", label = "施法条" }, + { id = "debuff", label = "减益" }, + { id = "target", label = "目标" }, + { id = "other", label = "其他" }, +} + +-- ============================================ +-- TAB CONTENT BUILDERS +-- ============================================ +local function BuildTab_HP(page) + local L = 20 + local R = 260 + local y = -10 + + SectionLabel(page, "-- 敌方 --", L, y) + y = y - 20 + CreateSlider(page, "宽度", "healthbarWidth", 60, 220, 5, L, y) + CreateSlider(page, "高度", "healthbarHeight", 4, 30, 1, R, y) + y = y - 38 + CreateSlider(page, "血量字号", "healthFontSize", 6, 18, 1, L, y) + CreateCycleButton(page, "血量格式", "healthTextFormat", hpFmtOpts, R, y + 6) + y = y - 38 + CreateSlider(page, "名字字号", "nameFontSize", 6, 16, 1, L, y) + CreateSlider(page, "等级字号", "levelFontSize", 6, 16, 1, R, y) + y = y - 38 + CreateSlider(page, "垂直偏移", "nameplateYOffset", -20, 40, 1, L, y) + + y = y - 50 + SectionLabel(page, "-- 友方 --", L, y) + y = y - 20 + CreateSlider(page, "宽度", "friendHealthbarWidth", 40, 200, 5, L, y) + CreateSlider(page, "高度", "friendHealthbarHeight", 2, 20, 1, R, y) + y = y - 38 + CreateSlider(page, "血量字号", "friendHealthFontSize", 6, 16, 1, L, y) + CreateCycleButton(page, "血量格式", "friendHealthTextFormat", hpFmtOpts, R, y + 6) + y = y - 38 + CreateSlider(page, "名字字号", "friendNameFontSize", 6, 16, 1, L, y) + CreateSlider(page, "等级字号", "friendLevelFontSize", 6, 16, 1, R, y) +end + +local function BuildTab_Cast(page) + local L = 20 + local R = 260 + local y = -10 + + SectionLabel(page, "-- 敌方施法条 --", L, y) + y = y - 20 + CreateSlider(page, "宽度", "castbarWidth", 60, 220, 5, L, y) + CreateSlider(page, "高度", "castbarHeight", 4, 20, 1, R, y) + y = y - 36 + CreateCheckbox(page, "显示法术图标", "showCastbarIcon", L, y) + + y = y - 45 + SectionLabel(page, "-- 友方施法条 --", L, y) + y = y - 20 + CreateSlider(page, "宽度", "friendCastbarWidth", 40, 200, 5, L, y) + CreateSlider(page, "高度", "friendCastbarHeight", 2, 16, 1, R, y) + y = y - 36 + CreateCheckbox(page, "显示法术图标", "friendShowCastbarIcon", L, y) +end + +local function BuildTab_Debuff(page) + local L = 20 + local R = 260 + local y = -10 + + SectionLabel(page, "-- 减益效果 (Debuff) --", L, y) + y = y - 22 + CreateCheckbox(page, "显示减益计时器", "showDebuffTimers", L, y, "在减益图标上显示倒计时") + y = y - 26 + CreateCheckbox(page, "仅显示自己的减益", "showOnlyMyDebuffs", L, y, "只显示自己施放的减益效果") + y = y - 32 + CreateSlider(page, "图标大小", "debuffIconSize", 12, 40, 1, L, y) + + y = y - 50 + SectionLabel(page, "-- 连击点 (星) --", L, y) + y = y - 22 + CreateCheckbox(page, "显示连击点", "showComboPoints", L, y, "盗贼和德鲁伊(猫形态)的连击点显示") + y = y - 32 + CreateSlider(page, "连击点大小", "comboPointsSize", 6, 20, 1, L, y) +end + +local function CreateArrowStyleSelector(parent, x, y) + local ARROW_TEXTURE = "Interface\\AddOns\\Nanami-Plates\\img\\arrow" + local ARROW_TEXCOORDS = NP.ARROW_TEXCOORDS or { + {0, 0.5, 0, 0.5}, + {0.5, 1, 0, 0.5}, + {0, 0.5, 0.5, 1}, + {0.5, 1, 0.5, 1}, + } + + local PREVIEW_SIZE = 40 + local GAP = 8 + local NUM_ARROWS = 4 + + local frame = CreateFrame("Frame", nil, parent) + frame:SetWidth(NUM_ARROWS * (PREVIEW_SIZE + GAP)) + frame:SetHeight(PREVIEW_SIZE + 20) + frame:SetPoint("TOPLEFT", parent, "TOPLEFT", x, y) + + local lbl = frame:CreateFontString(nil, "OVERLAY") + lbl:SetFont(NP.GetFont(), 10, NP.GetFontOutline()) + lbl:SetPoint("TOPLEFT", frame, "TOPLEFT", 0, 0) + local lblR, lblG, lblB = C("labelText", 0.75, 0.75, 0.75, 1) + lbl:SetTextColor(lblR, lblG, lblB) + lbl:SetText("箭头样式") + + local buttons = {} + local acR, acG, acB = C("accent", 1.0, 0.5, 0.8, 1) + local brR, brG, brB = C("panelBorder", 0.35, 0.20, 0.30, 1) + + local function UpdateSelection() + local cur = Settings.targetArrowStyle or 1 + for i, btn in ipairs(buttons) do + if i == cur then + btn:SetBackdropBorderColor(acR, acG, acB, 1) + else + btn:SetBackdropBorderColor(brR, brG, brB, 0.5) + end + end + end + + for i = 1, NUM_ARROWS do + local btn = CreateFrame("Button", nil, frame) + btn:SetWidth(PREVIEW_SIZE) + btn:SetHeight(PREVIEW_SIZE) + btn:SetPoint("TOPLEFT", frame, "TOPLEFT", (i - 1) * (PREVIEW_SIZE + GAP), -16) + btn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 } + }) + local bgR, bgG, bgB = C("panelBg", 0.08, 0.04, 0.08, 1) + btn:SetBackdropColor(bgR, bgG, bgB, 1) + + local tex = btn:CreateTexture(nil, "ARTWORK") + tex:SetTexture(ARROW_TEXTURE) + tex:SetPoint("TOPLEFT", btn, "TOPLEFT", 3, -3) + tex:SetPoint("BOTTOMRIGHT", btn, "BOTTOMRIGHT", -3, 3) + local coords = ARROW_TEXCOORDS[i] + tex:SetTexCoord(coords[1], coords[2], coords[3], coords[4]) + tex:SetVertexColor(acR, acG, acB, 1) + + btn.arrowIdx = i + btn:SetScript("OnClick", function() + Settings.targetArrowStyle = this.arrowIdx + NP:SaveSettings() + UpdateSelection() + if NP.UpdateAllArrows then NP.UpdateAllArrows() end + end) + local hvR, hvG, hvB = C("checkHoverBorder", 0.75, 0.40, 0.60, 1) + btn:SetScript("OnEnter", function() + if Settings.targetArrowStyle ~= this.arrowIdx then + this:SetBackdropBorderColor(hvR, hvG, hvB, 1) + end + end) + btn:SetScript("OnLeave", function() + UpdateSelection() + end) + + table.insert(buttons, btn) + end + + UpdateSelection() + return frame +end + +local function BuildTab_Target(page) + local L = 20 + local R = 260 + local y = -10 + + SectionLabel(page, "-- 目标指示 --", L, y) + y = y - 22 + CreateCheckbox(page, "显示目标箭头 >> <<", "showTargetGlow", L, y, "在当前目标姓名板两侧显示箭头") + y = y - 32 + CreateArrowStyleSelector(page, L, y) + y = y - 68 + CreateSlider(page, "箭头大小", "targetArrowSize", 12, 48, 2, L, y, nil, function() + if NP.UpdateAllArrows then NP.UpdateAllArrows() end + end) + CreateSlider(page, "箭头偏移", "targetArrowOffset", -20, 20, 1, R, y, nil, function() + if NP.UpdateAllArrows then NP.UpdateAllArrows() end + end) + y = y - 38 + CreateSlider(page, "箭头染色", "targetArrowTint", 0, 1.0, 0.05, L, y, "pct") + CreateSlider(page, "非目标透明度", "nonTargetAlpha", 0.1, 1.0, 0.05, R, y, "pct") + + y = y - 50 + SectionLabel(page, "-- 仇恨与角色 --", L, y) + y = y - 26 + CreateRoleButton(page, L, y) +end + +local function BuildTab_Other(page) + local L = 20 + local y = -10 + + SectionLabel(page, "-- 杂项 --", L, y) + y = y - 22 + CreateCheckbox(page, "显示小动物姓名板", "showCritterNameplates", L, y, "显示兔子、松鼠等小动物的姓名板") + y = y - 26 + CreateCheckbox(page, "显示法力条", "showManaBar", L, y) + y = y - 26 + CreateCheckbox(page, "显示任务怪图标", "showQuestIcon", L, y, "在任务目标怪物的姓名板上显示\"!\"图标和进度 (配合pfQuest)") + + y = y - 50 + + local resetBtn = CreateFrame("Button", "NP_ResetBtn", page) + resetBtn:SetWidth(220) + resetBtn:SetHeight(26) + resetBtn:SetPoint("TOPLEFT", page, "TOPLEFT", L, y) + local rBgR, rBgG, rBgB = C("buttonBg", 0.18, 0.10, 0.15, 1) + local rBrR, rBrG, rBrB = C("sepColor", 0.45, 0.25, 0.38, 1) + resetBtn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 } + }) + resetBtn:SetBackdropColor(rBgR, rBgG, rBgB, 0.94) + resetBtn:SetBackdropBorderColor(rBrR, rBrG, rBrB, 1) + + local rT = resetBtn:CreateFontString(nil, "OVERLAY") + rT:SetFont(NP.GetFont(), 10, NP.GetFontOutline()) + rT:SetPoint("CENTER", resetBtn, "CENTER", 0, 0) + rT:SetText("重置所有设置 (重载UI)") + rT:SetTextColor(0.9, 0.4, 0.4) + + resetBtn:SetScript("OnClick", function() NanamiPlatesDB = nil; ReloadUI() end) + resetBtn:SetScript("OnEnter", function() + resetBtn:SetBackdropBorderColor(1, 0.3, 0.3, 1) + rT:SetTextColor(1, 0.5, 0.5) + end) + resetBtn:SetScript("OnLeave", function() + resetBtn:SetBackdropBorderColor(rBrR, rBrG, rBrB, 1) + rT:SetTextColor(0.9, 0.4, 0.4) + end) +end + +local TAB_BUILDERS = { + hp = BuildTab_HP, + cast = BuildTab_Cast, + debuff = BuildTab_Debuff, + target = BuildTab_Target, + other = BuildTab_Other, +} + +-- ============================================ +-- MAIN OPTIONS FRAME +-- ============================================ +local function CreateOptionsFrame() + if optionsFrame then + if optionsFrame:IsShown() then optionsFrame:Hide() else optionsFrame:Show() end + return + end + + local W = 540 + local H = 440 + local TAB_H = 28 + local HEADER_H = 34 + + local f = CreateFrame("Frame", "NanamiPlatesOptions", UIParent) + f:SetWidth(W) + f:SetHeight(H) + f:SetPoint("CENTER", UIParent, "CENTER", 0, 50) + f:SetMovable(true) + f:EnableMouse(false) + f:SetFrameStrata("DIALOG") + f:SetToplevel(true) + f:SetClampedToScreen(true) + MakeBackdrop(f) + + -- Title bar (only this area is draggable) + local header = CreateFrame("Frame", nil, f) + header:SetHeight(HEADER_H) + header:SetPoint("TOPLEFT", f, "TOPLEFT", 1, -1) + header:SetPoint("TOPRIGHT", f, "TOPRIGHT", -1, -1) + header:EnableMouse(true) + header:RegisterForDrag("LeftButton") + header:SetScript("OnDragStart", function() f:StartMoving() end) + header:SetScript("OnDragStop", function() f:StopMovingOrSizing() end) + local hR, hG, hB = C("headerBg", 0.14, 0.08, 0.12, 1) + header:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) + header:SetBackdropColor(hR, hG, hB, 0.95) + + local acR, acG, acB = C("accent", 1.0, 0.5, 0.8, 1) + + -- Logo icon + local logoIcon = header:CreateTexture(nil, "OVERLAY") + logoIcon:SetTexture("Interface\\AddOns\\Nanami-Plates\\img\\icon") + logoIcon:SetWidth(16) + logoIcon:SetHeight(16) + logoIcon:SetPoint("LEFT", header, "LEFT", 10, 0) + logoIcon:SetVertexColor(acR, acG, acB, 0.9) + + local title = header:CreateFontString(nil, "OVERLAY") + title:SetFont(NP.GetFont(), 13, NP.GetFontOutline()) + title:SetPoint("LEFT", logoIcon, "RIGHT", 6, 0) + title:SetTextColor(acR, acG, acB) + title:SetText("Nanami-Plates") + + local verText = header:CreateFontString(nil, "OVERLAY") + verText:SetFont(NP.GetFont(), 9, NP.GetFontOutline()) + verText:SetPoint("LEFT", title, "RIGHT", 8, 0) + verText:SetTextColor(0.5, 0.5, 0.5, 0.7) + verText:SetText("v1.0") + + -- Close button with backdrop + local closeBtn = CreateFrame("Button", nil, header) + closeBtn:SetWidth(22) + closeBtn:SetHeight(22) + closeBtn:SetPoint("RIGHT", header, "RIGHT", -6, 0) + closeBtn:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 } + }) + closeBtn:SetBackdropColor(0.15, 0.08, 0.12, 0.6) + closeBtn:SetBackdropBorderColor(0.35, 0.20, 0.30, 0.5) + local cT = closeBtn:CreateFontString(nil, "OVERLAY") + cT:SetFont(NP.GetFont(), 13, NP.GetFontOutline()) + cT:SetPoint("CENTER", closeBtn, "CENTER", 0, 0) + cT:SetText("x") + cT:SetTextColor(0.7, 0.4, 0.5) + closeBtn:SetScript("OnClick", function() f:Hide() end) + closeBtn:SetScript("OnEnter", function() + cT:SetTextColor(1, 0.3, 0.4) + closeBtn:SetBackdropColor(0.3, 0.08, 0.08, 0.8) + closeBtn:SetBackdropBorderColor(0.8, 0.25, 0.25, 0.8) + end) + closeBtn:SetScript("OnLeave", function() + cT:SetTextColor(0.7, 0.4, 0.5) + closeBtn:SetBackdropColor(0.15, 0.08, 0.12, 0.6) + closeBtn:SetBackdropBorderColor(0.35, 0.20, 0.30, 0.5) + end) + + -- Header divider + local headerDiv = f:CreateTexture(nil, "ARTWORK") + headerDiv:SetTexture("Interface\\Buttons\\WHITE8X8") + headerDiv:SetHeight(1) + headerDiv:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, 0) + headerDiv:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, 0) + headerDiv:SetVertexColor(acR, acG, acB, 0.3) + + -- Tab bar + local tabBar = CreateFrame("Frame", nil, f) + tabBar:SetHeight(TAB_H) + tabBar:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 0, -1) + tabBar:SetPoint("TOPRIGHT", header, "BOTTOMRIGHT", 0, -1) + local tbBgR, tbBgG, tbBgB = C("sectionBg", 0.08, 0.05, 0.08, 1) + tabBar:SetBackdrop({ bgFile = "Interface\\Buttons\\WHITE8X8" }) + tabBar:SetBackdropColor(tbBgR, tbBgG, tbBgB, 0.9) + + -- Content area + local content = CreateFrame("Frame", nil, f) + content:SetPoint("TOPLEFT", tabBar, "BOTTOMLEFT", 0, -1) + content:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -1, 1) + + -- Pages + local pages = {} + for _, def in ipairs(TAB_DEFS) do + local page = CreateFrame("Frame", nil, content) + page:SetAllPoints(content) + page:Hide() + if TAB_BUILDERS[def.id] then + TAB_BUILDERS[def.id](page) + end + pages[def.id] = page + end + + -- Tab buttons + local tabs = {} + local tabW = (W - 2) / table.getn(TAB_DEFS) + + local activeTabId = TAB_DEFS[1].id + + local tabBgNR, tabBgNG, tabBgNB = C("tabBg", 0.10, 0.06, 0.10, 1) + local tabBrNR, tabBrNG, tabBrNB = C("tabBorder", 0.35, 0.20, 0.30, 1) + local tabBgAR, tabBgAG, tabBgAB = C("tabActiveBg", 0.20, 0.12, 0.18, 1) + local tabBrAR, tabBrAG, tabBrAB = C("tabActiveBorder", 0.9, 0.45, 0.7, 1) + local tabTxtNR, tabTxtNG, tabTxtNB = C("tabText", 0.6, 0.6, 0.6, 1) + local tabTxtAR, tabTxtAG, tabTxtAB = C("tabActiveText", 1.0, 0.9, 0.95, 1) + + local function SetActiveTab(id) + activeTabId = id + for _, def in ipairs(TAB_DEFS) do + local tab = tabs[def.id] + local pg = pages[def.id] + if def.id == id then + pg:Show() + tab:SetBackdropColor(tabBgAR, tabBgAG, tabBgAB, 1) + tab:SetBackdropBorderColor(tabBrNR, tabBrNG, tabBrNB, 0.3) + tab.label:SetTextColor(tabTxtAR, tabTxtAG, tabTxtAB) + tab.indicator:Show() + else + pg:Hide() + tab:SetBackdropColor(tabBgNR, tabBgNG, tabBgNB, 1) + tab:SetBackdropBorderColor(tabBrNR, tabBrNG, tabBrNB, 0.3) + tab.label:SetTextColor(tabTxtNR, tabTxtNG, tabTxtNB) + tab.indicator:Hide() + end + end + end + + for i, def in ipairs(TAB_DEFS) do + local tabId = def.id + local tabLabel = def.label + + local tab = CreateFrame("Button", nil, tabBar) + tab:SetWidth(tabW) + tab:SetHeight(TAB_H) + tab:SetPoint("TOPLEFT", tabBar, "TOPLEFT", (i - 1) * tabW, 0) + tab:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Buttons\\WHITE8X8", + tile = false, tileSize = 0, edgeSize = 1, + insets = { left = 0, right = 0, top = 0, bottom = 0 } + }) + tab.tabId = tabId + + local lbl = tab:CreateFontString(nil, "OVERLAY") + lbl:SetFont(NP.GetFont(), 11, NP.GetFontOutline()) + lbl:SetPoint("CENTER", tab, "CENTER", 0, 0) + lbl:SetText(tabLabel) + tab.label = lbl + + -- Active tab bottom indicator line + local ind = tab:CreateTexture(nil, "OVERLAY") + ind:SetTexture("Interface\\Buttons\\WHITE8X8") + ind:SetHeight(2) + ind:SetPoint("BOTTOMLEFT", tab, "BOTTOMLEFT", 4, 1) + ind:SetPoint("BOTTOMRIGHT", tab, "BOTTOMRIGHT", -4, 1) + ind:SetVertexColor(tabBrAR, tabBrAG, tabBrAB, 1) + ind:Hide() + tab.indicator = ind + + tab:SetScript("OnClick", function() SetActiveTab(this.tabId) end) + tab:SetScript("OnEnter", function() + if activeTabId ~= this.tabId then + this:SetBackdropColor(tabBgAR * 0.7, tabBgAG * 0.7, tabBgAB * 0.7, 0.8) + this.label:SetTextColor(0.85, 0.85, 0.85) + end + end) + tab:SetScript("OnLeave", function() + if activeTabId ~= this.tabId then + this:SetBackdropColor(tabBgNR, tabBgNG, tabBgNB, 1) + this:SetBackdropBorderColor(tabBrNR, tabBrNG, tabBrNB, 0.3) + this.label:SetTextColor(tabTxtNR, tabTxtNG, tabTxtNB) + end + end) + + tabs[tabId] = tab + end + + SetActiveTab(TAB_DEFS[1].id) + + optionsFrame = f + tinsert(UISpecialFrames, "NanamiPlatesOptions") +end + +-- ============================================ +-- MINIMAP BUTTON +-- ============================================ +local function CreateMinimapButton() + if minimapButton then return end + + local btnSize = 33 + local btn = CreateFrame("Button", "NanamiPlatesMinimapBtn", Minimap) + btn:SetWidth(btnSize) + btn:SetHeight(btnSize) + btn:SetFrameStrata("MEDIUM") + btn:SetFrameLevel(Minimap:GetFrameLevel() + 5) + btn:SetMovable(true) + btn:EnableMouse(true) + btn:RegisterForClicks("LeftButtonUp", "RightButtonUp") + + local savedAngle = NanamiPlatesDB and NanamiPlatesDB.minimapAngle or 220 + + local function UpdatePos(angle) + local rad = math.rad(angle) + btn:ClearAllPoints() + btn:SetPoint("CENTER", Minimap, "CENTER", math.cos(rad) * 80, math.sin(rad) * 80) + end + UpdatePos(savedAngle) + btn.angle = savedAngle + + local icon = btn:CreateTexture(nil, "BACKGROUND") + icon:SetTexture("Interface\\AddOns\\Nanami-Plates\\img\\icon") + icon:SetWidth(20) + icon:SetHeight(20) + icon:SetPoint("CENTER", btn, "CENTER", 0, 0) + local iconR, iconG, iconB = C("accent", 1.0, 0.5, 0.8, 1) + icon:SetVertexColor(iconR, iconG, iconB, 1) + + local overlay = btn:CreateTexture(nil, "OVERLAY") + overlay:SetTexture("Interface\\Minimap\\MiniMap-TrackingBorder") + overlay:SetWidth(52) + overlay:SetHeight(52) + overlay:SetPoint("CENTER", btn, "CENTER", 10, -10) + + btn:SetHighlightTexture("Interface\\Minimap\\UI-Minimap-ZoomButton-Highlight") + + local isDrag = false + btn:RegisterForDrag("LeftButton") + btn:SetScript("OnDragStart", function() isDrag = true end) + btn:SetScript("OnDragStop", function() + isDrag = false + if not NanamiPlatesDB then NanamiPlatesDB = {} end + NanamiPlatesDB.minimapAngle = btn.angle + end) + btn:SetScript("OnUpdate", function() + if not isDrag then return end + local cx, cy = GetCursorPosition() + local s = Minimap:GetEffectiveScale() + local mx, my = Minimap:GetCenter() + local angle = math.deg(math.atan2(cy / s - my, cx / s - mx)) + btn.angle = angle + UpdatePos(angle) + end) + + btn:SetScript("OnClick", function() CreateOptionsFrame() end) + + btn:SetScript("OnEnter", function() + icon:SetVertexColor(1, 1, 1, 1) + GameTooltip:SetOwner(this, "ANCHOR_LEFT") + local aR, aG, aB = C("accent", 1.0, 0.5, 0.8, 1) + GameTooltip:SetText("Nanami-Plates 姓名板", aR, aG, aB) + GameTooltip:AddLine("左键点击打开设置", 0.8, 0.8, 0.8) + GameTooltip:AddLine("拖拽移动按钮位置", 0.6, 0.6, 0.6) + GameTooltip:Show() + end) + btn:SetScript("OnLeave", function() + icon:SetVertexColor(iconR, iconG, iconB, 1) + GameTooltip:Hide() + end) + + minimapButton = btn +end + +-- ============================================ +-- INIT +-- ============================================ +local initFrame = CreateFrame("Frame") +initFrame:RegisterEvent("PLAYER_LOGIN") +initFrame:SetScript("OnEvent", function() CreateMinimapButton() end) + +-- ============================================ +-- SLASH +-- ============================================ +SLASH_NANAMIPLATES1 = "/np" +SLASH_NANAMIPLATES2 = "/nanamiplates" +SlashCmdList["NANAMIPLATES"] = function(msg) + msg = string.lower(msg or "") + -- trim leading/trailing spaces + msg = string.gsub(msg, "^%s+", "") + msg = string.gsub(msg, "%s+$", "") + if msg == "tank" then + NP.playerRole = (NP.playerRole == "TANK") and "DPS" or "TANK" + NP:SaveSettings() + NP.Print("角色: " .. NP.playerRole) + if NanamiPlates_Threat then NanamiPlates_Threat.BroadcastTankMode(true) end + elseif msg == "reset" then + NanamiPlatesDB = nil + ReloadUI() + elseif msg == "minimap" then + if minimapButton then + if minimapButton:IsShown() then minimapButton:Hide() else minimapButton:Show() end + end + elseif msg == "debug" then + local p = function(s) DEFAULT_CHAT_FRAME:AddMessage("|cffff88cc[NP]|r " .. s) end + if UnitExists("target") then + local SDB = NanamiPlates_SpellDB + p("|cff00ff00-- Debuff Debug --|r") + if not SDB then + p("|cffff0000SpellDB is nil!|r") + elseif not SDB.textureToSpell then + p("|cffff0000textureToSpell is nil!|r") + else + local count = 0 + for _ in pairs(SDB.textureToSpell) do count = count + 1 end + p("textureToSpell entries: " .. count) + end + for i = 1, 16 do + local texture, stacks = UnitDebuff("target", i) + if not texture then break end + local texLookup = SDB and SDB.textureToSpell and SDB.textureToSpell[texture] + local prioLookup = SDB and SDB.debuffPriority and SDB.debuffPriority[texture] + local scanResult = SDB and SDB:ScanDebuff("target", i) or "nil" + local dur = 0 + if SDB and scanResult and scanResult ~= "nil" and scanResult ~= "" then + dur = SDB:GetDuration(scanResult, 0) or 0 + end + p(i .. ": |cffffcc00" .. tostring(texture) .. "|r") + p(" prio=|cffaaaaff" .. tostring(prioLookup) .. "|r tex=|cffaaffaa" .. tostring(texLookup) .. "|r scan=|cff00ffff" .. tostring(scanResult) .. "|r dur=" .. dur) + end + p("|cff00ff00-- End --|r") + else + p("No target") + end + else + CreateOptionsFrame() + end +end diff --git a/Plates.lua b/Plates.lua new file mode 100644 index 0000000..2159216 --- /dev/null +++ b/Plates.lua @@ -0,0 +1,1402 @@ +local NP = NanamiPlates +local Settings = NP.Settings +local Colors = NP.Colors +local THREAT_COLORS = NP.THREAT_COLORS +local SpellDB = NanamiPlates_SpellDB +local Scanner = NanamiPlates_Scanner +local Healthbar = NanamiPlates_Healthbar + +local pairs = pairs +local ipairs = ipairs +local type = type +local tostring = tostring +local tonumber = tonumber +local string_find = string.find +local string_lower = string.lower +local string_format = string.format +local string_gfind = string.gfind +local string_gsub = string.gsub +local string_sub = string.sub +local math_floor = math.floor +local GetTime = GetTime +local UnitExists = UnitExists +local UnitName = UnitName +local UnitLevel = UnitLevel +local UnitClass = UnitClass +local UnitIsUnit = UnitIsUnit +local UnitCanAttack = UnitCanAttack +local UnitIsFriend = UnitIsFriend +local UnitIsEnemy = UnitIsEnemy +local UnitDebuff = UnitDebuff +local UnitGUID = UnitGUID +local CreateFrame = CreateFrame + +local registry = NP.registry +local superwow_active = NP.superwow_active +local playerClass = NP.playerClass +local platecount = 0 +local playerInCombat = false + +local REGION_ORDER = { "border", "glow", "name", "level", "levelicon", "raidicon" } +local DEBUFF_UPDATE_INTERVAL = 0.1 + +-- Quest mob detection: scans the quest log for kill objectives +-- and checks pfQuest tooltip data for item drop mobs +local questMobCache = {} +local questCacheTimer = 0 +local QUEST_CACHE_INTERVAL = 2 + +local questMonsterPattern +do + local raw = QUEST_MONSTERS_KILLED or "%s slain: %d/%d" + raw = string.gsub(raw, "([%(%)%.%+%-%?%[%]%^%$%%])", "%%%1") + raw = string.gsub(raw, "%%%%s", "(.+)") + raw = string.gsub(raw, "%%%%d", "(%%d+)") + questMonsterPattern = raw +end + +local function RebuildQuestMobCache() + for k in pairs(questMobCache) do questMobCache[k] = nil end + + local numEntries = GetNumQuestLogEntries() + if not numEntries or numEntries == 0 then return end + + for qid = 1, numEntries do + local title, level, _, header = GetQuestLogTitle(qid) + if title and not header then + local objectives = GetNumQuestLeaderBoards(qid) + if objectives then + for i = 1, objectives do + local text, objType, done = GetQuestLogLeaderBoard(i, qid) + if text and objType == "monster" and not done then + local _, _, mobName, current, needed = string_find(text, questMonsterPattern) + if not mobName then + _, _, mobName, current, needed = string_find(text, "^(.+):%s*(%d+)/(%d+)") + end + if mobName and current and needed then + if not questMobCache[mobName] or not questMobCache[mobName].done then + questMobCache[mobName] = { + current = tonumber(current), + needed = tonumber(needed), + quest = title, + } + end + end + end + end + end + end + end +end + +local function GetQuestMobInfo(plateName) + if questMobCache[plateName] then + return questMobCache[plateName] + end + if pfMap and pfMap.tooltips and pfMap.tooltips[plateName] then + if next(pfMap.tooltips[plateName]) then + return { tooltip = true } + end + end + return nil +end + +local function GetRankNumber(rankStr) + if not rankStr then return 0 end + for num in string_gfind(rankStr, "(%d+)") do + return tonumber(num) or 0 + end + return 0 +end + +local function ParseSpellName(spellString) + if not spellString then return nil, 0 end + for name, rank in string_gfind(spellString, "^(.+)%(Rank (%d+)%)$") do + return name, tonumber(rank) or 0 + end + return spellString, 0 +end + +-- Direct spell tracking: immediately records effect on target at cast time. +-- Even if the spell is resisted, the debuff icon won't appear on the nameplate, +-- so no incorrect timer will be displayed. +local function ClearSingleCurseTracking(targetName, guid, curseName) + if not SpellDB or not curseName then return end + if SpellDB.objects and SpellDB.objects[targetName] then + for lvl, effects in pairs(SpellDB.objects[targetName]) do + if effects[curseName] then effects[curseName] = nil end + end + end + if guid and SpellDB.objects and SpellDB.objects[guid] then + for lvl, effects in pairs(SpellDB.objects[guid]) do + if effects[curseName] then effects[curseName] = nil end + end + end + if NanamiPlates_Auras and NanamiPlates_Auras.timers then + NanamiPlates_Auras.timers[targetName .. "_" .. curseName] = nil + if guid then NanamiPlates_Auras.timers[guid .. "_" .. curseName] = nil end + end + if SpellDB.ownerBoundCache then + if SpellDB.ownerBoundCache[targetName] then + SpellDB.ownerBoundCache[targetName][curseName] = nil + end + if guid and SpellDB.ownerBoundCache[guid] then + SpellDB.ownerBoundCache[guid][curseName] = nil + end + end + if SpellDB.recentCasts then + SpellDB.recentCasts[curseName] = nil + end +end + +local function ClearCurseTracking(targetName, guid, exceptCurse) + if not SpellDB or not SpellDB.WARLOCK_CURSES then return end + for curse, _ in pairs(SpellDB.WARLOCK_CURSES) do + if curse ~= exceptCurse then + if SpellDB.objects and SpellDB.objects[targetName] then + for lvl, effects in pairs(SpellDB.objects[targetName]) do + if effects[curse] then effects[curse] = nil end + end + end + if guid and SpellDB.objects and SpellDB.objects[guid] then + for lvl, effects in pairs(SpellDB.objects[guid]) do + if effects[curse] then effects[curse] = nil end + end + end + if NanamiPlates_Auras and NanamiPlates_Auras.timers then + NanamiPlates_Auras.timers[targetName .. "_" .. curse] = nil + if guid then NanamiPlates_Auras.timers[guid .. "_" .. curse] = nil end + end + if SpellDB.ownerBoundCache then + if SpellDB.ownerBoundCache[targetName] then + SpellDB.ownerBoundCache[targetName][curse] = nil + end + if guid and SpellDB.ownerBoundCache[guid] then + SpellDB.ownerBoundCache[guid][curse] = nil + end + end + if SpellDB.recentCasts then + SpellDB.recentCasts[curse] = nil + end + end + end +end + +local function TrackSpellCast(spellName, duration) + if not SpellDB or not spellName then return end + if not UnitExists("target") then return end + + -- Translate localized spell name to English for DB consistency + local englishName = spellName + if SpellDB.localeMap and SpellDB.localeMap[spellName] then + englishName = SpellDB.localeMap[spellName] + elseif SpellDB.learnedLocale and SpellDB.learnedLocale[spellName] then + englishName = SpellDB.learnedLocale[spellName] + end + + -- Record this cast for locale learning + if SpellDB.DEBUFFS and SpellDB.DEBUFFS[englishName] then + SpellDB.lastCastSpell = englishName + else + SpellDB.lastCastSpell = spellName + end + SpellDB.lastCastTime = GetTime() + + -- Re-fetch duration with English name if original was localized + if englishName ~= spellName and (not duration or duration <= 0) then + duration = SpellDB:GetDuration(englishName, 0) + end + spellName = englishName + + if not duration or duration <= 0 then return end + + local targetName = UnitName("target") + local targetLevel = UnitLevel("target") or 0 + local guid = UnitGUID and UnitGUID("target") + + if SpellDB.WARLOCK_CURSES and SpellDB.WARLOCK_CURSES[spellName] then + if not SpellDB:HasMalediction() then + ClearCurseTracking(targetName, guid, spellName) + else + if spellName == "Curse of Doom" then + ClearCurseTracking(targetName, guid, spellName) + elseif spellName == SpellDB.WARLOCK_AGONY_CURSE then + -- Agony cast: clear only Doom tracking, keep other curses + ClearSingleCurseTracking(targetName, guid, "Curse of Doom") + elseif SpellDB.WARLOCK_LONG_CURSES and SpellDB.WARLOCK_LONG_CURSES[spellName] then + -- Long curse cast: clear other long curses + Doom, keep Agony + for curse, _ in pairs(SpellDB.WARLOCK_CURSES) do + if curse ~= spellName and curse ~= SpellDB.WARLOCK_AGONY_CURSE then + ClearSingleCurseTracking(targetName, guid, curse) + end + end + end + end + end + + SpellDB:AddPending(targetName, targetLevel, spellName, duration) + SpellDB:RefreshEffect(targetName, targetLevel, spellName, duration, true) + + if guid then + SpellDB:RefreshEffect(guid, targetLevel, spellName, duration, true) + end + + if SpellDB.OWNER_BOUND_DEBUFFS and SpellDB.OWNER_BOUND_DEBUFFS[spellName] then + SpellDB:TrackOwnerBoundDebuff(targetName, spellName, duration) + if guid then SpellDB:TrackOwnerBoundDebuff(guid, spellName, duration) end + end + + if NanamiPlates_Auras and NanamiPlates_Auras.timers then + NanamiPlates_Auras.timers[targetName .. "_" .. spellName] = nil + if guid then NanamiPlates_Auras.timers[guid .. "_" .. spellName] = nil end + end + + -- Malediction auto-apply: casting Recklessness/Shadow/Elements also applies max rank Agony + if SpellDB:HasMalediction() and SpellDB.MALEDICTION_AUTO_AGONY + and SpellDB.MALEDICTION_AUTO_AGONY[spellName] then + local agonyName = SpellDB.WARLOCK_AGONY_CURSE + local agonyDuration = SpellDB:GetDuration(agonyName, 0) + if agonyDuration and agonyDuration > 0 then + SpellDB:RefreshEffect(targetName, targetLevel, agonyName, agonyDuration, true) + if guid then + SpellDB:RefreshEffect(guid, targetLevel, agonyName, agonyDuration, true) + end + if SpellDB.OWNER_BOUND_DEBUFFS and SpellDB.OWNER_BOUND_DEBUFFS[agonyName] then + SpellDB:TrackOwnerBoundDebuff(targetName, agonyName, agonyDuration) + if guid then SpellDB:TrackOwnerBoundDebuff(guid, agonyName, agonyDuration) end + end + if NanamiPlates_Auras and NanamiPlates_Auras.timers then + NanamiPlates_Auras.timers[targetName .. "_" .. agonyName] = nil + if guid then NanamiPlates_Auras.timers[guid .. "_" .. agonyName] = nil end + end + end + end +end + +-- Spell cast hooks +local Original_CastSpell = CastSpell +CastSpell = function(spellId, bookType) + if SpellDB and spellId and bookType then + local spellName, rank = GetSpellName(spellId, bookType) + if spellName and UnitExists("target") and UnitCanAttack("player", "target") then + local duration = SpellDB:GetDuration(spellName, rank) + TrackSpellCast(spellName, duration) + end + end + return Original_CastSpell(spellId, bookType) +end + +local Original_CastSpellByName = CastSpellByName +CastSpellByName = function(spellString, onSelf) + if SpellDB and spellString then + local spellName, rank = ParseSpellName(spellString) + if spellName and UnitExists("target") and UnitCanAttack("player", "target") then + local duration = SpellDB:GetDuration(spellName, rank) + TrackSpellCast(spellName, duration) + end + end + return Original_CastSpellByName(spellString, onSelf) +end + +local Original_UseAction = UseAction +UseAction = function(slot, checkCursor, onSelf) + if SpellDB and slot then + local actionTexture = GetActionTexture(slot) + if GetActionText(slot) == nil and actionTexture ~= nil then + local spellName, rank = SpellDB:ScanAction(slot) + if spellName then + if SpellDB.textureToSpell then + local existing = SpellDB.textureToSpell[actionTexture] + if not existing or not SpellDB.DEBUFFS[existing] then + SpellDB.textureToSpell[actionTexture] = spellName + end + -- If spellName is localized and existing is English, learn mapping + if existing and SpellDB.DEBUFFS[existing] and spellName ~= existing then + if SpellDB.LearnLocale then + SpellDB:LearnLocale(spellName, existing) + end + spellName = existing + end + end + local duration = SpellDB:GetDuration(spellName, rank) + TrackSpellCast(spellName, duration) + end + end + end + return Original_UseAction(slot, checkCursor, onSelf) +end + +if SpellDB then SpellDB:InitScanner() end + +local function DisableObject(obj) + if not obj then return end + obj:Hide() + obj:SetAlpha(0) + if obj.SetWidth then obj:SetWidth(0.001) end + if obj.SetHeight then obj:SetHeight(0.001) end +end + +local BORDER_PAD = 2 + +local function CreateNanamiBackdrop(frame) + local bgR, bgG, bgB, bgA = NP.GetThemeColor("panelBg", 0.1, 0.06, 0.1, 0.85) + local brR, brG, brB, brA = NP.GetThemeColor("panelBorder", 0.55, 0.30, 0.42, 0.9) + + frame:SetBackdrop({ + bgFile = "Interface\\Buttons\\WHITE8X8", + edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", + tile = false, tileSize = 0, edgeSize = 10, + insets = { left = BORDER_PAD, right = BORDER_PAD, top = BORDER_PAD, bottom = BORDER_PAD } + }) + frame:SetBackdropColor(bgR, bgG, bgB, bgA) + frame:SetBackdropBorderColor(brR, brG, brB, brA) +end + +local ARROW_TEXTURE = "Interface\\AddOns\\Nanami-Plates\\img\\arrow" +local ARROW_TEXCOORDS = { + {0, 0.5, 0, 0.5}, + {0.5, 1, 0, 0.5}, + {0, 0.5, 0.5, 1}, + {0.5, 1, 0.5, 1}, +} +NP.ARROW_TEXCOORDS = ARROW_TEXCOORDS + +local function SetArrowTexCoords(f) + if not f or not f.tex then return end + local style = Settings.targetArrowStyle or 1 + local coords = ARROW_TEXCOORDS[style] or ARROW_TEXCOORDS[1] + if f.side == "RIGHT" then + f.tex:SetTexCoord(coords[2], coords[1], coords[3], coords[4]) + else + f.tex:SetTexCoord(coords[1], coords[2], coords[3], coords[4]) + end +end + +local function UpdateArrowPosition(f, healthBG) + if not f or not healthBG then return end + local offset = Settings.targetArrowOffset or 0 + f:ClearAllPoints() + if f.side == "LEFT" then + f:SetPoint("RIGHT", healthBG, "LEFT", -offset, 0) + else + f:SetPoint("LEFT", healthBG, "RIGHT", offset, 0) + end +end + +local function CreateArrowIndicator(parent, healthBG, side) + local size = Settings.targetArrowSize or 24 + + local f = CreateFrame("Frame", nil, parent) + f:SetWidth(size) + f:SetHeight(size) + f:SetFrameLevel(parent:GetFrameLevel() + 10) + f.side = side + f.healthBG = healthBG + + UpdateArrowPosition(f, healthBG) + + local tex = f:CreateTexture(nil, "OVERLAY") + tex:SetTexture(ARROW_TEXTURE) + tex:SetAllPoints(f) + f.tex = tex + + SetArrowTexCoords(f) + + f:Hide() + return f +end + +function NP.UpdateAllArrows() + local size = Settings.targetArrowSize or 24 + for frame, np in pairs(registry) do + if np.targetArrowL then + np.targetArrowL:SetWidth(size) + np.targetArrowL:SetHeight(size) + SetArrowTexCoords(np.targetArrowL) + UpdateArrowPosition(np.targetArrowL, np.targetArrowL.healthBG) + end + if np.targetArrowR then + np.targetArrowR:SetWidth(size) + np.targetArrowR:SetHeight(size) + SetArrowTexCoords(np.targetArrowR) + UpdateArrowPosition(np.targetArrowR, np.targetArrowR.healthBG) + end + end +end + +local function HandleNamePlate(frame) + if registry[frame] then return end + + platecount = platecount + 1 + local plateName = "NanamiPlate" .. platecount + + local r1, r2, r3, r4, r5, r6 = frame:GetRegions() + local regions = {r1, r2, r3, r4, r5, r6} + + local original = {} + for i, key in ipairs(REGION_ORDER) do + original[key] = regions[i] + end + + local children = { frame:GetChildren() } + original.healthbar = children[1] + if children[2] and children[2].GetObjectType and children[2]:GetObjectType() == "StatusBar" then + original.castbar = children[2] + end + + original.name = original.name or r3 + original.level = original.level or r4 + + -- Shrink original frame to reduce game engine's anti-overlap stacking + -- Original ~35px, lower = more overlap allowed, higher = more separation + frame:SetHeight(14) + + -- Create overlay + local np = CreateFrame("Button", plateName, frame) + np:SetAllPoints(frame) + np:SetFrameLevel(frame:GetFrameLevel() + 1) + np:EnableMouse(false) + local rawStrata = np:GetFrameStrata() + local VALID_STRATA = { BACKGROUND=1, LOW=1, MEDIUM=1, HIGH=1, DIALOG=1, FULLSCREEN=1, FULLSCREEN_DIALOG=1, TOOLTIP=1 } + np._defaultStrata = VALID_STRATA[rawStrata] and rawStrata or "BACKGROUND" + + np.original = original + + -- Health bar background frame (also contains mana bar) + local healthBG = CreateFrame("Frame", nil, np) + healthBG:SetHeight(Settings.healthbarHeight + BORDER_PAD * 2) + healthBG:SetWidth(Settings.healthbarWidth + BORDER_PAD * 2) + healthBG:SetPoint("CENTER", np, "CENTER", 0, Settings.nameplateYOffset or 0) + CreateNanamiBackdrop(healthBG) + np.healthBG = healthBG + + -- Health bar (single anchor + explicit size, so mana can fit below) + local health = CreateFrame("StatusBar", plateName .. "Health", healthBG) + health:SetStatusBarTexture(NP.GetTexture()) + health:SetPoint("TOPLEFT", healthBG, "TOPLEFT", BORDER_PAD, -BORDER_PAD) + health:SetWidth(Settings.healthbarWidth) + health:SetHeight(Settings.healthbarHeight) + health:SetMinMaxValues(0, 1) + health:SetValue(1) + np.health = health + + local healthBGTex = health:CreateTexture(nil, "BACKGROUND") + healthBGTex:SetAllPoints(health) + healthBGTex:SetTexture(NP.GetTexture()) + healthBGTex:SetVertexColor(0.15, 0.15, 0.15, 0.8) + np.healthBGTex = healthBGTex + + -- Target highlight: additive StatusBar overlay that brightens only the filled portion + local targetGlow = CreateFrame("StatusBar", nil, healthBG) + targetGlow:SetAllPoints(health) + targetGlow:SetStatusBarTexture(NP.GetTexture()) + targetGlow:SetFrameLevel(health:GetFrameLevel() + 1) + targetGlow:SetMinMaxValues(0, 1) + targetGlow:SetValue(1) + targetGlow:SetAlpha(0.35) + targetGlow:Hide() + np.targetGlow = targetGlow + local glowRegions = { targetGlow:GetRegions() } + for _, reg in ipairs(glowRegions) do + if reg and reg.SetBlendMode then reg:SetBlendMode("ADD") end + end + + -- Mana/Energy/Rage bar (inside healthBG, below health bar) + local manaBar = CreateFrame("StatusBar", plateName .. "Mana", healthBG) + manaBar:SetStatusBarTexture(NP.GetTexture()) + manaBar:SetPoint("TOPLEFT", health, "BOTTOMLEFT", 0, -1) + manaBar:SetWidth(Settings.healthbarWidth) + manaBar:SetHeight(Settings.manabarHeight or 3) + manaBar:SetMinMaxValues(0, 1) + manaBar:SetValue(1) + manaBar:SetStatusBarColor(0, 0, 1, 1) + manaBar:Hide() + np.manaBar = manaBar + + local manaBGTex = manaBar:CreateTexture(nil, "BACKGROUND") + manaBGTex:SetAllPoints(manaBar) + manaBGTex:SetTexture(NP.GetTexture()) + manaBGTex:SetVertexColor(0.05, 0.05, 0.1, 0.8) + + -- Thin separator line between health and mana + local brR, brG, brB = NP.GetThemeColor("panelBorder", 0.55, 0.30, 0.42, 0.9) + local manaSep = healthBG:CreateTexture(nil, "ARTWORK") + manaSep:SetTexture("Interface\\Buttons\\WHITE8X8") + manaSep:SetHeight(1) + manaSep:SetPoint("TOPLEFT", health, "BOTTOMLEFT", 0, 0) + manaSep:SetPoint("TOPRIGHT", health, "BOTTOMRIGHT", 0, 0) + manaSep:SetVertexColor(brR, brG, brB, 0.5) + manaSep:Hide() + np.manaSep = manaSep + + -- Health text + local healthText = health:CreateFontString(nil, "OVERLAY") + healthText:SetFont(NP.GetFont(), Settings.healthFontSize, NP.GetFontOutline()) + healthText:SetPoint("TOPRIGHT", health, "TOPRIGHT", -2, 0) + healthText:SetPoint("BOTTOMRIGHT", health, "BOTTOMRIGHT", -2, 0) + healthText:SetJustifyV("MIDDLE") + healthText:SetJustifyH("RIGHT") + healthText:SetTextColor(1, 1, 1, 1) + np.healthText = healthText + + -- Name text + local name = np:CreateFontString(nil, "OVERLAY") + name:SetFont(NP.GetFont(), Settings.nameFontSize, NP.GetFontOutline()) + name:SetPoint("BOTTOM", healthBG, "TOP", 0, 2) + name:SetTextColor(1, 1, 1, 1) + np.name = name + + -- Level text (on health bar so it renders above the bar fill) + local level = health:CreateFontString(nil, "OVERLAY") + level:SetFont(NP.GetFont(), Settings.levelFontSize, NP.GetFontOutline()) + level:SetPoint("TOPLEFT", health, "TOPLEFT", 2, 0) + level:SetPoint("BOTTOMLEFT", health, "BOTTOMLEFT", 2, 0) + level:SetJustifyV("MIDDLE") + level:SetJustifyH("LEFT") + level:SetTextColor(1, 1, 0.6, 1) + np.level = level + + -- Raid icon + local raidIcon = np:CreateTexture(nil, "OVERLAY") + raidIcon:SetTexture("Interface\\TargetingFrame\\UI-RaidTargetingIcons") + raidIcon:SetWidth(16) + raidIcon:SetHeight(16) + raidIcon:SetPoint("LEFT", healthBG, "RIGHT", 3, 0) + raidIcon:Hide() + np.raidIcon = raidIcon + + -- Castbar background (anchors dynamically below healthBG) + local castbarBG = CreateFrame("Frame", nil, np) + castbarBG:SetHeight(Settings.castbarHeight + 2) + castbarBG:SetWidth(Settings.healthbarWidth + 2) + castbarBG:SetPoint("TOP", healthBG, "BOTTOM", 0, -1) + CreateNanamiBackdrop(castbarBG) + castbarBG:Hide() + np.castbarBG = castbarBG + + -- Castbar + local castbar = CreateFrame("StatusBar", plateName .. "Castbar", castbarBG) + castbar:SetStatusBarTexture(NP.GetTexture()) + castbar:SetPoint("TOPLEFT", castbarBG, "TOPLEFT", 1, -1) + castbar:SetPoint("BOTTOMRIGHT", castbarBG, "BOTTOMRIGHT", -1, 1) + castbar:SetMinMaxValues(0, 1) + castbar:SetValue(0) + castbar:SetStatusBarColor(Settings.castbarColor[1], Settings.castbarColor[2], Settings.castbarColor[3], Settings.castbarColor[4]) + np.castbar = castbar + + local castbarBGTex = castbar:CreateTexture(nil, "BACKGROUND") + castbarBGTex:SetAllPoints(castbar) + castbarBGTex:SetTexture(NP.GetTexture()) + castbarBGTex:SetVertexColor(0.1, 0.1, 0.1, 0.8) + + local castbarText = castbar:CreateFontString(nil, "OVERLAY") + castbarText:SetFont(NP.GetFont(), Settings.healthFontSize - 1, NP.GetFontOutline()) + castbarText:SetPoint("CENTER", castbar, "CENTER", 0, 0) + castbarText:SetTextColor(1, 1, 1, 1) + np.castbarText = castbarText + + local castbarTimer = castbar:CreateFontString(nil, "OVERLAY") + castbarTimer:SetFont(NP.GetFont(), Settings.healthFontSize - 1, NP.GetFontOutline()) + castbarTimer:SetPoint("RIGHT", castbar, "RIGHT", -2, 0) + castbarTimer:SetTextColor(1, 1, 1, 1) + np.castbarTimer = castbarTimer + + -- Castbar icon (anchored to castbar, not healthbar, to avoid chevron overlap) + local castbarIcon = castbarBG:CreateTexture(nil, "OVERLAY") + castbarIcon:SetWidth(Settings.castbarHeight + 8) + castbarIcon:SetHeight(Settings.castbarHeight + 8) + castbarIcon:SetPoint("RIGHT", castbarBG, "LEFT", -2, 0) + castbarIcon:SetTexCoord(0.08, 0.92, 0.08, 0.92) + castbarIcon:Hide() + np.castbarIcon = castbarIcon + + -- Create aura frames (filled in by Auras module) + if NanamiPlates_Auras then + NanamiPlates_Auras:CreateDebuffFrames(np) + end + + -- Create combo point frames (filled in by ComboPoints module) + if NanamiPlates_ComboPoints then + NanamiPlates_ComboPoints:CreateComboPointFrames(np) + end + + -- Quest mob indicator icon (yellow "!" to the left of the name) + local questIcon = np:CreateTexture(nil, "OVERLAY") + questIcon:SetTexture("Interface\\GossipFrame\\AvailableQuestIcon") + questIcon:SetWidth(12) + questIcon:SetHeight(12) + questIcon:SetPoint("RIGHT", name, "LEFT", -1, 0) + questIcon:Hide() + np.questIcon = questIcon + + -- Quest progress text (e.g. "3/8" to the right of the name) + local questText = np:CreateFontString(nil, "OVERLAY") + questText:SetFont(NP.GetFont(), Settings.nameFontSize - 1, NP.GetFontOutline()) + questText:SetPoint("LEFT", name, "RIGHT", 2, 0) + questText:SetTextColor(1, 0.82, 0, 1) + questText:Hide() + np.questText = questText + + -- Target arrow indicators >> [healthbar] << + np.targetArrowL = CreateArrowIndicator(np, healthBG, "LEFT") + np.targetArrowR = CreateArrowIndicator(np, healthBG, "RIGHT") + + -- Hide original elements thoroughly (keep healthbar color intact for unit type detection) + for _, key in ipairs(REGION_ORDER) do + if original[key] then DisableObject(original[key]) end + end + if original.healthbar then + local hbRegions = { original.healthbar:GetRegions() } + for _, reg in ipairs(hbRegions) do + if reg and reg.Hide then reg:Hide() end + if reg and reg.SetAlpha then reg:SetAlpha(0) end + end + original.healthbar:ClearAllPoints() + original.healthbar:SetPoint("BOTTOMLEFT", frame, "BOTTOMLEFT", 0, 0) + original.healthbar:SetWidth(0.001) + original.healthbar:SetHeight(0.001) + end + if original.castbar then + original.castbar:SetAlpha(0.001) + local cbRegions = { original.castbar:GetRegions() } + for _, reg in ipairs(cbRegions) do + if reg and reg.SetAlpha then reg:SetAlpha(0) end + end + end + + -- Tracking state + np.isTarget = false + np.lastDebuffUpdate = 0 + np.unitstr = nil + np.plateName = nil + np._lastFriendly = nil + np._lastManaShowing = nil + np._lastFriendly_resize = nil + + frame.nameplate = np + registry[frame] = np + + -- OnShow: reset and re-hide original elements + local oldOnShow = frame:GetScript("OnShow") + frame:SetScript("OnShow", function() + if oldOnShow then oldOnShow() end + this:SetHeight(14) + local nameplate = this.nameplate + if nameplate then + Healthbar.ResetCache(nameplate) + nameplate.isTarget = false + nameplate._isTargetStrata = nil + nameplate.lastDebuffUpdate = 0 + nameplate.unitstr = nil + nameplate._lastFriendly = nil + nameplate._lastManaShowing = nil + nameplate._lastFriendly_resize = nil + nameplate._lastAlpha = nil + if nameplate._defaultStrata then + nameplate:SetFrameStrata(nameplate._defaultStrata) + end + nameplate.castbarBG:Hide() + nameplate.castbarIcon:Hide() + nameplate.manaBar:Hide() + if nameplate.manaSep then nameplate.manaSep:Hide() end + if nameplate.targetArrowL then nameplate.targetArrowL:Hide() end + if nameplate.targetArrowR then nameplate.targetArrowR:Hide() end + if nameplate.comboPoints then + for i = 1, 5 do + if nameplate.comboPoints[i] then nameplate.comboPoints[i]:Hide() end + end + end + if nameplate.questIcon then nameplate.questIcon:Hide() end + if nameplate.questText then nameplate.questText:Hide() end + + -- Re-hide original elements (Blizzard resets them on show) + local orig = nameplate.original + for _, key in ipairs(REGION_ORDER) do + if orig[key] then + orig[key]:Hide() + if orig[key].SetAlpha then orig[key]:SetAlpha(0) end + end + end + if orig.healthbar then + orig.healthbar:SetWidth(0.001) + orig.healthbar:SetHeight(0.001) + local hbRegions = { orig.healthbar:GetRegions() } + for _, reg in ipairs(hbRegions) do + if reg and reg.Hide then reg:Hide() end + end + end + if orig.castbar then + orig.castbar:SetAlpha(0.001) + local cbRegions = { orig.castbar:GetRegions() } + for _, reg in ipairs(cbRegions) do + if reg and reg.SetAlpha then reg:SetAlpha(0) end + end + end + + if Healthbar.ShouldSkipNameplate(this, nameplate, nameplate.original, Settings) then + nameplate:Hide() + else + nameplate:Show() + end + end + end) + + -- OnHide: cleanup + local oldOnHide = frame:GetScript("OnHide") + frame:SetScript("OnHide", function() + if oldOnHide then oldOnHide() end + local nameplate = this.nameplate + if nameplate then + nameplate.castbarBG:Hide() + nameplate.castbarIcon:Hide() + nameplate.manaBar:Hide() + if nameplate.manaSep then nameplate.manaSep:Hide() end + if nameplate.targetArrowL then nameplate.targetArrowL:Hide() end + if nameplate.targetArrowR then nameplate.targetArrowR:Hide() end + if nameplate.questIcon then nameplate.questIcon:Hide() end + if nameplate.questText then nameplate.questText:Hide() end + end + end) +end + +local function ShortenNumber(n) + if n >= 1000000 then + return string_format("%.1fM", n / 1000000) + elseif n >= 10000 then + return string_format("%.1fK", n / 1000) + else + return tostring(n) + end +end + +local function FormatHealthText(current, max, format) + if not format or format == 0 then return "" end + if max == 0 then return "" end + local pct = math_floor(current / max * 100 + 0.5) + if format == 1 then return pct .. "%" end + if format == 2 then return ShortenNumber(current) end + if format == 3 then return ShortenNumber(current) .. " (" .. pct .. "%)" end + if format == 4 then return ShortenNumber(current) .. "/" .. ShortenNumber(max) end + if format == 5 then return ShortenNumber(current) .. "/" .. ShortenNumber(max) .. " " .. pct .. "%" end + return pct .. "%" +end + +local function GetLevelDiffColor(unitLevel) + local playerLevel = UnitLevel("player") or 60 + local diff = unitLevel - playerLevel + if diff >= 5 then return 1, 0, 0 + elseif diff >= 3 then return 1, 0.5, 0 + elseif diff >= -2 then return 1, 1, 0 + elseif diff >= -4 - math_floor(playerLevel / 10) then return 0.25, 0.75, 0.25 + else return 0.5, 0.5, 0.5 + end +end + +local function HideOriginalElements(original) + for _, key in ipairs(REGION_ORDER) do + local obj = original[key] + if obj and obj.IsShown and obj:IsShown() then + obj:Hide() + if obj.SetAlpha then obj:SetAlpha(0) end + end + end + if original.castbar and original.castbar:GetAlpha() > 0.01 then + original.castbar:SetAlpha(0.001) + end +end + +local function UpdateNamePlate(frame) + local np = frame.nameplate + if not np then return end + if not np:IsShown() then return end + + local original = np.original + if not original or not original.healthbar then return end + + -- Keep original frame small to reduce anti-overlap stacking jumps + if frame:GetHeight() > 16 then + frame:SetHeight(14) + end + + -- Continuously enforce hiding of original elements + HideOriginalElements(original) + + -- Unit name + local plateName = "" + if original.name and original.name.GetText then + plateName = original.name:GetText() or "" + end + np.plateName = plateName + np.name:SetText(plateName) + + -- SuperWoW unit detection + local unitstr = nil + local hasValidGUID = false + if superwow_active and frame.GetName then + unitstr = frame:GetName(1) + if unitstr and UnitExists(unitstr) then + hasValidGUID = true + else + unitstr = nil + end + end + np.unitstr = unitstr + + -- Unit type detection + local isHostile, isNeutral, isFriendly, origR, origG, origB = Healthbar.DetectUnitType(np, original) + Healthbar.CheckUnitChange(np, plateName, isNeutral) + + -- Override neutral→hostile when the mob has entered combat (e.g. after player attacks it) + if (isNeutral or np.wasNeutral) and unitstr and hasValidGUID + and UnitAffectingCombat and UnitAffectingCombat(unitstr) then + isHostile = true + isNeutral = false + isFriendly = false + np.wasNeutral = false + end + + local isFriendlyStyle = isFriendly + + -- Tapped detection: mob tagged by another player outside our group (unlootable) + local isTapped = false + if unitstr and hasValidGUID and UnitIsTapped then + if UnitIsTapped(unitstr) and (not UnitIsTappedByPlayer or not UnitIsTappedByPlayer(unitstr)) then + isTapped = true + end + end + -- Color fallback: hostile/neutral/friendly all have B≈0; tapped grey has B>0 + if not isTapped and not isHostile and not isNeutral and origB > 0.1 and origR > 0.15 then + isTapped = true + end + if isTapped then + isFriendlyStyle = false + end + + -- Health values + local hp = original.healthbar:GetValue() or 0 + local _, hpmax = original.healthbar:GetMinMaxValues() + hpmax = hpmax or 1 + if hpmax == 0 then hpmax = 1 end + np.health:SetMinMaxValues(0, hpmax) + np.health:SetValue(hp) + if np.targetGlow then + np.targetGlow:SetMinMaxValues(0, hpmax) + np.targetGlow:SetValue(hp) + end + + -- Dimensions (only update when style changes) + local hHeight, hWidth, hFontSize, lFontSize, nFontSize, hTextFormat + if isFriendlyStyle then + hHeight = Settings.friendHealthbarHeight + hWidth = Settings.friendHealthbarWidth + hFontSize = Settings.friendHealthFontSize + lFontSize = Settings.friendLevelFontSize + nFontSize = Settings.friendNameFontSize + hTextFormat = Settings.friendHealthTextFormat + else + hHeight = Settings.healthbarHeight + hWidth = Settings.healthbarWidth + hFontSize = Settings.healthFontSize + lFontSize = Settings.levelFontSize + nFontSize = Settings.nameFontSize + hTextFormat = Settings.healthTextFormat + end + + if np._lastFriendly ~= isFriendlyStyle then + np._lastFriendly = isFriendlyStyle + np.health:SetWidth(hWidth) + np.health:SetHeight(hHeight) + np.name:SetFont(NP.GetFont(), nFontSize, NP.GetFontOutline()) + np.level:SetFont(NP.GetFont(), lFontSize, NP.GetFontOutline()) + np.healthText:SetFont(NP.GetFont(), hFontSize, NP.GetFontOutline()) + end + + -- Health text + np.healthText:SetText(FormatHealthText(hp, hpmax, hTextFormat)) + + -- Level text and classification + local unitLevel = nil + local isBoss = original.levelicon and original.levelicon.IsShown and original.levelicon:IsShown() + local levelText = (original.level and original.level.GetText) and original.level:GetText() or nil + + if levelText then + unitLevel = tonumber(levelText) + end + + local classification = "normal" + if isBoss then + classification = "worldboss" + elseif isTarget and UnitExists("target") and UnitClassification then + classification = UnitClassification("target") or "normal" + elseif unitstr and UnitExists(unitstr) and UnitClassification then + classification = UnitClassification(unitstr) or "normal" + elseif original.border and original.border.GetVertexColor then + local br, bg, bb = original.border:GetVertexColor() + if br and bg and bb and br > 0.6 and bg > 0.5 and bb < 0.3 then + classification = "elite" + end + end + + if classification == "worldboss" then + np.level:SetText("??") + np.level:SetTextColor(1, 0, 0) + elseif levelText then + if classification == "rareelite" then + np.level:SetText(levelText .. "+") + np.level:SetTextColor(0.8, 0.8, 0.8) + elseif classification == "elite" then + np.level:SetText(levelText .. "+") + if unitLevel then + local lr, lg, lb = GetLevelDiffColor(unitLevel) + np.level:SetTextColor(lr, lg, lb) + else + np.level:SetTextColor(1, 1, 0.6) + end + elseif classification == "rare" then + np.level:SetText(levelText) + np.level:SetTextColor(0.8, 0.8, 0.8) + else + np.level:SetText(levelText) + if unitLevel then + local lr, lg, lb = GetLevelDiffColor(unitLevel) + np.level:SetTextColor(lr, lg, lb) + else + np.level:SetTextColor(1, 1, 0.6) + end + end + end + + -- Raid icon + if original.raidicon and original.raidicon.IsShown and original.raidicon:IsShown() then + local ux, uy = original.raidicon:GetTexCoord() + np.raidIcon:SetTexCoord(ux, ux + 0.25, uy, uy + 0.25) + np.raidIcon:Show() + else + np.raidIcon:Hide() + end + + -- Quest mob indicator + if Settings.showQuestIcon and not isFriendlyStyle then + local questInfo = GetQuestMobInfo(plateName) + if questInfo then + if not np.questIcon:IsShown() then np.questIcon:Show() end + if questInfo.current and questInfo.needed then + np.questText:SetText(questInfo.current .. "/" .. questInfo.needed) + if not np.questText:IsShown() then np.questText:Show() end + else + if np.questText:IsShown() then np.questText:Hide() end + end + else + if np.questIcon:IsShown() then np.questIcon:Hide() end + if np.questText:IsShown() then np.questText:Hide() end + end + else + if np.questIcon:IsShown() then np.questIcon:Hide() end + if np.questText:IsShown() then np.questText:Hide() end + end + + -- Health bar color + local barR, barG, barB = origR, origG, origB + + if isTapped then + barR, barG, barB = Colors.tapped[1], Colors.tapped[2], Colors.tapped[3] + elseif isHostile then + barR, barG, barB = Colors.hostile[1], Colors.hostile[2], Colors.hostile[3] + elseif isNeutral or Healthbar.WasNeutral(np) then + barR, barG, barB = Colors.neutral[1], Colors.neutral[2], Colors.neutral[3] + elseif isFriendly then + barR, barG, barB = Colors.friendly[1], Colors.friendly[2], Colors.friendly[3] + else + barR, barG, barB = Colors.hostile[1], Colors.hostile[2], Colors.hostile[3] + end + + -- Class colors for players + if unitstr and hasValidGUID and UnitIsPlayer(unitstr) then + local _, unitClass = UnitClass(unitstr) + if unitClass and Colors.class[unitClass] then + barR = Colors.class[unitClass][1] + barG = Colors.class[unitClass][2] + barB = Colors.class[unitClass][3] + end + end + + -- Threat coloring (overrides basic colors for hostile mobs) + if isHostile and not isTapped then + local threatColor = nil + local mobGUID = unitstr + + if NanamiPlates_Threat then + local hasData, playerHasAggro, otherName, otherPct = NanamiPlates_Threat.GetTWTankModeThreat(mobGUID, plateName) + + if hasData then + local role = NP.playerRole or "DPS" + if role == "TANK" then + if playerHasAggro then + threatColor = THREAT_COLORS.TANK.AGGRO + elseif otherPct and otherPct > 70 then + threatColor = THREAT_COLORS.TANK.LOSING_AGGRO + elseif otherName and NP.TANK_CLASSES and NP.GetPlayerClassByName then + local otherClass = NP.GetPlayerClassByName(otherName) + if otherClass and NP.TANK_CLASSES[otherClass] then + threatColor = THREAT_COLORS.TANK.OTHER_TANK + else + threatColor = THREAT_COLORS.TANK.NO_AGGRO + end + else + threatColor = THREAT_COLORS.TANK.NO_AGGRO + end + else + if playerHasAggro then + threatColor = THREAT_COLORS.DPS.AGGRO + elseif otherPct and otherPct > 70 then + threatColor = THREAT_COLORS.DPS.HIGH_THREAT + else + threatColor = THREAT_COLORS.DPS.NO_AGGRO + end + end + end + end + + -- Stun override + if unitstr and hasValidGUID then + for _, stunEffect in ipairs(NP.STUN_EFFECTS) do + local data = SpellDB and SpellDB:FindEffectData(unitstr, 0, stunEffect) + if data and data.start and data.duration then + if data.start + data.duration > GetTime() then + threatColor = THREAT_COLORS.STUN + break + end + end + end + end + + if threatColor then + barR, barG, barB = threatColor[1], threatColor[2], threatColor[3] + end + end + + np.health:SetStatusBarColor(barR, barG, barB, 1) + + -- Sync target glow with final bar color (must happen here, not in UpdateTarget, + -- because the bar color may have changed this frame due to combat/threat) + if np.targetGlow and np.targetGlow:IsShown() then + np.targetGlow:SetStatusBarColor(barR, barG, barB, 1) + local lum = 0.299 * barR + 0.587 * barG + 0.114 * barB + local glowAlpha = 0.75 + if lum > 0.5 then + glowAlpha = 0.75 * math.max(0.15, (1.0 - lum) * 2) + end + np.targetGlow:SetAlpha(glowAlpha) + end + + -- Target detection + local isTarget = false + if UnitExists("target") then + if unitstr and hasValidGUID then + isTarget = UnitIsUnit(unitstr, "target") + else + isTarget = (UnitName("target") == plateName) and (not UnitIsFriend("player", "target") == not isFriendly) + end + end + np.isTarget = isTarget + + -- Target nameplate always on top via FrameStrata + if isTarget and not np._isTargetStrata then + np._isTargetStrata = true + np:SetFrameStrata("HIGH") + elseif not isTarget and np._isTargetStrata then + np._isTargetStrata = false + np:SetFrameStrata(np._defaultStrata) + end + + -- Target highlight + if NanamiPlates_Target then + NanamiPlates_Target.UpdateTarget(np, isTarget) + end + + -- Non-target alpha (only update on change) + local wantAlpha = (UnitExists("target") and not isTarget) and (Settings.nonTargetAlpha or 0.6) or 1 + if np._lastAlpha ~= wantAlpha then + np._lastAlpha = wantAlpha + np:SetAlpha(wantAlpha) + end + + -- Mana/Energy/Rage bar update (inside healthBG) + local manaShowing = false + local manaH = Settings.manabarHeight or 3 + if Settings.showManaBar and unitstr and hasValidGUID and UnitMana and UnitManaMax then + local manaMax = UnitManaMax(unitstr) + if manaMax and manaMax > 0 then + local manaCur = UnitMana(unitstr) or 0 + local powerType = UnitPowerType and UnitPowerType(unitstr) or 0 + local powerColor = Colors.power[powerType] or Colors.power[0] + + if not np.manaBar:IsShown() then + np.manaBar:SetWidth(hWidth) + np.manaBar:SetHeight(manaH) + np.manaBar:Show() + np.manaSep:Show() + end + np.manaBar:SetMinMaxValues(0, manaMax) + np.manaBar:SetValue(manaCur) + np.manaBar:SetStatusBarColor(powerColor[1], powerColor[2], powerColor[3], 1) + manaShowing = true + end + end + if not manaShowing and np.manaBar:IsShown() then + np.manaBar:Hide() + np.manaSep:Hide() + end + + -- Resize healthBG only when mana visibility or style changes + local needResize = (np._lastManaShowing ~= manaShowing) or (np._lastFriendly_resize ~= isFriendlyStyle) + if needResize then + np._lastManaShowing = manaShowing + np._lastFriendly_resize = isFriendlyStyle + if manaShowing then + np.healthBG:SetHeight(hHeight + manaH + 1 + BORDER_PAD * 2) + else + np.healthBG:SetHeight(hHeight + BORDER_PAD * 2) + end + np.healthBG:SetWidth(hWidth + BORDER_PAD * 2) + + local cbHeight = isFriendlyStyle and (Settings.friendCastbarHeight or 6) or (Settings.castbarHeight or 10) + np.castbarBG:SetWidth(hWidth + 2) + np.castbarBG:SetHeight(cbHeight + 2) + end + + local castShowing = false + local casting = nil + local castNow = GetTime() + + -- Method 1: castDB by GUID (UNIT_CASTEVENT, unitstr IS the GUID from GetName(1)) + local castDB = NP.castDB + if hasValidGUID and unitstr and castDB[unitstr] then + local cast = castDB[unitstr] + if cast.startTime + (cast.duration / 1000) > castNow then + casting = cast + else + castDB[unitstr] = nil + end + end + + -- Method 2: UnitCastingInfo / UnitChannelInfo (SuperWoW 1.5+) + if not casting and superwow_active and hasValidGUID and unitstr then + if UnitCastingInfo then + local spell, _, _, texture, startTime, endTime = UnitCastingInfo(unitstr) + if spell then + casting = { + spell = spell, + startTime = startTime / 1000, + duration = endTime - startTime, + icon = texture + } + end + end + if not casting and UnitChannelInfo then + local spell, _, _, texture, startTime, endTime = UnitChannelInfo(unitstr) + if spell then + casting = { + spell = spell, + startTime = startTime / 1000, + duration = endTime - startTime, + icon = texture, + channel = true + } + end + end + end + + -- Method 3: Fallback to original Blizzard castbar visibility + if not casting and original.castbar then + local origCBVisible = original.castbar.IsVisible and original.castbar:IsVisible() + if not origCBVisible then + origCBVisible = original.castbar.IsShown and original.castbar:IsShown() + end + if origCBVisible then + local castVal = original.castbar:GetValue() or 0 + local castMin, castMax = original.castbar:GetMinMaxValues() + if castMax and castMax > 0 then + np.castbar:SetMinMaxValues(castMin, castMax) + np.castbar:SetValue(castVal) + np.castbarText:SetText("") + np.castbarTimer:SetText("") + np.castbarIcon:Hide() + np.castbarBG:Show() + castShowing = true + end + end + end + + -- Render castbar from Method 1 or 2 + if casting and casting.spell and not castShowing then + local start = casting.startTime + local duration = casting.duration + if castNow < start + (duration / 1000) then + np.castbar:SetMinMaxValues(0, duration) + if casting.channel then + np.castbar:SetValue(duration - (castNow - start) * 1000) + else + np.castbar:SetValue((castNow - start) * 1000) + end + np.castbarText:SetText(casting.spell) + local timeLeft = (start + (duration / 1000)) - castNow + np.castbarTimer:SetText(string_format("%.1f", timeLeft)) + + if casting.icon then + np.castbarIcon:SetTexture(casting.icon) + np.castbarIcon:Show() + else + np.castbarIcon:Hide() + end + + np.castbarBG:Show() + castShowing = true + end + end + + if not castShowing then + np.castbarBG:Hide() + np.castbarIcon:Hide() + end + + -- Debuff update (throttled) + local now = GetTime() + if now - np.lastDebuffUpdate >= DEBUFF_UPDATE_INTERVAL then + np.lastDebuffUpdate = now + local numDebuffs = 0 + + -- Debuff anchor: below castbar if visible, else below healthBG (which now includes mana) + if castShowing then + np.debuffAnchor = np.castbarBG + else + np.debuffAnchor = np.healthBG + end + + if NanamiPlates_Auras and np.debuffs then + numDebuffs = NanamiPlates_Auras:UpdateDebuffs(np, unitstr, plateName, isTarget, hasValidGUID, superwow_active) + NanamiPlates_Auras:UpdateDebuffPositions(np, numDebuffs) + end + + -- Combo points + if NanamiPlates_ComboPoints then + local numPoints = NanamiPlates_ComboPoints:UpdateComboPoints(np, isTarget) + NanamiPlates_ComboPoints:UpdateComboPointPositions(np, numDebuffs) + end + end +end + +-- Main OnUpdate loop +local scanThrottle = 0 +local SCAN_INTERVAL = 0.1 + +local function OnUpdate() + local now = GetTime() + + -- Rebuild quest mob cache periodically + if now - questCacheTimer >= QUEST_CACHE_INTERVAL then + questCacheTimer = now + RebuildQuestMobCache() + end + + -- Scan for new nameplates + if now - scanThrottle >= SCAN_INTERVAL then + scanThrottle = now + Scanner.ScanForNewNameplates(registry, HandleNamePlate) + end + + -- Update all active nameplates + for frame, np in pairs(registry) do + if frame:IsShown() then + UpdateNamePlate(frame) + end + end + + -- Cleanup debuff timers + if NanamiPlates_Auras then + NanamiPlates_Auras:CleanupTimers() + end +end + +-- Event handler +local function OnEvent() + local evnt = event + + if evnt == "ADDON_LOADED" and arg1 == "Nanami-Plates" then + NP:LoadSettings() + NP.Print("Loaded.") + + elseif evnt == "PLAYER_ENTERING_WORLD" then + Scanner.Reset() + if NanamiPlates_Castbar.ClearCastData then + NanamiPlates_Castbar.ClearCastData() + end + NP.playerClassCache = {} + + if SpellDB and SpellDB.ScanSpellbook then + SpellDB:ScanSpellbook() + end + + NP:DetectTankSpec() + + if NanamiPlates_Threat then + NanamiPlates_Threat.BroadcastTankMode(true) + end + + elseif evnt == "CHARACTER_POINTS_CHANGED" then + NP:DetectTankSpec() + + elseif evnt == "UNIT_CASTEVENT" then + if superwow_active and arg1 then + NanamiPlates_Castbar.HandleUnitCastEvent(arg1, arg2, arg3, arg4, arg5) + end + + elseif evnt == "SPELLCAST_STOP" then + if SpellDB then SpellDB:PersistPending() end + + elseif evnt == "CHAT_MSG_SPELL_FAILED_LOCALPLAYER" then + if SpellDB then SpellDB:RemovePending() end + + elseif evnt == "QUEST_LOG_UPDATE" then + questCacheTimer = 0 + + elseif evnt == "PLAYER_TARGET_CHANGED" then + -- Force debuff update on all plates + for frame, np in pairs(registry) do + if frame:IsShown() then + np.lastDebuffUpdate = 0 + end + end + + elseif evnt == "PLAYER_REGEN_DISABLED" then + playerInCombat = true + + elseif evnt == "PLAYER_REGEN_ENABLED" then + playerInCombat = false + NP.playerClassCache = {} + if SpellDB then + for k in pairs(SpellDB.objects) do + SpellDB.objects[k] = nil + end + end + + elseif evnt == "PARTY_MEMBERS_CHANGED" or evnt == "RAID_ROSTER_UPDATE" then + NP.playerClassCache = {} + if NanamiPlates_Threat then + NanamiPlates_Threat.BroadcastTankMode(true) + end + + elseif NP.SPELL_EVENTS[evnt] then + if NanamiPlates_CombatLog then + NanamiPlates_CombatLog.HandleSpellEvent(evnt, arg1) + end + + elseif NP.COMBAT_EVENTS[evnt] then + if NanamiPlates_CombatLog then + NanamiPlates_CombatLog.HandleCombatEvent(evnt, arg1) + end + end +end + +NP.EventFrame:SetScript("OnEvent", OnEvent) +NP.EventFrame:SetScript("OnUpdate", OnUpdate) diff --git a/README.md b/README.md new file mode 100644 index 0000000..037b15f --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Nanami-Plates + +> Turtle WoW 高度可定制的姓名板替换插件,采用 Nanami-UI 风格,提供血条、施法条、减益追踪、仇恨着色、连击点、目标指示、任务怪标记等丰富功能,完整支持 SuperWoW 增强特性。 + +**Nanami-Plates** 完全替换游戏默认姓名板,为 Turtle WoW (1.12) 提供现代化、美观且功能强大的姓名板体验。作为 Nanami-UI 套件的一部分,它自动继承主题配色、字体和材质,与整体 UI 风格浑然一体。 + +## 核心功能 + +### 血条系统 + +- 敌方与友方姓名板独立设置宽度、高度、字号 +- 6 种血量显示格式:隐藏 / 百分比 / 当前值 / 当前(百分比) / 当前/最大 / 当前/最大 % +- 等级文本根据与玩家的等级差自动着色(红/橙/黄/绿/灰) +- 精英(+)、稀有、世界Boss(??) 分类标识 +- 玩家单位自动显示职业颜色 +- 被其他玩家标记的怪物显示灰色(Tapped 检测) +- 可选隐藏小动物(兔子、松鼠等)姓名板 + +### 施法条 + +- 显示法术名称、法术图标和施法倒计时 +- 完整支持 SuperWoW 的 `UNIT_CASTEVENT` / `UnitCastingInfo` / `UnitChannelInfo` +- 无 SuperWoW 时回退至暴雪原生施法条数据 +- 敌方与友方施法条独立配置尺寸 + +### 减益 (Debuff) 追踪 + +- 在姓名板下方显示减益图标,支持最多 16 个 +- 图标上实时显示剩余时间倒计时(颜色随时间变化:白→黄→红) +- 内置完整的法术持续时间数据库(SpellDB),涵盖所有职业的 DoT、诅咒、毒药、陷阱等 +- 可选「仅显示自己的减益」过滤模式 +- 术士诅咒互斥逻辑完美处理(含 Malediction 天赋支持) +- 自动学习本地化法术名称映射,多语言服务器无缝兼容 + +### 连击点显示 + +- 盗贼和德鲁伊(猫形态)在目标姓名板上方显示连击点 +- 每个连击点独立着色(绿→黄→橙→红),带数字标识 +- 可自定义连击点大小 + +### 仇恨系统 + +- 集成 TWThreat 插件数据,实时仇恨着色 +- 自动检测坦克天赋(战士防护 / 骑士防护 / 德鲁伊野性)自动切换坦克/输出模式 +- **坦克模式:** 持有仇恨=绿色 / 即将丢失=橙色 / 未持有=红色 / 其他坦克持有=蓝色 +- **输出模式:** OT=红色 / 高仇恨=橙色 / 安全=默认色 +- 目标被控制(击晕/闷棍等)时显示紫色 +- 通过插件频道在队伍/团队中广播坦克模式 + +### 目标指示 + +- 4 种箭头样式可选,显示在目标姓名板两侧 `>> [血条] <<` +- 箭头大小、偏移量、染色强度均可调节 +- 目标血条高亮发光效果(自适应亮度的 Additive 混合) +- 目标血条边框高亮 +- 目标姓名板自动提升 FrameStrata 保持在最前 +- 非目标姓名板透明度可调(默认 35%) + +### 法力/能量/怒气条 + +- SuperWoW 环境下可在血条下方显示目标的法力条 +- 根据能量类型自动着色(蓝=法力 / 红=怒气 / 橙=能量 / 黄=幸福值) + +### 任务怪标记 + +- 自动扫描任务日志,在任务目标怪物旁显示 "!" 图标 +- 显示击杀进度(如 "3/8") +- 兼容 pfQuest 的提示数据 + +### 团队标记 + +- 正确显示团队标记图标(骷髅、叉、月亮等) + +## 设置面板 + +输入 `/np` 或点击小地图按钮即可打开设置界面,共 5 个标签页: + +| 标签 | 内容 | +|------|------| +| 血条 | 敌方/友方血条尺寸、字号、血量格式、垂直偏移 | +| 施法条 | 敌方/友方施法条尺寸、法术图标开关 | +| 减益 | 减益计时器、仅显示自己的减益、图标大小、连击点设置 | +| 目标 | 箭头样式/大小/偏移/染色、非目标透明度、坦克/输出角色切换 | +| 其他 | 小动物姓名板、法力条、任务怪图标、重置设置 | + +## 命令 + +| 命令 | 功能 | +|------|------| +| `/np` | 打开设置面板 | +| `/np tank` | 切换坦克/输出模式 | +| `/np reset` | 重置所有设置并重载 UI | +| `/np minimap` | 显示/隐藏小地图按钮 | +| `/np debug` | 输出当前目标的 Debuff 调试信息 | + +## 依赖与兼容 + +- **必需:** Nanami-UI 主框架 +- **可选:** TWThreat(仇恨数据源) +- **增强:** SuperWoW — 启用后解锁施法条详情、精确减益追踪、法力条显示等高级功能 +- **自动禁用冲突:** 自动禁用 ShaguTweaks 和 pfUI 的姓名板模块,避免冲突 + +## 安装 + +1. 将 `Nanami-Plates` 文件夹放入 `Interface\AddOns\` +2. 确保已安装 Nanami-UI +3. 进入游戏,按 `V` 键显示姓名板 diff --git a/Scanner.lua b/Scanner.lua new file mode 100644 index 0000000..46bdc40 --- /dev/null +++ b/Scanner.lua @@ -0,0 +1,63 @@ +NanamiPlates_Scanner = {} + +local initializedChildren = 0 +local cachedWorldChildren = {} + +local function CheckRegionForBorder(r) + if r and r.GetObjectType and r:GetObjectType() == "Texture" and r.GetTexture then + return r:GetTexture() == "Interface\\Tooltips\\Nameplate-Border" + end + return false +end + +function NanamiPlates_Scanner.IsNamePlate(frame) + if not frame then return nil end + local objType = frame:GetObjectType() + if objType ~= "Frame" and objType ~= "Button" then return nil end + + local r1, r2, r3, r4, r5, r6 = frame:GetRegions() + if CheckRegionForBorder(r1) then return true end + if CheckRegionForBorder(r2) then return true end + if CheckRegionForBorder(r3) then return true end + if CheckRegionForBorder(r4) then return true end + if CheckRegionForBorder(r5) then return true end + if CheckRegionForBorder(r6) then return true end + + return nil +end + +function NanamiPlates_Scanner.ScanForNewNameplates(registry, callback) + local parentcount = WorldFrame:GetNumChildren() + if initializedChildren >= parentcount then + return false + end + + cachedWorldChildren = { WorldFrame:GetChildren() } + local foundNew = false + + for i = initializedChildren + 1, parentcount do + local plate = cachedWorldChildren[i] + if plate and not registry[plate] then + if NanamiPlates_Scanner.IsNamePlate(plate) then + callback(plate) + foundNew = true + end + end + end + + initializedChildren = parentcount + return foundNew +end + +function NanamiPlates_Scanner.Reset() + initializedChildren = 0 + for k in pairs(cachedWorldChildren) do + cachedWorldChildren[k] = nil + end +end + +function NanamiPlates_Scanner.GetInitializedCount() + return initializedChildren +end + +NanamiPlates.Scanner = NanamiPlates_Scanner diff --git a/SpellDB.lua b/SpellDB.lua new file mode 100644 index 0000000..d35a76d --- /dev/null +++ b/SpellDB.lua @@ -0,0 +1,1484 @@ +-- NanamiPlates Spell Database +-- Debuff duration tracking with rank support (ShaguPlates-style) + +-- Performance: Upvalue frequently used globals +local pairs = pairs +local type = type +local tonumber = tonumber +local string_gfind = string.gfind +local string_sub = string.sub +local GetTime = GetTime + +NanamiPlates_SpellDB = {} +NanamiPlates_SpellDB.scanner = nil + +-- Chinese → English spell name mapping (for localized tooltip scanning) +NanamiPlates_SpellDB.localeMap = { + -- WARRIOR + ["撕裂"] = "Rend", ["雷霆一击"] = "Thunder Clap", ["破甲攻击"] = "Sunder Armor", + ["缴械"] = "Disarm", ["断筋"] = "Hamstring", ["挫志怒吼"] = "Demoralizing Shout", + ["破胆怒吼"] = "Intimidating Shout", ["震荡猛击"] = "Concussion Blow", + ["嘲讽打击"] = "Mocking Blow", ["刺耳怒吼"] = "Piercing Howl", + ["致死打击"] = "Mortal Strike", ["重伤"] = "Deep Wound", + ["冲锋昏迷"] = "Charge Stun", ["冲锋"] = "Charge", + ["拦截昏迷"] = "Intercept Stun", ["拦截"] = "Intercept", + ["挑战怒吼"] = "Challenging Shout", + ["嘲讽"] = "Taunt", + -- ROGUE + ["偷袭"] = "Cheap Shot", ["肾击"] = "Kidney Shot", ["闷棍"] = "Sap", + ["致盲"] = "Blind", ["凿击"] = "Gouge", ["割裂"] = "Rupture", + ["绞喉"] = "Garrote", ["破甲"] = "Expose Armor", + ["致残毒药"] = "Crippling Poison", ["致残毒药 II"] = "Crippling Poison II", + ["致命毒药"] = "Deadly Poison", ["致命毒药 II"] = "Deadly Poison II", + ["致命毒药 III"] = "Deadly Poison III", ["致命毒药 IV"] = "Deadly Poison IV", + ["致命毒药 V"] = "Deadly Poison V", + ["麻痹毒药"] = "Mind-numbing Poison", ["麻痹毒药 II"] = "Mind-numbing Poison II", + ["麻痹毒药 III"] = "Mind-numbing Poison III", + ["伤残毒药"] = "Wound Poison", ["伤残毒药 II"] = "Wound Poison II", + ["伤残毒药 III"] = "Wound Poison III", ["伤残毒药 IV"] = "Wound Poison IV", + ["速效毒药"] = "Instant Poison", + ["脚踢 - 沉默"] = "Kick - Silenced", + -- Turtle WoW rogue variants + ["腐蚀毒药"] = "Crippling Poison", + ["腐蚀毒药 II"] = "Crippling Poison II", + -- MAGE + ["冰霜新星"] = "Frost Nova", ["变形术"] = "Polymorph", + ["变形术:猪"] = "Polymorph: Pig", ["变形术:龟"] = "Polymorph: Turtle", + ["变形术:牛"] = "Polymorph: Cow", + ["寒冰箭"] = "Frostbolt", ["冰锥术"] = "Cone of Cold", + ["冰霜撕咬"] = "Frostbite", ["法术反制 - 沉默"] = "Counterspell - Silenced", + ["冬天的寒意"] = "Winter's Chill", ["火球术"] = "Fireball", + ["炎爆术"] = "Pyroblast", ["点燃"] = "Ignite", + -- WARLOCK + ["腐蚀术"] = "Corruption", ["献祭"] = "Immolate", + ["恐惧术"] = "Fear", ["恐惧嚎叫"] = "Howl of Terror", + ["死亡缠绕"] = "Death Coil", ["痛苦诅咒"] = "Curse of Agony", + ["虚弱诅咒"] = "Curse of Weakness", ["鲁莽诅咒"] = "Curse of Recklessness", + ["语言诅咒"] = "Curse of Tongues", ["元素诅咒"] = "Curse of the Elements", + ["暗影诅咒"] = "Curse of Shadow", ["疲劳诅咒"] = "Curse of Exhaustion", + ["末日诅咒"] = "Curse of Doom", ["生命虹吸"] = "Siphon Life", + ["生命吸取"] = "Drain Life", ["法力吸取"] = "Drain Mana", + ["灵魂吸取"] = "Drain Soul", ["放逐术"] = "Banish", + ["奴役恶魔"] = "Enslave Demon", ["魅惑"] = "Seduction", + ["暗影易伤"] = "Shadow Vulnerability", + ["法术封锁"] = "Spell Lock", + -- PRIEST + ["暗言术:痛"] = "Shadow Word: Pain", ["心灵尖啸"] = "Psychic Scream", + ["精神鞭笞"] = "Mind Flay", ["精神控制"] = "Mind Control", + ["沉默"] = "Silence", ["虚弱灵魂"] = "Weakened Soul", + ["噬灵瘟疫"] = "Devouring Plague", ["吸血鬼的拥抱"] = "Vampiric Embrace", + ["昏厥"] = "Blackout", + -- HUNTER + ["毒蛇钉刺"] = "Serpent Sting", ["蝰蛇钉刺"] = "Viper Sting", + ["毒蝎钉刺"] = "Scorpid Sting", ["震荡射击"] = "Concussive Shot", + ["驱散射击"] = "Scatter Shot", ["摔绊"] = "Wing Clip", + ["猎人印记"] = "Hunter's Mark", ["反击"] = "Counterattack", + ["翼龙钉刺"] = "Wyvern Sting", ["冰冻陷阱效果"] = "Freezing Trap Effect", + ["爆炸陷阱效果"] = "Explosive Trap Effect", ["献祭陷阱效果"] = "Immolation Trap Effect", + ["冰霜陷阱光环"] = "Frost Trap Aura", ["胁迫"] = "Intimidation", + ["诱捕"] = "Entrapment", ["恐吓野兽"] = "Scare Beast", + -- DRUID + ["月火术"] = "Moonfire", ["纠缠根须"] = "Entangling Roots", + ["重击"] = "Bash", ["精灵之火"] = "Faerie Fire", + ["精灵之火(野性)"] = "Faerie Fire (Feral)", + ["撕碎"] = "Rake", ["撕扯"] = "Rip", ["突袭流血"] = "Pounce Bleed", + ["突袭"] = "Pounce", ["虫群"] = "Insect Swarm", + ["休眠"] = "Hibernate", ["野性冲锋效果"] = "Feral Charge Effect", + ["低吼"] = "Growl", ["挑战咆哮"] = "Challenging Roar", + ["挫志咆哮"] = "Demoralizing Roar", + -- PALADIN + ["制裁之锤"] = "Hammer of Justice", ["忏悔"] = "Repentance", + ["超度亡灵"] = "Turn Undead", + ["十字军审判"] = "Judgement of the Crusader", + ["光明审判"] = "Judgement of Light", ["智慧审判"] = "Judgement of Wisdom", + ["公正审判"] = "Judgement of Justice", ["审判"] = "Judgement", + -- SHAMAN + ["冰霜震击"] = "Frost Shock", ["地震术"] = "Earth Shock", + ["烈焰震击"] = "Flame Shock", ["地缚"] = "Earthbind", + ["风暴打击"] = "Stormstrike", + ["报应"] = "Vindication", + ["十字军打击"] = "Crusader Strike", + -- SHAMAN (extra) + ["石爪昏迷"] = "Stoneclaw Stun", + -- OTHER + ["战争践踏"] = "War Stomp", ["眩晕"] = "Dazed", + ["锤击昏迷效果"] = "Mace Stun Effect", + ["盾击 - 沉默"] = "Shield Bash - Silenced", + ["复仇昏迷"] = "Revenge Stun", + ["撕裂(宠物)"] = "Lacerate", + ["冲击"] = "Impact", + ["余波"] = "Aftermath", + ["火焰风暴"] = "Pyroclasm", + ["还击"] = "Riposte", + -- ENGINEERING / ITEMS + ["铁皮手雷"] = "Iron Grenade", + ["瑟银手雷"] = "Thorium Grenade", + ["闪光弹"] = "Flash Bomb", + ["简易炸药"] = "Ez-Thro Dynamite", + ["简易炸药 II"] = "Ez-Thro Dynamite II", + ["致密炸药"] = "Dense Dynamite", + ["固体炸药"] = "Solid Dynamite", + ["高爆炸弹"] = "Hi-Explosive Bomb", + ["黑铁炸弹"] = "Dark Iron Bomb", + ["地精工兵炸药"] = "Goblin Sapper Charge", + ["地精迫击炮"] = "Goblin Mortar", + ["大炸弹"] = "The Big One", + ["电网发射器"] = "Net-o-Matic", + ["鲁莽冲锋"] = "Reckless Charge", + ["侏儒精神控制帽"] = "Gnomish Mind Control Cap", + ["侏儒电网发射器"] = "Gnomish Net-o-Matic", + ["侏儒死亡射线"] = "Gnomish Death Ray", + ["地精火箭头盔"] = "Goblin Rocket Helmet", + ["冰霜手雷"] = "Frost Grenade", + ["粗制铜质炸弹"] = "Rough Copper Bomb", + ["大型铜质炸弹"] = "Large Copper Bomb", + ["小型青铜炸弹"] = "Small Bronze Bomb", + ["大型青铜炸弹"] = "Big Bronze Bomb", + ["大型铁质炸弹"] = "Big Iron Bomb", + ["地精地雷"] = "Goblin Land Mine", + ["混乱射线"] = "Discombobulator Ray", + ["大绳索网"] = "Large Rope Net", + ["陷阱"] = "Trap", + ["自由行动药水"] = "Free Action Potion", + ["活力行动药水"] = "Living Action Potion", + ["潮汐护符"] = "Tidal Charm", + ["昏迷"] = "Stun", +} + +-- ONLY unique texture → spell mappings here. +-- Ambiguous textures (shared by multiple spells) are omitted; tooltip + locale handles them. +NanamiPlates_SpellDB.textureToSpell = { + -- Warrior (unique only) + ["Interface\\Icons\\Ability_Rend"] = "Rend", + ["Interface\\Icons\\Ability_Warrior_Decimate"] = "Improved Hamstring", + ["Interface\\Icons\\Ability_Warrior_Sunder"] = "Sunder Armor", + ["Interface\\Icons\\Ability_BullRush"] = "Challenging Shout", + + -- Warlock (unique only) + ["Interface\\Icons\\Spell_Shadow_LifeDrain"] = "Tainted Blood Effect", + ["Interface\\Icons\\Spell_Shadow_SoulLeech"] = "Dark Harvest", + ["Interface\\Icons\\Spell_Shadow_AbominationExplosion"] = "Corruption", + ["Interface\\Icons\\Spell_Shadow_CurseOfSargeras"] = "Curse of Agony", + ["Interface\\Icons\\Spell_Shadow_CurseOfMannoroth"] = "Curse of Weakness", + ["Interface\\Icons\\Spell_Shadow_UnholyStrength"] = "Curse of Recklessness", + ["Interface\\Icons\\Spell_Shadow_CurseOfTounges"] = "Curse of Tongues", + ["Interface\\Icons\\Spell_Shadow_ChillTouch"] = "Curse of the Elements", + ["Interface\\Icons\\Spell_Shadow_CurseOfAchimonde"] = "Curse of Shadow", + ["Interface\\Icons\\Spell_Shadow_GrimWard"] = "Curse of Exhaustion", + ["Interface\\Icons\\Spell_Shadow_AuraOfDarkness"] = "Curse of Doom", + ["Interface\\Icons\\Spell_Shadow_MindRot"] = "Curse of Idiocy", + ["Interface\\Icons\\Spell_Fire_Immolation"] = "Immolate", + ["Interface\\Icons\\Spell_Shadow_Requiem"] = "Siphon Life", + ["Interface\\Icons\\Spell_Shadow_Cripple"] = "Banish", + + -- Druid (unique only) + ["Interface\\Icons\\Spell_Nature_FaerieFire"] = "Faerie Fire", + ["Interface\\Icons\\Ability_Druid_Disembowel"] = "Rake", + ["Interface\\Icons\\Ability_Druid_Rip"] = "Rip", + ["Interface\\Icons\\Ability_Druid_SupriseAttack"] = "Pounce Bleed", + ["Interface\\Icons\\Ability_Druid_ChallangingRoar"] = "Challenging Roar", + ["Interface\\Icons\\Spell_Nature_StarFall"] = "Moonfire", + ["Interface\\Icons\\Spell_Nature_StrangleVines"] = "Entangling Roots", + ["Interface\\Icons\\Spell_Nature_InsectSwarm"] = "Insect Swarm", + ["Interface\\Icons\\Ability_Druid_DemoralizingRoar"] = "Demoralizing Roar", + ["Interface\\Icons\\Ability_Druid_Mangle"] = "Mangle", + + -- Paladin (unique only) + ["Interface\\Icons\\Spell_Holy_Vindication"] = "Vindication", + + -- Hunter (unique only) + ["Interface\\Icons\\spell_lacerate_1C"] = "Lacerate", + ["Interface\\Icons\\Spell_Frost_ChainsOfIce"] = "Freezing Trap Effect", + ["Interface\\Icons\\Spell_Fire_SelfDestruct"] = "Explosive Trap Effect", + ["Interface\\Icons\\Spell_Frost_FreezingBreath"] = "Frost Trap Aura", + + -- Rogue (unique only) + ["Interface\\Icons\\Ability_CheapShot"] = "Cheap Shot", + ["Interface\\Icons\\Ability_Rogue_KidneyShot"] = "Kidney Shot", + ["Interface\\Icons\\Ability_Sap"] = "Sap", + ["Interface\\Icons\\Ability_Rogue_Rupture"] = "Rupture", + ["Interface\\Icons\\Ability_Rogue_Garrote"] = "Garrote", + + -- Other (unique only) + ["Interface\\Icons\\Spell_Nature_Cyclone"] = "Thunderfury's Blessing", +} + +-- Preferred names for textures when encountered as DEBUFFS (context-aware priority) +NanamiPlates_SpellDB.debuffPriority = { + ["Interface\\Icons\\Spell_Holy_HealingAura"] = "Judgement of Light", + ["Interface\\Icons\\Spell_Holy_RighteousnessAura"] = "Judgement of Wisdom", + ["Interface\\Icons\\Spell_Holy_HolySmite"] = "Judgement of the Crusader", + ["Interface\\Icons\\Spell_Holy_SealOfWrath"] = "Judgement of Justice", +} + +-- ============================================ +-- DEBUFF DURATIONS BY SPELL NAME AND RANK +-- Format: ["Spell Name"] = { [rank] = duration, [0] = default/max } +-- ============================================ +NanamiPlates_SpellDB.DEBUFFS = { + -- WARRIOR + ["Rend"] = {[1]=9, [2]=12, [3]=15, [4]=18, [5]=21, [6]=21, [7]=21, [0]=21}, + ["Thunder Clap"] = {[1]=10, [2]=14, [3]=18, [4]=22, [5]=26, [6]=30, [0]=30}, + ["Sunder Armor"] = {[0]=30}, + ["Disarm"] = {[0]=10}, + ["Hamstring"] = {[0]=15}, + ["Improved Hamstring"] = {[0]=5}, + ["Demoralizing Shout"] = {[0]=30}, + ["Intimidating Shout"] = {[0]=8}, + ["Concussion Blow"] = {[0]=5}, + ["Mocking Blow"] = {[0]=6}, + ["Piercing Howl"] = {[0]=6}, + ["Mortal Strike"] = {[0]=10}, + ["Deep Wound"] = {[0]=12}, + ["Charge"] = {[0]=1}, + ["Charge Stun"] = {[0]=1}, + ["Intercept"] = {[0]=3}, + ["Intercept Stun"] = {[0]=3}, + ["Challenging Shout"] = {[0]=6}, + ["Demoralizing Roar"] = {[0]=30}, + ["Dazed"] = {[0]=4}, + ["Taunt"] = {[0]=3}, + + -- ROGUE + ["Cheap Shot"] = {[0]=4}, + ["Kidney Shot"] = {[1]=0, [2]=1, [0]=1}, -- Rank1: 0+1s/CP, Rank2: 1+1s/CP + ["Sap"] = {[1]=25, [2]=35, [3]=45, [0]=45}, + ["Blind"] = {[0]=10}, + ["Gouge"] = {[1]=4, [2]=4, [3]=4, [4]=4, [5]=4, [0]=4}, -- base 4s, +0.5s/talent + ["Rupture"] = {[0]=8}, -- base 8s + 2s/CP (via DYN_DEBUFFS) + ["Garrote"] = {[1]=18, [2]=18, [3]=18, [4]=18, [5]=18, [0]=18}, + ["Expose Armor"] = {[0]=30}, + ["Crippling Poison"] = {[0]=12}, + ["Crippling Poison II"] = {[0]=12}, + ["Deadly Poison"] = {[0]=12}, + ["Deadly Poison II"] = {[0]=12}, + ["Deadly Poison III"] = {[0]=12}, + ["Deadly Poison IV"] = {[0]=12}, + ["Deadly Poison V"] = {[0]=12}, + ["Mind-numbing Poison"] = {[0]=14}, + ["Mind-numbing Poison II"] = {[0]=14}, + ["Mind-numbing Poison III"] = {[0]=14}, + ["Wound Poison"] = {[0]=15}, + ["Wound Poison II"] = {[0]=15}, + ["Wound Poison III"] = {[0]=15}, + ["Wound Poison IV"] = {[0]=15}, + ["Instant Poison"] = {[0]=0}, + ["Instant Poison II"] = {[0]=0}, + ["Instant Poison III"] = {[0]=0}, + ["Instant Poison IV"] = {[0]=0}, + ["Instant Poison V"] = {[0]=0}, + ["Instant Poison VI"] = {[0]=0}, + + -- MAGE + ["Frost Nova"] = {[1]=8, [2]=8, [3]=8, [4]=8, [0]=8}, + ["Polymorph"] = {[1]=20, [2]=30, [3]=40, [4]=50, [0]=50}, + ["Polymorph: Pig"] = {[0]=50}, + ["Polymorph: Turtle"] = {[0]=50}, + ["Polymorph: Cow"] = {[0]=50}, + ["Frostbolt"] = {[1]=5, [2]=6, [3]=7, [4]=8, [5]=9, [6]=9, [7]=9, [8]=9, [9]=9, [10]=9, [11]=9, [0]=9}, + ["Cone of Cold"] = {[0]=8}, + ["Frostbite"] = {[0]=5}, + ["Counterspell - Silenced"] = {[0]=4}, + ["Winter's Chill"] = {[0]=15}, + ["Fireball"] = {[0]=8}, -- DoT component + ["Pyroblast"] = {[0]=12}, -- DoT component + ["Ignite"] = {[0]=4}, + ["Fire Vulnerability"] = {[0]=30}, + + -- WARLOCK + ["Corruption"] = {[1]=12, [2]=15, [3]=18, [4]=18, [5]=18, [6]=18, [7]=18, [0]=18}, + ["Immolate"] = {[1]=15, [2]=15, [3]=15, [4]=15, [5]=15, [6]=15, [7]=15, [8]=15, [0]=15}, + ["Fear"] = {[1]=10, [2]=15, [3]=20, [0]=20}, + ["Howl of Terror"] = {[1]=10, [2]=15, [0]=15}, + ["Death Coil"] = {[0]=3}, + ["Curse of Agony"] = {[1]=24, [2]=24, [3]=24, [4]=24, [5]=24, [6]=24, [0]=24}, + ["Curse of Weakness"] = {[0]=120}, + ["Curse of Recklessness"] = {[0]=120}, + ["Curse of Tongues"] = {[0]=30}, + ["Curse of the Elements"] = {[0]=300}, + ["Curse of Shadow"] = {[0]=300}, + ["Curse of Exhaustion"] = {[0]=12}, + ["Curse of Doom"] = {[0]=60}, + ["Siphon Life"] = {[0]=30}, + ["Drain Life"] = {[0]=5}, + ["Drain Mana"] = {[0]=5}, + ["Drain Soul"] = {[0]=15}, + ["Banish"] = {[1]=20, [2]=30, [0]=30}, + ["Enslave Demon"] = {[0]=300}, + ["Seduction"] = {[0]=15}, + ["Shadow Vulnerability"] = {[0]=30}, + ["Dark Harvest"] = {[0]=8}, + + -- PRIEST + ["Shadow Word: Pain"] = {[1]=18, [2]=18, [3]=18, [4]=18, [5]=18, [6]=18, [7]=18, [8]=18, [0]=18}, + ["Psychic Scream"] = {[1]=8, [2]=8, [3]=8, [4]=8, [0]=8}, + ["Mind Flay"] = {[0]=3}, + ["Mind Control"] = {[0]=60}, + ["Silence"] = {[0]=5}, + ["Weakened Soul"] = {[0]=15}, + ["Devouring Plague"] = {[0]=24}, + ["Vampiric Embrace"] = {[0]=60}, + ["Blackout"] = {[0]=3}, + ["Mana Burn"] = {[0]=0}, -- instant + ["Touch of Weakness"] = {[0]=120}, + ["Mind Soothe"] = {[0]=15}, + + -- HUNTER + ["Serpent Sting"] = {[1]=15, [2]=15, [3]=15, [4]=15, [5]=15, [6]=15, [7]=15, [8]=15, [9]=15, [0]=15}, + ["Viper Sting"] = {[1]=8, [2]=8, [3]=8, [4]=8, [0]=8}, + ["Scorpid Sting"] = {[0]=20}, + ["Concussive Shot"] = {[0]=4}, + ["Scatter Shot"] = {[0]=4}, + ["Wing Clip"] = {[0]=10}, + ["Improved Concussive Shot"] = {[0]=3}, + ["Hunter's Mark"] = {[0]=120}, + ["Counterattack"] = {[0]=5}, + ["Wyvern Sting"] = {[0]=12}, -- sleep, then 12s DoT + ["Freezing Trap Effect"] = {[1]=10,[2]=15,[3]=20,[0]=20}, + ["Immolation Trap Effect"] = {[0]=15}, + ["Explosive Trap Effect"] = {[0]=20}, + ["Frost Trap Aura"] = {[0]=8}, + ["Intimidation"] = {[0]=3}, + ["Entrapment"] = {[0]=5}, + ["Lacerate"] = {[0]=8}, + ["Scare Beast"] = {[1]=10,[2]=15,[3]=20,[0]=20}, + + -- DRUID + ["Moonfire"] = {[1]=9, [2]=18, [3]=18, [4]=18, [5]=18, [6]=18, [7]=18, [8]=18, [9]=18, [10]=18, [0]=18}, + ["Entangling Roots"] = {[1]=12, [2]=15, [3]=18, [4]=21, [5]=24, [6]=27, [0]=27}, + ["Bash"] = {[1]=2, [2]=3, [3]=4, [0]=4}, + ["Faerie Fire"] = {[0]=40}, + ["Faerie Fire (Feral)"] = {[0]=40}, + ["Rake"] = {[1]=9, [2]=9, [3]=9, [4]=9, [0]=9}, + ["Rip"] = {[0]=8}, -- +2s per combo point, handled dynamically + ["Pounce Bleed"] = {[0]=18}, + ["Pounce"] = {[0]=3}, -- stun component + ["Insect Swarm"] = {[0]=12}, + ["Hibernate"] = {[1]=20, [2]=30, [3]=40, [0]=40}, + ["Feral Charge Effect"] = {[0]=4}, + ["Challenging Roar"] = {[0]=6}, + ["Mangle"] = {[0]=12}, + ["Growl"] = {[0]=3}, + + -- PALADIN + ["Hammer of Justice"] = {[1]=3, [2]=4, [3]=5, [4]=6, [0]=6}, + ["Turn Undead"] = {[0]=20}, + ["Repentance"] = {[0]=6}, + ["Crusader Strike"] = {[0]=30}, + ["Judgement of the Crusader"] = {[0]=10}, + ["Judgement of Light"] = {[0]=10}, + ["Judgement of Wisdom"] = {[0]=10}, + ["Judgement of Justice"] = {[0]=10}, + ["Judgement"] = {[0]=10}, + ["Vindication"] = {[0]=10}, + + -- SHAMAN + ["Frost Shock"] = {[1]=8, [2]=8, [3]=8, [4]=8, [0]=8}, + ["Earth Shock"] = {[0]=2}, -- interrupt + ["Flame Shock"] = {[1]=12, [2]=12, [3]=12, [4]=12, [5]=12, [6]=12, [0]=12}, + ["Earthbind"] = {[0]=5}, -- per pulse + ["Stoneclaw Stun"] = {[0]=3}, + ["Stormstrike"] = {[0]=12}, + + -- Felhunter + ["Tainted Blood Effect"] = {[0]=10}, + ["Spell Lock"] = {[0]=6}, + + -- MISC / PVP / RACIAL / PROCS + ["War Stomp"] = {[0]=2}, + ["Tidal Charm"] = {[0]=3}, + ["Impact"] = {[0]=2}, + ["Aftermath"] = {[0]=5}, + ["Pyroclasm"] = {[0]=3}, + ["Mace Stun Effect"] = {[0]=3}, + ["Blackout"] = {[0]=3}, + ["Revenge Stun"] = {[0]=3}, + ["Sleep"] = {[1]=20, [2]=30, [0]=30}, + ["Riposte"] = {[0]=6}, + ["Shield Bash - Silenced"] = {[0]=6}, + ["Kick - Silenced"] = {[0]=2}, + ["Stun"] = {[0]=3}, + + -- ENGINEERING / ITEMS + ["Iron Grenade"] = {[0]=3}, + ["Thorium Grenade"] = {[0]=3}, + ["Flash Bomb"] = {[0]=10}, + ["Ez-Thro Dynamite"] = {[0]=2}, + ["Ez-Thro Dynamite II"] = {[0]=2}, + ["Dense Dynamite"] = {[0]=5}, + ["Solid Dynamite"] = {[0]=3}, + ["Hi-Explosive Bomb"] = {[0]=3}, + ["Dark Iron Bomb"] = {[0]=4}, + ["Goblin Sapper Charge"] = {[0]=0}, + ["Goblin Mortar"] = {[0]=3}, + ["The Big One"] = {[0]=5}, + ["Net-o-Matic"] = {[0]=10}, + ["Reckless Charge"] = {[0]=3}, + ["Gnomish Mind Control Cap"] = {[0]=20}, + ["Gnomish Net-o-Matic"] = {[0]=10}, + ["Gnomish Death Ray"] = {[0]=0}, + ["Goblin Rocket Helmet"] = {[0]=30}, + ["Frost Grenade"] = {[0]=5}, + ["Rough Copper Bomb"] = {[0]=2}, + ["Large Copper Bomb"] = {[0]=2}, + ["Small Bronze Bomb"] = {[0]=2}, + ["Big Bronze Bomb"] = {[0]=3}, + ["Big Iron Bomb"] = {[0]=3}, + ["Goblin Land Mine"] = {[0]=5}, + ["Discombobulator Ray"] = {[0]=12}, + ["Large Rope Net"] = {[0]=10}, + ["Trap"] = {[0]=10}, + + -- CONSUMABLES + ["Free Action Potion"] = {[0]=30}, + ["Living Action Potion"] = {[0]=5}, + ["Skull of Impending Doom"] = {[0]=0}, + ["Tidal Charm"] = {[0]=3}, + + -- Other + ["Thunderfury"] = {[0]=12}, + ["Thunderfury's Blessing"] = {[0]=12}, +} + +-- Dynamic debuffs that scale with combo points +NanamiPlates_SpellDB.COMBO_POINT_DEBUFFS = { + ["Kidney Shot"] = true, + ["Rupture"] = true, + ["Rip"] = true, +} + +-- Dynamic debuffs that scale with talents +NanamiPlates_SpellDB.DYN_DEBUFFS = { + ["Rupture"] = "Rupture", + ["Kidney Shot"] = "Kidney Shot", + ["Rip"] = "Rip", + ["Rend"] = "Rend", + ["Shadow Word: Pain"] = "Shadow Word: Pain", + ["Demoralizing Shout"] = "Demoralizing Shout", + ["Frostbolt"] = "Frostbolt", + ["Gouge"] = "Gouge", +} + +-- Shared debuffs that can only exist once on a target (shared across all players of the same class) +NanamiPlates_SpellDB.SHARED_DEBUFFS = { + -- Rogue + ["Expose Armor"] = "ROGUE", + -- Mage + ["Winter's Chill"] = "MAGE", + ["Fire Vulnerability"] = "MAGE", + ["Frostbolt"] = "MAGE", + -- Warrior + ["Thunder Clap"] = "WARRIOR", + ["Demoralizing Shout"] = "WARRIOR", + ["Sunder Armor"] = "WARRIOR", + ["Challenging Shout"] = "WARRIOR", + ["Hamstring"] = "WARRIOR", + ["Mortal Strike"] = "WARRIOR", + ["Piercing Howl"] = "WARRIOR", + ["Disarm"] = "WARRIOR", + ["Taunt"] = true, + -- Druid + ["Faerie Fire"] = "DRUID", + ["Faerie Fire (Feral)"] = "DRUID", + ["Demoralizing Roar"] = "DRUID", + ["Entangling Roots"] = "DRUID", + ["Hibernate"] = "DRUID", + ["Bash"] = "DRUID", + ["Pounce"] = "DRUID", + ["Challenging Roar"] = "DRUID", + ["Feral Charge Effect"] = "DRUID", + ["Mangle"] = "DRUID", + ["Growl"] = true, + -- Priest + ["Shadow Vulnerability"] = "PRIEST", -- Can also be Warlock, but primarily Priest + ["Silence"] = "PRIEST", + ["Touch of Weakness"] = "PRIEST", + ["Mind Soothe"] = "PRIEST", + -- Warlock (curses are shared, but Malediction allows CoA + long curse) + ["Curse of Agony"] = "WARLOCK", + ["Curse of Weakness"] = "WARLOCK", + ["Curse of Recklessness"] = "WARLOCK", + ["Curse of Tongues"] = "WARLOCK", + ["Curse of the Elements"] = "WARLOCK", + ["Curse of Shadow"] = "WARLOCK", + ["Curse of Exhaustion"] = "WARLOCK", + ["Curse of Doom"] = "WARLOCK", + ["Curse of Idiocy"] = "WARLOCK", + -- Hunter + ["Hunter's Mark"] = "HUNTER", + ["Scorpid Sting"] = "HUNTER", + ["Scare Beast"] = "HUNTER", + ["Freezing Trap Effect"] = "HUNTER", + ["Immolation Trap Effect"] = "HUNTER", + ["Explosive Trap Effect"] = "HUNTER", + ["Frost Trap Aura"] = "HUNTER", + ["Entrapment"] = "HUNTER", + + -- Paladin + ["Hammer of Justice"] = "PALADIN", + ["Repentance"] = "PALADIN", + ["Crusader Strike"] = "PALADIN", + ["Vindication"] = "PALADIN", + ["Judgement of the Crusader"] = "PALADIN", + ["Judgement of Light"] = "PALADIN", + ["Judgement of Wisdom"] = "PALADIN", + ["Judgement of Justice"] = "PALADIN", + ["Judgement"] = "PALADIN", + -- Other + ["Thunderfury"] = true, + ["Thunderfury's Blessing"] = true, + ["Gift of Arthas"] = true, + ["Spell Vulnerability"] = true, + ["Armor Shatter"] = true, +} + +-- Rogue Poisons - always show for Rogue players with "Only My Debuffs" enabled +-- Since only Rogues can apply these, they are always "mine" when present +NanamiPlates_SpellDB.ROGUE_POISONS = { + ["Crippling Poison"] = true, + ["Crippling Poison II"] = true, + ["Deadly Poison"] = true, + ["Deadly Poison II"] = true, + ["Deadly Poison III"] = true, + ["Deadly Poison IV"] = true, + ["Deadly Poison V"] = true, + ["Instant Poison"] = true, + ["Instant Poison II"] = true, + ["Instant Poison III"] = true, + ["Instant Poison IV"] = true, + ["Instant Poison V"] = true, + ["Instant Poison VI"] = true, + ["Mind-numbing Poison"] = true, + ["Mind-numbing Poison II"] = true, + ["Mind-numbing Poison III"] = true, + ["Wound Poison"] = true, + ["Wound Poison II"] = true, + ["Wound Poison III"] = true, + ["Wound Poison IV"] = true, +} + +-- Rogue Poison TEXTURES - for icon-based detection when tooltip scanning fails +-- This ensures poisons are detected even without spell name +NanamiPlates_SpellDB.ROGUE_POISON_TEXTURES = { + -- Crippling Poison + ["Interface\\Icons\\Ability_PoisonSting"] = "Crippling Poison", + -- Deadly Poison + ["Interface\\Icons\\Ability_Rogue_DualWeild"] = "Deadly Poison", + -- Instant Poison + ["Interface\\Icons\\Ability_Poisons"] = "Instant Poison", + -- Mind-numbing Poison + ["Interface\\Icons\\Spell_Nature_NullifyDisease"] = "Mind-numbing Poison", + -- Wound Poison + ["Interface\\Icons\\INV_Misc_Herb_16"] = "Wound Poison", +} + +-- Hunter Traps - show for Hunter players when "Only My Debuffs" is enabled +-- These are placed on ground and triggered by enemies, so ownership can't be tracked reliably +NanamiPlates_SpellDB.HUNTER_TRAPS = { + ["Freezing Trap Effect"] = true, + ["Immolation Trap Effect"] = true, + ["Explosive Trap Effect"] = true, + ["Frost Trap Aura"] = true, + ["Entrapment"] = true, +} + +-- Hunter Trap TEXTURES - for icon-based detection when tooltip scanning fails +NanamiPlates_SpellDB.HUNTER_TRAP_TEXTURES = { + ["Interface\\Icons\\Spell_Frost_ChainsOfIce"] = "Freezing Trap Effect", + ["Interface\\Icons\\Spell_Fire_FlameShock"] = "Immolation Trap Effect", + ["Interface\\Icons\\Spell_Fire_SelfDestruct"] = "Explosive Trap Effect", + ["Interface\\Icons\\Spell_Frost_FreezingBreath"] = "Frost Trap Aura", + ["Interface\\Icons\\Spell_Frost_FrostNova"] = "Frost Trap Aura", + ["Interface\\Icons\\Ability_Ensnare"] = "Entrapment", +} + +-- Hunter Stings - for Hunter players, treat as owned for reliable display +NanamiPlates_SpellDB.HUNTER_STINGS = { + ["Serpent Sting"] = true, + ["Viper Sting"] = true, + ["Scorpid Sting"] = true, + ["Wyvern Sting"] = true, +} + +-- ============================================ +-- WARLOCK MALEDICTION SUPPORT (TurtleWoW) +-- Malediction talent allows 2 curses: 1 long curse + Curse of Agony +-- ============================================ + +-- All Warlock curses - for detection when "Only My Debuffs" is enabled +-- Since only Warlocks can apply curses, treat any visible curse as "mine" +NanamiPlates_SpellDB.WARLOCK_CURSES = { + ["Curse of Agony"] = true, + ["Curse of Weakness"] = true, + ["Curse of Recklessness"] = true, + ["Curse of Tongues"] = true, + ["Curse of the Elements"] = true, + ["Curse of Shadow"] = true, + ["Curse of Exhaustion"] = true, + ["Curse of Doom"] = true, + ["Curse of Idiocy"] = true, +} + +-- Warlock Curse TEXTURES - for icon-based detection when tooltip scanning fails +NanamiPlates_SpellDB.WARLOCK_CURSE_TEXTURES = { + ["Interface\\Icons\\Spell_Shadow_CurseOfSargeras"] = "Curse of Agony", + ["Interface\\Icons\\Spell_Shadow_CurseOfMannoroth"] = "Curse of Weakness", + ["Interface\\Icons\\Spell_Shadow_UnholyStrength"] = "Curse of Recklessness", + ["Interface\\Icons\\Spell_Shadow_CurseOfTounges"] = "Curse of Tongues", + ["Interface\\Icons\\Spell_Shadow_ChillTouch"] = "Curse of the Elements", + ["Interface\\Icons\\Spell_Shadow_CurseOfAchimonde"] = "Curse of Shadow", + ["Interface\\Icons\\Spell_Shadow_GrimWard"] = "Curse of Exhaustion", + ["Interface\\Icons\\Spell_Shadow_AuraOfDarkness"] = "Curse of Doom", + ["Interface\\Icons\\Spell_Shadow_MindRot"] = "Curse of Idiocy", +} + +-- All curses except Agony and Doom that can coexist with Agony under Malediction +NanamiPlates_SpellDB.WARLOCK_LONG_CURSES = { + ["Curse of Weakness"] = true, + ["Curse of Recklessness"] = true, + ["Curse of Tongues"] = true, + ["Curse of the Elements"] = true, + ["Curse of Shadow"] = true, + ["Curse of Exhaustion"] = true, + ["Curse of Idiocy"] = true, +} + +-- Curse of Agony (short curse that can coexist with long curses under Malediction) +NanamiPlates_SpellDB.WARLOCK_AGONY_CURSE = "Curse of Agony" + +-- Curses that auto-apply highest rank Curse of Agony when Malediction is active +NanamiPlates_SpellDB.MALEDICTION_AUTO_AGONY = { + ["Curse of Recklessness"] = true, + ["Curse of Shadow"] = true, + ["Curse of the Elements"] = true, +} + +-- Cache for Corruption haste talent check ("快速衰弱" / "Nightfall" in TurtleWoW) +-- This talent makes Corruption instant but shortens its tick interval and total +-- duration by the player's total spell haste percentage. +NanamiPlates_SpellDB.corruptionHasteCache = { + hasChecked = false, + talentRank = 0, + lastCheck = 0, +} + +function NanamiPlates_SpellDB:GetCorruptionHasteRank() + local now = GetTime() + local cache = self.corruptionHasteCache + + if cache.hasChecked and (now - cache.lastCheck) < 10 then + return cache.talentRank + end + + local _, playerClass = UnitClass("player") + if playerClass ~= "WARLOCK" then + cache.hasChecked = true + cache.talentRank = 0 + cache.lastCheck = now + return 0 + end + + for i = 1, 25 do + local name, _, _, _, rank, maxRank = GetTalentInfo(1, i) + if not name then break end + if maxRank == 2 and (name == "\229\191\171\233\128\159\232\161\176\229\188\177" + or name == "Nightfall" or name == "Rapid Deterioration") then + cache.hasChecked = true + cache.talentRank = rank or 0 + cache.lastCheck = now + return cache.talentRank + end + end + + cache.hasChecked = true + cache.talentRank = 0 + cache.lastCheck = now + return 0 +end + +-- Cache for Malediction talent check +NanamiPlates_SpellDB.maledictionCache = { + hasChecked = false, + hasMalediction = false, + lastCheck = 0, +} + +-- Check if player has Malediction talent (TurtleWoW Affliction tree) +-- Malediction is in Affliction tree (tree 1) - typically around tier 5-6 +function NanamiPlates_SpellDB:HasMalediction() + local now = GetTime() + local cache = self.maledictionCache + + -- Cache result for 5 seconds to avoid excessive talent queries + if cache.hasChecked and (now - cache.lastCheck) < 5 then + return cache.hasMalediction + end + + -- Only check for Warlocks + local _, playerClass = UnitClass("player") + if playerClass ~= "WARLOCK" then + cache.hasChecked = true + cache.hasMalediction = false + cache.lastCheck = now + return false + end + + -- Search Affliction tree (tree 1) for Malediction talent + -- TurtleWoW places it around tier 5-6, we scan all talents to be safe + for i = 1, 25 do + local name, _, _, _, rank, maxRank = GetTalentInfo(1, i) + if not name then break end + if maxRank == 1 and (name == "Malediction" + or name == "\233\130\170\229\146\146") and rank and rank > 0 then + cache.hasChecked = true + cache.hasMalediction = true + cache.lastCheck = now + return true + end + end + + cache.hasChecked = true + cache.hasMalediction = false + cache.lastCheck = now + return false +end + +-- Check if two curses can coexist (Malediction allows long curse + Curse of Agony) +-- Curse of Doom cannot coexist with Agony even with Malediction +function NanamiPlates_SpellDB:CanCursesCoexist(curse1, curse2) + if not self:HasMalediction() then + return false + end + + if curse1 == "Curse of Doom" or curse2 == "Curse of Doom" then + return false + end + + local isAgony1 = (curse1 == self.WARLOCK_AGONY_CURSE) + local isAgony2 = (curse2 == self.WARLOCK_AGONY_CURSE) + local isLong1 = self.WARLOCK_LONG_CURSES and self.WARLOCK_LONG_CURSES[curse1] + local isLong2 = self.WARLOCK_LONG_CURSES and self.WARLOCK_LONG_CURSES[curse2] + + return (isAgony1 and isLong2) or (isAgony2 and isLong1) +end + +-- Check if a curse is a warlock curse (for shared debuff handling) +function NanamiPlates_SpellDB:IsWarlockCurse(effect) + return effect == self.WARLOCK_AGONY_CURSE or self.WARLOCK_LONG_CURSES[effect] +end + +-- Hunter Sting TEXTURES - for icon-based detection when tooltip scanning fails +NanamiPlates_SpellDB.HUNTER_STING_TEXTURES = { + ["Interface\\Icons\\Ability_Hunter_Quickshot"] = "Serpent Sting", + ["Interface\\Icons\\Ability_Hunter_AimedShot"] = "Viper Sting", + ["Interface\\Icons\\Ability_Hunter_CriticalShot"] = "Scorpid Sting", + ["Interface\\Icons\\INV_Spear_02"] = "Wyvern Sting", +} + +-- Warlock DOT TEXTURES - for icon-based detection when tooltip scanning returns localized names +NanamiPlates_SpellDB.WARLOCK_DOT_TEXTURES = { + ["Interface\\Icons\\Spell_Shadow_AbominationExplosion"] = "Corruption", + ["Interface\\Icons\\Spell_Shadow_Requiem"] = "Siphon Life", + ["Interface\\Icons\\Spell_Fire_Immolation"] = "Immolate", + ["Interface\\Icons\\Spell_Shadow_SoulLeech"] = "Dark Harvest", +} + +-- Debuffs that are bound to the owner (should be visible when "Only My Debuffs" is active) +NanamiPlates_SpellDB.OWNER_BOUND_DEBUFFS = { + -- Warrior + ["Rend"] = true, + ["Deep Wound"] = true, + + -- Warlock + ["Immolate"] = true, + ["Corruption"] = true, + ["Curse of Agony"] = true, + ["Curse of Weakness"] = true, + ["Curse of Recklessness"] = true, + ["Curse of Tongues"] = true, + ["Curse of the Elements"] = true, + ["Curse of Shadow"] = true, + ["Curse of Exhaustion"] = true, + ["Curse of Doom"] = true, + ["Curse of Idiocy"] = true, + ["Siphon Life"] = true, + + -- Priest + ["Shadow Word: Pain"] = true, + ["Devouring Plague"] = true, + + -- Druid + ["Moonfire"] = true, + ["Insect Swarm"] = true, + ["Rip"] = true, + ["Rake"] = true, + ["Pounce Bleed"] = true, + + -- Rogue (excluding poisons - those are handled via ROGUE_POISONS visibility exception) + ["Sap"] = true, + ["Kidney Shot"] = true, + ["Blind"] = true, + ["Garrote"] = true, + ["Rupture"] = true, + ["Gouge"] = true, + + -- Hunter + ["Serpent Sting"] = true, + ["Wing Clip"] = true, + ["Lacerate"] = true, + + -- Mage + ["Ignite"] = true, + + -- Paladin + ["Vindication"] = true, +} + +-- ============================================ +-- DEBUFF TRACKING STATE +-- objects[unit][unitlevel][effect] = {effect, start, duration} +-- ============================================ +NanamiPlates_SpellDB.objects = {} +NanamiPlates_SpellDB.pending = {} -- Array: [1]=unit, [2]=unitlevel, [3]=effect, [4]=duration + +-- ============================================ +-- OWNER_BOUND_DEBUFFS OWNERSHIP CACHE +-- Tracks player's own applications of OWNER_BOUND_DEBUFFS +-- Format: ownerBoundCache[unit][effect] = {start, duration} +-- Used to infer ownership when "Only My Debuffs" is enabled +-- ============================================ +NanamiPlates_SpellDB.ownerBoundCache = {} + +-- ============================================ +-- DURATION LOOKUP FUNCTIONS +-- ============================================ + +-- Search function to avoid duplication +function NanamiPlates_SpellDB:FindEffectData(u, lvl, eff) + if not self.objects[u] then return nil end + if self.objects[u][lvl] and self.objects[u][lvl][eff] then + return self.objects[u][lvl][eff] + elseif self.objects[u][0] and self.objects[u][0][eff] then + return self.objects[u][0][eff] + else + for l, effects in pairs(self.objects[u]) do + if effects[eff] then return effects[eff] end + end + end + return nil +end + +-- Get max rank for a spell +function NanamiPlates_SpellDB:GetMaxRank(effect) + local spellData = self.DEBUFFS[effect] + if not spellData then return 0 end + + local max = 0 + for id in pairs(spellData) do + if id > max then max = id end + end + return max +end + +-- Get duration by spell name and rank +function NanamiPlates_SpellDB:GetDuration(effect, rank) + if not effect then return 0 end + + -- Translate localized name to English if needed + if not self.DEBUFFS[effect] then + if self.localeMap and self.localeMap[effect] then + effect = self.localeMap[effect] + elseif self.learnedLocale and self.learnedLocale[effect] then + effect = self.learnedLocale[effect] + end + end + + local spellData = self.DEBUFFS[effect] + if not spellData then return 0 end + + -- Parse rank from string like "Rank 2" if needed + local rankNum = 0 + if rank then + if type(rank) == "number" then + rankNum = rank + elseif type(rank) == "string" then + -- Extract number from "Rank X" format + for num in string_gfind(rank, "(%d+)") do + rankNum = tonumber(num) or 0 + break + end + end + end + + -- If exact rank not found, use max rank + if not spellData[rankNum] then + rankNum = self:GetMaxRank(effect) + end + + local duration = spellData[rankNum] or spellData[0] or 0 + + -- Handle dynamic duration adjustments + -- For combo point abilities, cache the CP count at cast time + -- because combo points are consumed after cast + local cp = GetComboPoints("player", "target") or 0 + if cp > 0 and self.COMBO_POINT_DEBUFFS and self.COMBO_POINT_DEBUFFS[effect] then + self.lastComboPoints = cp + elseif cp == 0 and self.COMBO_POINT_DEBUFFS and self.COMBO_POINT_DEBUFFS[effect] then + cp = self.lastComboPoints or 0 + end + + if effect == self.DYN_DEBUFFS["Rupture"] then + duration = duration + cp * 2 + elseif effect == self.DYN_DEBUFFS["Kidney Shot"] then + duration = duration + cp * 1 + elseif effect == self.DYN_DEBUFFS["Rip"] then + duration = duration + cp * 2 + elseif effect == self.DYN_DEBUFFS["Demoralizing Shout"] then + -- Booming Voice: 10% per talent + local _,_,_,_,count = GetTalentInfo(2, 1) + if count and count > 0 then + duration = duration + (duration / 100 * (count * 10)) + end + elseif effect == self.DYN_DEBUFFS["Shadow Word: Pain"] then + -- Improved Shadow Word: Pain: +3s per talent + local _,_,_,_,count = GetTalentInfo(3, 4) + if count and count > 0 then + duration = duration + count * 3 + end + elseif effect == self.DYN_DEBUFFS["Frostbolt"] then + -- Permafrost: +1s per talent + local _,_,_,_,count = GetTalentInfo(3, 7) + if count and count > 0 then + duration = duration + count + end + elseif effect == self.DYN_DEBUFFS["Gouge"] then + -- Improved Gouge: +.5s per talent + local _,_,_,_,count = GetTalentInfo(2, 1) + if count and count > 0 then + duration = duration + (count * 0.5) + end + elseif effect == self.DYN_DEBUFFS["Rend"] then + return duration + end + + if effect == "Corruption" then + local hasteRank = self:GetCorruptionHasteRank() + if hasteRank > 0 then + local hastePct = hasteRank * 0.03 + duration = duration / (1 + hastePct) + end + end + + return duration +end + +-- ============================================ +-- PENDING SPELL TRACKING +-- ============================================ + +-- Store recent casts by spell name for combat log fallback +NanamiPlates_SpellDB.recentCasts = {} + +-- Secondary pending slot for Malediction dual-curse support +NanamiPlates_SpellDB.pendingCurse = {} + +function NanamiPlates_SpellDB:AddPending(unit, unitlevel, effect, duration) + if not unit or not effect then return end + if not self.DEBUFFS[effect] then return end + if duration <= 0 then return end + + -- Always store recent cast keyed by spell name (for combat log fallback) + self.recentCasts[effect] = { + duration = duration, + time = GetTime() + } + + -- Try to get GUID for unique identification (SuperWoW) + local unitKey = unit + if UnitGUID and UnitExists("target") and UnitName("target") == unit then + local guid = UnitGUID and UnitGUID("target") + if guid then unitKey = guid end + end + + -- Malediction dual-curse handling: use separate pending slots for curses + local isNewCurse = self:IsWarlockCurse(effect) + local isExistingCurse = self.pending[3] and self:IsWarlockCurse(self.pending[3]) + + if isNewCurse and isExistingCurse and self:HasMalediction() then + -- Both are curses and Malediction is active + -- Check if they can coexist (one long curse + Curse of Agony) + if self:CanCursesCoexist(effect, self.pending[3]) then + -- Store the new curse in secondary pending slot + self.pendingCurse[1] = unitKey + self.pendingCurse[2] = unitlevel or 0 + self.pendingCurse[3] = effect + self.pendingCurse[4] = duration + self.pendingCurse[5] = unit + return -- Don't overwrite primary pending + end + end + + -- Standard pending: overwrite previous + self.pending[1] = unitKey + self.pending[2] = unitlevel or 0 + self.pending[3] = effect + self.pending[4] = duration + self.pending[5] = unit -- Store original name for fallback lookups +end + +function NanamiPlates_SpellDB:RemovePending() + self.pending[1] = nil + self.pending[2] = nil + self.pending[3] = nil + self.pending[4] = nil + self.pending[5] = nil +end + +-- Clear secondary curse pending slot (Malediction support) +function NanamiPlates_SpellDB:RemovePendingCurse() + self.pendingCurse[1] = nil + self.pendingCurse[2] = nil + self.pendingCurse[3] = nil + self.pendingCurse[4] = nil + self.pendingCurse[5] = nil +end + +function NanamiPlates_SpellDB:PersistPending(effect) + local persisted = false + + -- Check primary pending slot + if self.pending[3] then + if self.pending[3] == effect or (effect == nil and self.pending[3]) then + -- Store by GUID (pending[1]) for accurate per-mob tracking + -- Mark as isOwn = true since this is the player's own debuff + self:RefreshEffect(self.pending[1], self.pending[2], self.pending[3], self.pending[4], true) + -- Also store by name (pending[5]) as fallback for non-SuperWoW lookups + if self.pending[5] and self.pending[5] ~= self.pending[1] then + self:RefreshEffect(self.pending[5], self.pending[2], self.pending[3], self.pending[4], true) + end + persisted = true + self:RemovePending() + end + end + + -- Check secondary curse pending slot (Malediction dual-curse support) + if self.pendingCurse[3] then + if self.pendingCurse[3] == effect or (effect == nil and self.pendingCurse[3]) then + -- Store by GUID for accurate per-mob tracking + self:RefreshEffect(self.pendingCurse[1], self.pendingCurse[2], self.pendingCurse[3], self.pendingCurse[4], true) + -- Also store by name as fallback + if self.pendingCurse[5] and self.pendingCurse[5] ~= self.pendingCurse[1] then + self:RefreshEffect(self.pendingCurse[5], self.pendingCurse[2], self.pendingCurse[3], self.pendingCurse[4], true) + end + persisted = true + self:RemovePendingCurse() + end + end + + return persisted +end + +-- ============================================ +-- EFFECT TRACKING +-- ============================================ + +function NanamiPlates_SpellDB:AddEffect(unit, unitlevel, effect, duration, isOwn) + if not unit or not effect then return end + unitlevel = unitlevel or 0 + + if not self.objects[unit] then self.objects[unit] = {} end + if not self.objects[unit][unitlevel] then self.objects[unit][unitlevel] = {} end + + local existing = self.objects[unit][unitlevel][effect] + if existing and existing.start then + if existing.duration and (existing.start + existing.duration) > GetTime() then + return + end + end + + if not self.objects[unit][unitlevel][effect] then self.objects[unit][unitlevel][effect] = {} end + + self.objects[unit][unitlevel][effect].effect = effect + self.objects[unit][unitlevel][effect].start = GetTime() + self.objects[unit][unitlevel][effect].duration = duration or self:GetDuration(effect) + self.objects[unit][unitlevel][effect].isOwn = isOwn or false +end + +function NanamiPlates_SpellDB:RefreshEffect(unit, unitlevel, effect, duration, isOwn) + if not unit or not effect then return end + unitlevel = unitlevel or 0 + -- Always refresh start time and duration + if not self.objects[unit] then self.objects[unit] = {} end + if not self.objects[unit][unitlevel] then self.objects[unit][unitlevel] = {} end + if not self.objects[unit][unitlevel][effect] then self.objects[unit][unitlevel][effect] = {} end + + self.objects[unit][unitlevel][effect].effect = effect + self.objects[unit][unitlevel][effect].start = GetTime() + self.objects[unit][unitlevel][effect].duration = duration or self:GetDuration(effect) + self.objects[unit][unitlevel][effect].isOwn = isOwn ~= false -- default to true for backwards compatibility +end + +function NanamiPlates_SpellDB:UpdateDuration(unit, unitlevel, effect, duration) + if not unit or not effect or not duration then return end + unitlevel = unitlevel or 0 + + if self.objects[unit] and self.objects[unit][unitlevel] and self.objects[unit][unitlevel][effect] then + self.objects[unit][unitlevel][effect].duration = duration + end +end + +-- ============================================ +-- OWNER_BOUND_DEBUFFS OWNERSHIP TRACKING +-- ============================================ + +-- Track player's application of an OWNER_BOUND_DEBUFF +function NanamiPlates_SpellDB:TrackOwnerBoundDebuff(unit, effect, duration) + if not unit or not effect then return end + if not self.OWNER_BOUND_DEBUFFS[effect] then return end + + if not self.ownerBoundCache[unit] then + self.ownerBoundCache[unit] = {} + end + + self.ownerBoundCache[unit][effect] = { + start = GetTime(), + duration = duration or self:GetDuration(effect, 0) or 30 + } +end + +-- Check if player owns an OWNER_BOUND_DEBUFF on a unit +-- Returns true if player has a valid (non-expired) cached application +function NanamiPlates_SpellDB:IsOwnerBoundDebuffMine(unit, effect) + if not unit or not effect then return false end + if not self.OWNER_BOUND_DEBUFFS[effect] then return false end + + local cache = self.ownerBoundCache[unit] + if not cache or not cache[effect] then return false end + + local entry = cache[effect] + if not entry.start or not entry.duration then return false end + + local now = GetTime() + local elapsed = now - entry.start + + -- Allow 2 second grace period beyond expected duration + -- This handles slight timing variations + if elapsed > entry.duration + 2 then + -- Entry expired, clean it up + cache[effect] = nil + return false + end + + return true +end + +-- Clean up expired entries from ownerBoundCache +function NanamiPlates_SpellDB:CleanupOwnerBoundCache() + local now = GetTime() + + for unit, effects in pairs(self.ownerBoundCache) do + local hasAny = false + for effect, entry in pairs(effects) do + if entry.start and entry.duration then + local elapsed = now - entry.start + if elapsed > entry.duration + 5 then + -- Grace period expired, remove + effects[effect] = nil + else + hasAny = true + end + else + effects[effect] = nil + end + end + -- Clean up empty unit tables + if not hasAny then + self.ownerBoundCache[unit] = nil + end + end +end + +-- Remove a specific debuff tracking when it fades +function NanamiPlates_SpellDB:RemoveOwnerBoundDebuff(unit, effect) + if not unit or not effect then return end + if self.ownerBoundCache[unit] then + self.ownerBoundCache[unit][effect] = nil + end +end + +-- ============================================ +-- UNITDEBUFF WRAPPER +-- Returns: effect, rank, texture, stacks, dtype, duration, timeleft, isOwn +-- ============================================ +function NanamiPlates_SpellDB:UnitDebuff(unit, id) + local unitname = UnitName(unit) + local unitlevel = UnitLevel(unit) or 0 + local texture, stacks, dtype = UnitDebuff(unit, id) + local duration, timeleft = nil, -1 + local rank = nil + local effect = nil + local isOwn = false + + if texture then + -- Get spell name via tooltip scanning + -- Try the unit first, but if it's a GUID and that fails, try "target" if it matches + effect = self:ScanDebuff(unit, id) + + -- If scanning failed and this unit is the target, try scanning "target" instead + if (not effect or effect == "") and UnitName("target") == unitname then + effect = self:ScanDebuff("target", id) + end + + effect = effect or "" + end + + -- Check tracked debuffs with level + if effect and effect ~= "" and (self.objects[unitname] or (UnitGUID and UnitGUID(unit) and self.objects[UnitGUID(unit)])) then + local data = nil + local unitguid = UnitGUID and UnitGUID(unit) + + local dataName = self:FindEffectData(unitname, unitlevel, effect) + local dataGUID = unitguid and self:FindEffectData(unitguid, unitlevel, effect) + + if dataName and dataGUID then + if (dataName.start or 0) >= (dataGUID.start or 0) then + data = dataName + else + data = dataGUID + end + else + data = dataName or dataGUID + end + + if data and data.start and data.duration then + -- Clean up expired + if data.duration + data.start < GetTime() then + -- Don't remove here, let it be cleaned up elsewhere + data = nil + else + duration = data.duration + timeleft = duration + data.start - GetTime() + isOwn = data.isOwn == true + end + end + end + + -- Fallback: if we have effect name but no tracked data, get duration from DB + -- Don't set timeleft - let the caller handle untracked debuffs with their own timer cache + if effect and effect ~= "" and (not duration or duration <= 0) then + local dbDuration = self:GetDuration(effect, 0) + if dbDuration and dbDuration > 0 then + duration = dbDuration + -- timeleft stays at -1, signaling caller to use their own timer cache + end + end + + return effect, rank, texture, stacks, dtype, duration, timeleft, isOwn +end + +function NanamiPlates_SpellDB:InitScanner() + if self.scanner then return end + + -- Create hidden tooltip for scanning + self.scanner = CreateFrame("GameTooltip", "NanamiPlatesDebuffScanner", UIParent, "GameTooltipTemplate") + self.scanner:SetOwner(UIParent, "ANCHOR_NONE") +end + +-- Check if a string looks like a SuperWoW GUID +local function IsGUID(unit) + if not unit or type(unit) ~= "string" then return false end + return string_sub(unit, 1, 2) == "0x" +end + +function NanamiPlates_SpellDB:ScanDebuff(unit, index) + if not self.scanner then self:InitScanner() end + + local texture = UnitDebuff(unit, index) + if not texture then return nil end + + -- 1. Prioritize debuff-specific mappings (manually curated, always correct) + if self.debuffPriority and self.debuffPriority[texture] then + return self.debuffPriority[texture] + end + + -- 2. Try tooltip scanning FIRST (most accurate, returns actual debuff name) + local scanUnit = unit + local canScanTooltip = true + if IsGUID(unit) then + if UnitExists("target") then + local targetGUID = UnitGUID and UnitGUID("target") + if targetGUID and targetGUID == unit then + scanUnit = "target" + else + canScanTooltip = false + end + else + canScanTooltip = false + end + end + + if canScanTooltip then + self.scanner:ClearLines() + self.scanner:SetUnitDebuff(scanUnit, index) + + local textLeft = getglobal("NanamiPlatesDebuffScannerTextLeft1") + if textLeft then + local effect = textLeft:GetText() + if effect and effect ~= "" then + local originalTooltipName = effect + + -- Try localeMap translation (static) + if self.localeMap and self.localeMap[effect] then + effect = self.localeMap[effect] + end + -- Try runtime learned locale cache + if not self.DEBUFFS[effect] and self.learnedLocale and self.learnedLocale[effect] then + effect = self.learnedLocale[effect] + end + -- Only use textureToSpell when tooltip name doesn't resolve in DEBUFFS + if not self.DEBUFFS[effect] and texture and self.textureToSpell[texture] then + local texName = self.textureToSpell[texture] + if self.DEBUFFS[texName] then + self:LearnLocale(originalTooltipName, texName) + effect = texName + end + end + -- Auto-learn: if tooltip name not in DB, but we just cast a known spell + if not self.DEBUFFS[effect] and self.lastCastSpell and self.lastCastTime then + if (GetTime() - self.lastCastTime) < 3 and self.DEBUFFS[self.lastCastSpell] then + self:LearnLocale(originalTooltipName, self.lastCastSpell) + effect = self.lastCastSpell + self.lastCastSpell = nil + end + end + -- Cache texture -> resolved name (only if we got a valid DEBUFFS match) + if texture and effect and effect ~= "" and self.DEBUFFS[effect] then + self.textureToSpell[texture] = effect + end + end + if effect and effect ~= "" then + return effect + end + end + end + + -- 3. Fallback to textureToSpell (for non-target GUIDs or when tooltip produced nothing) + if self.textureToSpell[texture] then + return self.textureToSpell[texture] + end + + return nil +end + +-- Scan spellbook at login to build texture → spell mappings and locale learning +function NanamiPlates_SpellDB:ScanSpellbook() + self.learnedLocale = self.learnedLocale or {} + + -- Build a reverse map: English name → texture from our existing textureToSpell + local engToTex = {} + for tex, eng in pairs(self.textureToSpell) do + if self.DEBUFFS[eng] then + engToTex[eng] = tex + end + end + + local i = 1 + while true do + local localName, rank = GetSpellName(i, "spell") + if not localName then break end + local spellTex = GetSpellTexture(i, "spell") + + if spellTex and localName and localName ~= "" then + -- Case 1: spellbook name is already in DEBUFFS (English client or matching name) + if self.DEBUFFS[localName] then + if not self.textureToSpell[spellTex] or not self.DEBUFFS[self.textureToSpell[spellTex]] then + self.textureToSpell[spellTex] = localName + end + else + -- Case 2: spellbook name is localized (e.g. Chinese) + -- Try to find the English name via localeMap + local engName = self.localeMap and self.localeMap[localName] + if engName and self.DEBUFFS[engName] then + self.learnedLocale[localName] = engName + if not self.textureToSpell[spellTex] or not self.DEBUFFS[self.textureToSpell[spellTex]] then + self.textureToSpell[spellTex] = engName + end + else + -- Case 3: Try matching by texture path to find English name + local existing = self.textureToSpell[spellTex] + if existing and self.DEBUFFS[existing] and existing ~= localName then + self.learnedLocale[localName] = existing + end + end + end + end + i = i + 1 + end +end + +-- Learn locale mapping: associate a localized tooltip name with an English DEBUFFS name +-- Called when we detect a debuff was just cast by the player +function NanamiPlates_SpellDB:LearnLocale(localeName, englishName) + if not localeName or not englishName or localeName == englishName then return end + if localeName == "" or englishName == "" then return end + if not self.DEBUFFS[englishName] then return end + self.learnedLocale = self.learnedLocale or {} + if not self.learnedLocale[localeName] then + self.learnedLocale[localeName] = englishName + end +end + +-- Get spell name and rank from action bar slot by matching texture to spellbook +-- Returns the HIGHEST rank spell that matches the texture (since all ranks share same texture) +function NanamiPlates_SpellDB:ScanAction(slot) + local actionTexture = GetActionTexture(slot) + if not actionTexture then return nil, nil end + + -- Search through spellbook to find ALL matching textures and return highest rank + local bestName, bestRank, bestRankNum = nil, nil, -1 + local i = 1 + while true do + local spellName, spellRank = GetSpellName(i, "spell") + if not spellName then break end + + local spellTexture = GetSpellTexture(i, "spell") + if spellTexture and spellTexture == actionTexture then + -- Parse rank number from "Rank X" string + local rankNum = 0 + if spellRank then + for num in string.gfind(spellRank, "(%d+)") do + rankNum = tonumber(num) or 0 + break + end + end + -- Keep track of highest rank found + if rankNum > bestRankNum then + bestName = spellName + bestRank = spellRank + bestRankNum = rankNum + end + end + i = i + 1 + end + + -- Return highest rank if found + if bestName then + return bestName, bestRank + end + + -- If not found in spellbook, try tooltip as fallback + if not self.scanner then self:InitScanner() end + self.scanner:ClearLines() + self.scanner:SetAction(slot) + + local textLeft = getglobal("NanamiPlatesDebuffScannerTextLeft1") + local textRight = getglobal("NanamiPlatesDebuffScannerTextRight1") + + local effect = textLeft and textLeft:GetText() or nil + local rank = textRight and textRight:GetText() or nil + + return effect, rank +end + +-- Expose on the main addon table +NanamiPlates.SpellDB = NanamiPlates_SpellDB diff --git a/Target.lua b/Target.lua new file mode 100644 index 0000000..b1148f3 --- /dev/null +++ b/Target.lua @@ -0,0 +1,65 @@ +NanamiPlates_Target = {} + +local NP = NanamiPlates +local Settings = NP.Settings + +local function ApplyArrowTint(ind) + if not ind or not ind.tex then return end + local tint = Settings.targetArrowTint or 0 + if tint <= 0 then + ind.tex:SetVertexColor(1, 1, 1, 1) + else + local acR, acG, acB = NP.GetThemeColor("accent", 1.0, 0.5, 0.8, 1) + local r = 1 + (acR - 1) * tint + local g = 1 + (acG - 1) * tint + local b = 1 + (acB - 1) * tint + ind.tex:SetVertexColor(r, g, b, 1) + end +end + +function NanamiPlates_Target.UpdateTarget(nameplate, isTarget) + if not nameplate then return end + + local indL = nameplate.targetArrowL + local indR = nameplate.targetArrowR + if not indL or not indR then return end + + if isTarget and Settings.showTargetGlow then + local acR, acG, acB = NP.GetThemeColor("accent", 1.0, 0.5, 0.8, 0.9) + ApplyArrowTint(indL) + ApplyArrowTint(indR) + indL:Show() + indR:Show() + -- Highlight healthBG border for target + if nameplate.healthBG then + nameplate.healthBG:SetBackdropBorderColor(acR, acG, acB, 1) + end + -- Show additive glow overlay (color/alpha synced in UpdateNamePlate) + if nameplate.targetGlow then + if not nameplate.targetGlow:IsShown() then + local barR, barG, barB = nameplate.health:GetStatusBarColor() + local lum = 0.299 * barR + 0.587 * barG + 0.114 * barB + local glowAlpha = 0.75 + if lum > 0.5 then + glowAlpha = 0.75 * math.max(0.15, (1.0 - lum) * 2) + end + nameplate.targetGlow:SetStatusBarColor(barR, barG, barB, 1) + nameplate.targetGlow:SetAlpha(glowAlpha) + end + nameplate.targetGlow:Show() + end + else + indL:Hide() + indR:Hide() + -- Restore default border for non-target + if nameplate.healthBG then + local brR, brG, brB, brA = NP.GetThemeColor("panelBorder", 0.55, 0.30, 0.42, 0.9) + nameplate.healthBG:SetBackdropBorderColor(brR, brG, brB, brA) + end + if nameplate.targetGlow then + nameplate.targetGlow:Hide() + end + end +end + +NanamiPlates.Target = NanamiPlates_Target diff --git a/Threat.lua b/Threat.lua new file mode 100644 index 0000000..2dadebb --- /dev/null +++ b/Threat.lua @@ -0,0 +1,204 @@ +NanamiPlates_Threat = {} + +local NP = NanamiPlates + +local string_find = string.find +local string_sub = string.sub +local string_format = string.format +local string_gfind = string.gfind +local pairs = pairs +local ipairs = ipairs +local tonumber = tonumber +local tostring = tostring +local GetTime = GetTime +local UnitName = UnitName +local UnitExists = UnitExists +local UnitIsUnit = UnitIsUnit +local UnitInRaid = UnitInRaid +local UnitInParty = UnitInParty +local SendAddonMessage = SendAddonMessage + +local GP_TankModeThreats = {} +local GP_Threats = {} +local GP_TankPlayers = {} + +NanamiPlates_Threat.GP_TankModeThreats = GP_TankModeThreats +NanamiPlates_Threat.GP_Threats = GP_Threats +NanamiPlates_Threat.GP_TankPlayers = GP_TankPlayers + +local function GP_Split(str, delimiter) + local result = {} + local pattern = "([^" .. delimiter .. "]+)" + for match in string_gfind(str, pattern) do + table.insert(result, match) + end + return result +end + +local function GP_HandleTankModePacket(packet) + local startPos = string_find(packet, "TMTv1=") + if not startPos then return end + local dataStr = string_sub(packet, startPos + 6) + + for k in pairs(GP_TankModeThreats) do GP_TankModeThreats[k] = nil end + + local entries = GP_Split(dataStr, ";") + for _, entry in ipairs(entries) do + local parts = GP_Split(entry, ":") + if parts[1] and parts[2] and parts[3] and parts[4] then + GP_TankModeThreats[parts[2]] = { + creature = parts[1], + name = parts[3], + perc = tonumber(parts[4]) or 0 + } + end + end +end + +local function GP_HandleThreatPacket(packet) + local startPos = string_find(packet, "TWTv4=") + if not startPos then return end + local dataStr = string_sub(packet, startPos + 6) + + for k in pairs(GP_Threats) do GP_Threats[k] = nil end + + local entries = GP_Split(dataStr, ";") + for _, entry in ipairs(entries) do + local parts = GP_Split(entry, ":") + if parts[1] and parts[3] and parts[4] then + GP_Threats[parts[1]] = { + threat = tonumber(parts[3]) or 0, + perc = tonumber(parts[4]) or 0, + tank = (parts[6] == "1") + } + end + end +end + +local NP_ADDON_PREFIX = "NanamiPlates" +local TANK_BROADCAST_DEBOUNCE = 5 +local lastTankBroadcast = 0 + +function NanamiPlates_Threat.BroadcastTankMode(force) + local now = GetTime() + if not force and (now - lastTankBroadcast) < TANK_BROADCAST_DEBOUNCE then return end + + local playerRole = NP and NP.playerRole + local isTank = (playerRole == "TANK") + local msg = isTank and "TM=1" or "TM=0" + + local myName = UnitName("player") + if myName then + GP_TankPlayers[myName] = isTank or nil + end + + if UnitInRaid("player") then + SendAddonMessage(NP_ADDON_PREFIX, msg, "RAID") + lastTankBroadcast = now + elseif UnitInParty() then + SendAddonMessage(NP_ADDON_PREFIX, msg, "PARTY") + lastTankBroadcast = now + end +end + +function NanamiPlates_Threat.IsPlayerTank(playerName) + if not playerName then return false end + return GP_TankPlayers[playerName] == true +end + +local function GP_HandleTankModeMessage(sender, msg) + if string_find(msg, "TM=") then + local isTank = string_sub(msg, 4, 4) == "1" + GP_TankPlayers[sender] = isTank or nil + end +end + +function NanamiPlates_Threat.GetTWTankModeThreat(mobGUID, mobName) + local playerName = UnitName("player") + + if mobGUID then + local data = GP_TankModeThreats[mobGUID] + if data then + local playerHasAggro = (data.name == playerName) + return true, playerHasAggro, data.name, data.perc or 0 + end + end + + if mobName then + for guid, data in pairs(GP_TankModeThreats) do + if data.creature == mobName then + local playerHasAggro = (data.name == playerName) + return true, playerHasAggro, data.name, data.perc or 0 + end + end + end + + return false, false, nil, 0 +end + +function NanamiPlates_Threat.GetGPThreatData() + local playerName = UnitName("player") + local playerPct = 0 + local highestOtherPct = 0 + local hasData = false + local threatHolderName = nil + + for name, data in pairs(GP_Threats) do + hasData = true + local pct = data.perc or 0 + if name == playerName then + playerPct = pct + else + if pct > highestOtherPct then highestOtherPct = pct end + end + if pct >= 100 then threatHolderName = name end + end + + return hasData, (playerPct >= 100), playerPct, highestOtherPct, threatHolderName +end + +function NanamiPlates_Threat.IsInPlayerGroup(unit) + if not unit or not UnitExists(unit) then return false end + if UnitIsUnit(unit, "player") then return true end + + for i = 1, 4 do + if UnitIsUnit(unit, "party" .. i) then return true end + end + if UnitInRaid("player") then + for i = 1, 40 do + if UnitIsUnit(unit, "raid" .. i) then return true end + end + end + if UnitIsUnit(unit, "pet") then return true end + for i = 1, 4 do + if UnitIsUnit(unit, "partypet" .. i) then return true end + end + + return false +end + +local NP_ThreatFrame = CreateFrame("Frame") +NP_ThreatFrame:RegisterEvent("CHAT_MSG_ADDON") +NP_ThreatFrame:RegisterEvent("PARTY_MEMBERS_CHANGED") +NP_ThreatFrame:RegisterEvent("RAID_ROSTER_UPDATE") +NP_ThreatFrame:RegisterEvent("ZONE_CHANGED_NEW_AREA") +NP_ThreatFrame:RegisterEvent("PLAYER_ENTERING_WORLD") +NP_ThreatFrame:SetScript("OnEvent", function() + if event == "CHAT_MSG_ADDON" then + local prefix = arg1 + local msg = arg2 or "" + local sender = arg4 + + if string_find(msg, "TMTv1=") then GP_HandleTankModePacket(msg) end + if string_find(msg, "TWTv4=") then GP_HandleThreatPacket(msg) end + + if prefix == NP_ADDON_PREFIX and msg and sender then + GP_HandleTankModeMessage(sender, msg) + end + elseif event == "PARTY_MEMBERS_CHANGED" or event == "RAID_ROSTER_UPDATE" + or event == "ZONE_CHANGED_NEW_AREA" or event == "PLAYER_ENTERING_WORLD" then + NanamiPlates_Threat.BroadcastTankMode(true) + end +end) + +NanamiPlates.Threat = NanamiPlates_Threat diff --git a/img/arrow.tga b/img/arrow.tga new file mode 100644 index 0000000000000000000000000000000000000000..b595226fa92df4763a8331d4b4c5691920faa6ca GIT binary patch literal 262162 zcmeI5>#tNr)`y+<)BG9lKl;vhzc?BvDlu^|YBYc&MjUlu1cMG3h`@jWqvphb1LGy! z1?19iKi#Z8>(r^*yJ}y$_wN1`IqX~Qs#QC4uUxq@`gE0)i^GQxFVfD*KR)?nqtai(YE%8b|Ni?s%7|T@KYxDoW#yfB-m&B? zjz9nW^XSu6QdSoUe|oaXI?)nto8tH2!GmEDSzKSdba5@}$|6BePww8myB6unDWj)P zpPr@qiGy(a_U(;He-5Kf_G9(mmk(D|CP$AR9aUL*^yty#)mLBLCG*XjH+`~Z=bMu6 zBsP}62M!$A#c5NRD4lZxR{wWZ4tE1x@>qD8*fD+I*I$2aj~B;Hm=m)Gka92?OWsP( z?g`lZ|H-t6c=9CHOUz5ozJ2>fQBJ~g9?Bi+#PGlF>*UWq`)suTH&%>|x-VG@aSQ~u z>wkQVXk~u{`l^5jYXcy|8oyYCJ%`|o5=)P48eca8JR=Ks|FocfP`Z3s+KGE{LgcxTR@*^!3( z_wU=I*>PRh$YS%m@4x?k6531s_@5tl@JX@O{eB6=Y*}{{)ZpUv*IzHrH~KpqWh1Bl zTa^8#yj|$~_wS#Cb=u_i$A5nM>4Fz}K=1gD0Jf3sB+U4#CnN->AI}MWN8;QCdQslT_R~A&hdJR6$&Z&mJV)>I z(|>=OT)K2=a_ZEn31y^7zu8xMu~AJq`Xt|HA;Z?z)(-Eb?KWv|dS_16K(Sz=cH>|4Q9@_ zWAi)R{7<={ue~|#SFkCk9-ueE-jaR<+w(s^|3Z`*}kdcWewn z-)C`#_7e1i_H3xfnJ(*T1JJboH+?T;-}d^q-+s&D?c3_Gy#`fv;Axw|TlVHWob77{74|F&u!o!Bd_Tq6=TEE;nc09% zjIV*+6Qy! z(Cq%uj#i5~Qiq#5-t=$VwH>R%_3u^rt9zEaMj**yycWo!h7{eUnLFSk_I)m8$XA7t5$T}h1n5W`=e{cHdUGTIi6C@>3NL^*Y!2zK3^cOHCH$1>Oy5jc`71x88q#^s)}+ zyh?8#%7{+Z<^uFdj03$3b^esM&{^Jb^w0XJ|Ac@1c>TJCm#K^M?N-gFFn!KC4!Tpu zyf44<@}hj#$%2hQX`gCRP_GUhI+Pt(l`P1Ct}x~pD}?R7g+=|XvIVg4;6t18(AlHe zJ|UYQm|ygv7XB)a*0IfM{?oC5$vC8A9itps{oE`To~`K+mbYbVGN#RuC-h@wB_>-R z_lTc-Z|kU2=*n}(w$ua0;jy*C9687f>R8=4zK&O&4GDb);|A;?d|22vm5yud_f=y1 zir#$l%^l(UxR!Cw_e`gi>OzRww_ z`ndZ#C)ej~f8QzE89ugw)CKAtYY*(xK7Rc8%r0!F&vsXgvA4FiW;UrgIt&||V4pFs z7wzINd7Cz@uY9fwcUI@8{XWjh!u+a4`hNW~uc}Hz_{{IkLb4LOTb~jiuD`#G^De$u z;N_2i(ks53^Uu9m9v5{a!!g%si_~#}kIZd#h>z z`usi4dAf!<-m|<~hhv?6SKa)w&)+AXcWv{2f5`KH)`!A+nRAWquhjW)x3;!QU+>g^ zpZ3A?Twh(=)+wj~2jO3WW?0d|qFq+(-kN@X=z>w$vP4~=tn;sbRjS|m^JvgBY z*Bis<%e?N>9$=a2*yOl;c}tzg_=MkJPQBaazOVoD{$Sn~#N=7!y{5mfXEN8>TZU_f zUSEjq2W~!Jwh)(dY?HTj@2)CM%ON+MSi*fPj46HI#B8H;>=k?%P0zRMzt#R>jR$Ss z-6VZxV@eux-uL@`z3$!50Zbk{_F2lXv(J&csUOm?jCAzLsE@EIH&%AK3_tvy8ugxX z=AGX+)HGj9-kVm9x$bXzvi;9XG>r3-G~pK=pShCnFW5UAEza<}PTo(~=T;y6o>G&e z>*GAXyG}u~@MxnSPVKyx<_(3K7M={_6~VtuJ8IE&J|6DK8~cR z>+~>wrTs#$n)()lrG-BC(#1T zPkr1cO-=I}dknst!1_c^--`TvTZcN(bZpJ|n6|j>@6;ei)qZ~V17-ni1}-aaFo7)=5HVdprDCjyjzRC)B}u#^D-b7*`k9 zcQW@ldb!%3=gQ66D`V*<-wCAbH?hG$HpcMmw=eyjrW6;#q6~zN^kFM*I)Lzf)`Gk~ zDSY3qDMRdMHotz}8QSgIQ-*ZQYL|+u^Q|fGl{eB=HW_a%ThrbgO`fYm+ zdmrC2&rNn%@}0Cc<7&nRZR&j2bprjOcj)!3j#e?IuP+_%S$k#sNB#F3{C9?$+OSs1 z+x8iZw(GwqHoo&ZeG{yz=6$jCmp&=b54Fv|Ht)Cn&0hA)r^Zlq+!$l!ev_#rjea~> z@*PpuADTFm&B~QsbM$(SKJB=DoTcZp+0bkb&|DYPgj*l)&r3M?063t{gwU zE||PyJ^F8UR;Asq6~z3-<`!Ku8RBn2#8}w9w!eSK@AFt#T^?CKO-kXzN^TWK>`qiqm)9#XAruX)KoAk#Pf7OwC)<$1J*nWI& zGSim$c+s%^rVhomX4_zGVkyVa%n>t8(Sd$O+0DaZ_uF`++{=G%=upvt6 z1{aB^c)9D&(XX?n$#{p~HlscsJa}*=X${v?L)laNX3@cVSHhRI zY+Td&Z@)=W66Z4gdOJtY$e+^&n%)q*pU`%0YisA;wq?qn8<(Xg_M4ABjH8}e<&-)?ZbuN}qbw_jn zrP46o!!ds-8UItiO5<9F|JZjALvFO6zTU^$iXpdcO*zKO6l0(L2lh{x+juvIF;7zt zV|p*AJd!aCs%)gW7OF;UZanw*5rlp?S-g>#wubckE-W!XK%cmpvJZ9t1*df$E z4Si0Ro8EQuhfa#|s@L<8Z*A)$d&SK8u?Vh<-)mEz_f3C) zsm{?pw24FSj*oz^|8?bK3}eDD4KX{FP``rSe|$MtCUI6Aq7M)Kt>d+Ei5;8H9oLc5rlo1S{>Mtj{9b53GGs>B>%`W0 zLft+oORc94IekrJtFm{>{mwy?c=gWk30VD4)qd84hM$UR2}3&`(rW8n)o-rH(^%f7 z_K2)Uij|670@ymG+`Z|3kKAyE>Mu+i`&x!7YaepO);9atuhfU^3bTI#ss4ZLx?sLp z6S{CeTmN)dA49mN!#cp~rIG|gA%K27bYouw#}+Phx%()Gk}qAzap=&Y1%@Ghr=yEB zDvWjneEpx<08Z@y+l9E=w`0F+5L*jat(41&-S#eS${NRVsIFfui%w#C;_YI`m{Ok> z&iN+Z7}L>KX7UlYj&{s#+35Y91lR+>-hr{#eNr_m^iTi8c6w(@H|Gi)C#~&mPJSz; za{^ZXoBp<{IEsJ>jF$lQU$(5{%|A&l0&NJepRLXNdRGKQKmYKmYKmYKmYKmYKmYKmYKmYKmYKmY zKmYKmYKmYKmYKmYKmYKmYKmYKmYKmYKmYKmYKmY zKmYKmYKmYKmYKmYKmYKmYKmYKmYKmYKmYKm^u}z?WZsIr;3f&nD}ZE~Q5VM4+0$ z;lqa~<}O{lq&A=$m!68iS`ql+haar^&yMHLol_gIRynN1h(MaaS6_X#qyBSo>eMN< z0cm7iE`MZ>bH4;OTy1}?0m&9c9 zq$;Rg&vL_+zhM&~W54<4o9uEj?9^;ahd!Gz@4kKe)*@}8ea@~5nYi)mufJxcKX?3Z z(L8zbBrDwQ9MRbj3HWuN!$%IUO`cjgjU9jc?YGI#KmR%a<=tzW(~_SzE#Q3j3Ki-+VKR z=f@v^Jo*0n@5>_1@a@;`{`u*ri}4QUj~_qI2qEo-wTJ55(9l^sqa5l#iS%&I)Byw?ObI{0l$Brzp#C?<1@xRC3z&j+XS$$ zWPbVJ!GqSh!kmmg{q@&h&(4q78~{Q57`l%+?lysL$Jf30z@MF3t0c=ztz-qJ)F z?lvdaH#GFktkE`g=%AQqe*XFAlUHATwH5cXXU{Ig@O6H1)18cAPZ()X{ol^CuohuE z*q6gjA&gJg+XSlS+f{kOxc{S%KI(<-pZ@Ppi>T}Bzj-`#Xt?{rtoP4Ni=}BDk7na_ z<;s=WxX?->^Txg$?Tf{uGd%z{ZFi=+9dw8(eIEC*@ws>J-UR&t>k7z-F2eSJw;3}dOAeR5 z{cKw8r++zk@Zb*i>;bBBZ@lrw%zs~Nf);nK?L*&X!iT@-l~*RDoA$EA(J!0*rw=c7 zp0iKO$IU*hSUCP3>u}Ul>TqAKP5nnE+Mm8{Vm*`CxsCt(Vl>2XDa*{qavklzvCDm@ z${%Hty(BT0TW3t3WgkoD<~MHKs4V03#U-w4U-h0nduHqXCE*Rh?<#FVW^-pr_x&*e`O>CdALj)LXylJK(3lVI4(yoRO(V?IMjW4onz{qF7LCx$(m>A7`xNY z*TscCrNoui+1Qw$dGk6uzxGtezs>Vaoc}i|H^%tFg=Tiab^2;A6M8F)Z`nue=^3ks zj;`(1nHuKFd%ZrltykGW5TNvSy zHFWRV+Ng%%H@#-fD|E21F?=lClwLCoC+C{=)-Voiv|^66Th=L9M}6m=cXqw!!{Iq& z9>!hh-b%jd7K^*fdpB<_caNym{*0!$%Vdr!EXoz-hqBeiG43TC>}E_a&Roy$Wuc$N z&rlq?h?uL=S=;(}?7fnE=vqP!{(bZ*Z@>NaEuKZcw-*s9%ud$ySYwB#@&f*%{F{ST7_a2kgY$e*1t1w*ZCXTrj0Hw@dvNdg&UW=Zigm zm`AJws_Ct9#HV3Hd`u+2v ze)9<#H);Nl?Y-&qeV&HrU4BzIR!_J$#Qi=YhpOvTx>9XG$v9xB^&eZL5_fmH*JK&G z%Bp;-dPdj3N%g-j?`(Y5=YD@=Sk|{Hs`9?=GwOo%g(26N{*L(gMrV%e+7`a6I(YHo z#T@Zxepa!bSDL4D{huGHWG8PeJsQ7~{eh(*Lj8ipImRPv^eLCJ5y-I*@cX2?;DU@@ zgp+FTJcBX)PS||>@1-Bs`Qxdbg*mg+qHOS2`h5xuCw27eF)2T7@2A$vx+t5g3L~{H z2rF!fV{*Zv&c)tqdXKpLw_Tfr!+OwmY1}P6wtIffs7V~ley1uuyHnWi4)&bri224g zb8xUYg)EGo0}$Y-;ZtC-o1OHZ4BI+t_7y*fX|DupI7lt*$0qjn5SIIzOaOq&$Bjq zfY{hSN0(e8>$3gExbuA+=5DlitdX%-f;x>o!6CZ7I-IyTMGu3^IvudmxW68a$ zvfn0sLwk4MbbMCjs`fRfzOl@hv5jq1sGne**o0fC_fOe6GznYp^iKf$5$rdqi|F{9 z?y;+DPr~w_n*a8n;?=}ZHAaR_)jmihru~h5XBGRuCMgQ@qv>yQ*uHPZienw?H{qvu zb`l87Q`NQCC+-Yoy{~R(d|NSQZ{?{lPG{`NIMlv9$~Q_%#saZ#e>T<4lh5m{zwzvK z9w)%KA!YX#wga0!J`8!puDjCj#d6Ef`F-5$xc(ALzwY%zAZPsF_1bkmDd{&<)@gEG z>>EE#rAz4%OqSGIQ`7kL-sTWMXHe(-TEga#iKobPgx(W@)e>O;Pt_W0tc~E?14H>X_-g4Xb`e-F0?f7e-ZtM)VE-n6 z>@j3tujXOvmDfs*2#A0Ph=2%)fCz|y2#A0Ph=2%)fCz|y2#A0Ph=2%)fCz|y2#A0P zh=2%)fCz|y2#A0Ph=2%)fCz|y2#A0Ph=2%)fCz|y2#A0Ph=2%)fCz|y2#A0Ph=2%) zfCz|y2#A0Ph=2%)fCz|y2#A0Ph=2%)fCz|y2#A0Ph=2%)fCz|y2#A0Ph=2%)fCz|y z2#A0Ph=2%)fCz|y2#A0Ph=2%)fCz|y2#A0Ph=2%)fCz|y2#A0Ph=2%)fCz|y2(%&a E|ItwZMF0Q* literal 0 HcmV?d00001 diff --git a/img/icon.tga b/img/icon.tga new file mode 100644 index 0000000000000000000000000000000000000000..6941661753184e2b214aeedcd3b3bc7920891823 GIT binary patch literal 65554 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 KpLRT-d;cH2JQ_Oy literal 0 HcmV?d00001