commit 017e37a365b0d0ed93166f2ab4dd8a69fe73dd6c Author: rucky Date: Fri Mar 20 10:20:05 2026 +0800 第一次发版v0.1.0 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 0000000..b595226 Binary files /dev/null and b/img/arrow.tga differ diff --git a/img/icon.tga b/img/icon.tga new file mode 100644 index 0000000..6941661 Binary files /dev/null and b/img/icon.tga differ