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 -- Set parent to 1x1 to fully disable engine anti-overlap (ShaguPlates technique) -- Custom stacking in ResolveStacking() handles overlap instead frame:SetHeight(1) frame:SetWidth(1) -- 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 -- Threat percentage text local threatPctText = health:CreateFontString(nil, "OVERLAY") threatPctText:SetFont(NP.GetFont(), Settings.healthFontSize, NP.GetFontOutline()) threatPctText:SetPoint("CENTER", health, "CENTER", 0, 0) threatPctText:SetTextColor(1, 1, 1, 1) threatPctText:SetText("") np.threatPctText = threatPctText -- 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(1) this:SetWidth(1) 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 nameplate._stackY = nil nameplate._appliedOffY = nil nameplate.healthBG:ClearAllPoints() nameplate.healthBG:SetPoint("CENTER", nameplate, "CENTER", 0, Settings.nameplateYOffset or 0) 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 parent 1x1 to disable engine anti-overlap entirely if frame:GetHeight() > 1 or frame:GetWidth() > 1 then frame:SetHeight(1) frame:SetWidth(1) 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 local threatPctValue = nil local threatPctRole = NP.playerRole or "DPS" if NanamiPlates_Threat then local hasData, playerHasAggro, otherName, otherPct = NanamiPlates_Threat.GetTWTankModeThreat(mobGUID, plateName) if hasData then local role = threatPctRole if role == "TANK" then if playerHasAggro then threatColor = THREAT_COLORS.TANK.AGGRO threatPctValue = 100 elseif otherPct and otherPct > 70 then threatColor = THREAT_COLORS.TANK.LOSING_AGGRO threatPctValue = otherPct 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 threatPctValue = otherPct or 0 else threatColor = THREAT_COLORS.TANK.NO_AGGRO threatPctValue = otherPct or 0 end else if playerHasAggro then threatColor = THREAT_COLORS.DPS.AGGRO threatPctValue = 100 elseif otherPct and otherPct > 70 then threatColor = THREAT_COLORS.DPS.HIGH_THREAT threatPctValue = otherPct else threatColor = THREAT_COLORS.DPS.NO_AGGRO threatPctValue = otherPct or 0 end end end if not hasData and NanamiDPS and NanamiDPS.ThreatEngine then local TE = NanamiDPS.ThreatEngine if TE.inCombat and unitstr then local data = TE:QueryUnitThreat(unitstr) if not data and plateName then local tk = TE.GetTargetKey and TE.GetTargetKey(unitstr) if tk then local t, isTk, pct = TE:QueryNameThreat(tk, UnitName("player")) if t > 0 then data = { pct = pct, isTanking = isTk, threat = t, tankThreat = 0, secondPct = 0, secondName = nil } end end end if data then local role = threatPctRole if role == "TANK" then if data.isTanking then threatColor = THREAT_COLORS.TANK.AGGRO threatPctValue = data.secondPct or 0 else threatColor = THREAT_COLORS.TANK.NO_AGGRO threatPctValue = data.pct or 0 end else if data.isTanking then threatColor = THREAT_COLORS.DPS.AGGRO threatPctValue = 100 elseif data.pct and data.pct > 70 then threatColor = THREAT_COLORS.DPS.HIGH_THREAT threatPctValue = data.pct else threatColor = THREAT_COLORS.DPS.NO_AGGRO threatPctValue = data.pct or 0 end end 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 threatPctValue = nil break end end end end if threatColor then barR, barG, barB = threatColor[1], threatColor[2], threatColor[3] end if np.threatPctText then if threatPctValue and threatColor then np.threatPctText:SetText(string.format("%.0f%%", threatPctValue)) np.threatPctText:SetTextColor(threatColor[1], threatColor[2], threatColor[3], 1) np.threatPctText:Show() else np.threatPctText:SetText("") np.threatPctText:Hide() end end else if np.threatPctText then np.threatPctText:SetText("") np.threatPctText:Hide() 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 -- Nameplate vertical stacking resolution -- Engine anti-overlap is fully disabled (parent set to 1x1 like ShaguPlates), -- so we resolve overlaps ourselves with a stable algorithm. local stackPool = {} local stackN = 0 local STACK_SMOOTH = 0.15 local function ResolveStacking() if not Settings.enableStacking then -- Stacking disabled: decay any residual offsets from when it was enabled for frame, np in pairs(registry) do if np._stackY and np._stackY ~= 0 then local new = np._stackY * (1 - STACK_SMOOTH * 3) if new > -0.5 and new < 0.5 then new = 0 end np._stackY = new np._appliedOffY = (Settings.nameplateYOffset or 0) + new np.healthBG:ClearAllPoints() np.healthBG:SetPoint("CENTER", np, "CENTER", 0, np._appliedOffY) end end return end stackN = 0 for frame, np in pairs(registry) do if frame:IsShown() and np:IsShown() then local cx, cy = frame:GetCenter() if cx and cy then stackN = stackN + 1 if not stackPool[stackN] then stackPool[stackN] = {} end local e = stackPool[stackN] e.np = np e.cx = cx e.cy = cy e.name = np.plateName or "" e.stackTarget = 0 end end end if stackN < 1 then return end -- Single plate: decay any residual stacking offset if stackN < 2 then local np = stackPool[1].np if np._stackY and np._stackY ~= 0 then local new = np._stackY * (1 - STACK_SMOOTH * 3) if new > -0.5 and new < 0.5 then new = 0 end np._stackY = new np._appliedOffY = (Settings.nameplateYOffset or 0) + new np.healthBG:ClearAllPoints() np.healthBG:SetPoint("CENTER", np, "CENTER", 0, np._appliedOffY) end return end -- Stable bubble sort: Y descending (higher on screen first), name as tiebreaker for i = stackN, 2, -1 do for j = 1, i - 1 do local a = stackPool[j] local b = stackPool[j + 1] local ay = math_floor(a.cy) local by = math_floor(b.cy) if by > ay or (by == ay and b.name < a.name) then stackPool[j] = b stackPool[j + 1] = a end end end -- Resolve vertical overlaps: push lower plates down when horizontally close local minSep = (Settings.healthbarHeight or 12) + BORDER_PAD * 2 + (Settings.nameFontSize or 9) + 6 local overlapW = (Settings.healthbarWidth or 120) * 0.8 for i = 1, stackN do for j = i + 1, stackN do local a = stackPool[i] local b = stackPool[j] local dx = a.cx - b.cx if dx < 0 then dx = -dx end if dx < overlapW then local ya = a.cy + a.stackTarget local yb = b.cy + b.stackTarget local gap = ya - yb if gap < minSep then b.stackTarget = b.stackTarget - (minSep - gap) end end end end -- Apply stacking offsets with smooth convergence for i = 1, stackN do local e = stackPool[i] local np = e.np local curStack = np._stackY or 0 local tarStack = e.stackTarget local d = tarStack - curStack local newStack if d > -0.5 and d < 0.5 then newStack = tarStack else newStack = curStack + d * STACK_SMOOTH end if newStack > -0.3 and newStack < 0.3 then newStack = 0 end np._stackY = newStack local offY = (Settings.nameplateYOffset or 0) + newStack local pY = np._appliedOffY or (Settings.nameplateYOffset or 0) if (offY - pY) > 0.5 or (offY - pY) < -0.5 then np._appliedOffY = offY np.healthBG:ClearAllPoints() np.healthBG:SetPoint("CENTER", np, "CENTER", 0, offY) 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 -- Resolve stacking overlaps and smooth position jitter ResolveStacking() -- 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)