Files
BlizzNameplatesPlus/modules/scale.lua
T
2026-10-25 17:46:03 +01:00

678 lines
21 KiB
Lua

BNP = BNP or {}
local MIN_SCALE = 0.50
local MAX_SCALE = 1.50
local DEFAULT_SCALE = 1.00
local function Clamp(value)
if value < MIN_SCALE then return MIN_SCALE end
if value > MAX_SCALE then return MAX_SCALE end
return value
end
function BNP:GetNameplateScale()
BNP_DB = BNP_DB or {}
return Clamp(tonumber(BNP_DB.nameplateScale) or DEFAULT_SCALE)
end
local function GetTargetScaleMultiplier(plate)
if plate and plate.BNPIsCurrentTarget and BNP.IsTargetScaleEnabled and BNP:IsTargetScaleEnabled() then
if BNP.GetTargetScale then return BNP:GetTargetScale() end
end
return 1.0
end
local function EffectiveScale(plate)
local uiScale = 1.0
if UIParent and UIParent.GetScale then
uiScale = UIParent:GetScale() or 1.0
end
return uiScale * BNP:GetNameplateScale() * GetTargetScaleMultiplier(plate)
end
local function ApplyDetachedTargetScale(plate, multiplier)
if not plate then return end
multiplier = multiplier or 1.0
-- Auras / CC / immunities intentionally live beside the projected nameplate
-- so they do not inherit Blizzard alpha changes. The totem replacement icon
-- is also a direct child of the projected plate rather than BNPScaleWrapper.
-- Scale these few detached visuals explicitly so Target Scale enlarges the
-- COMPLETE target presentation, not only the healthbar/border/name.
local function ApplyObject(object)
if object and object.SetScale and object.BNPTargetScaleApplied ~= multiplier then
object:SetScale(multiplier)
object.BNPTargetScaleApplied = multiplier
end
end
-- Do not collect these in an ipairs table: most plates do not have every
-- optional container, and Lua 5.1 stops ipairs at the first nil entry.
ApplyObject(plate.BNPAuraContainer)
ApplyObject(plate.BNPCCContainer)
ApplyObject(plate.BNPImmunityContainer)
ApplyObject(plate.BNPTotemIndicator)
end
local function CaptureAnchorState(object, plate, wrapper)
if not object or not object.GetPoint then return nil end
local state = {
object = object,
points = {},
}
local count = 1
if object.GetNumPoints then
count = object:GetNumPoints() or 1
if count < 1 then count = 1 end
end
local i
for i = 1, count do
local point, relativeTo, relativePoint, x, y = object:GetPoint(i)
if point then
if relativeTo == plate then
relativeTo = wrapper
end
state.points[table.getn(state.points) + 1] = {
point = point,
relativeTo = relativeTo or wrapper,
relativePoint = relativePoint or point,
x = x or 0,
y = y or 0,
}
end
end
return state
end
local function StateYOffset(state)
if state and state.BNPNameAnchor and BNP.GetNameFontYOffset then
return BNP:GetNameFontYOffset()
end
return 0
end
local function AnchorStateMatches(state)
if not state or not state.object or not state.object.GetPoint then return false end
local object = state.object
local expectedCount = table.getn(state.points)
local currentCount = expectedCount
if object.GetNumPoints then
currentCount = object:GetNumPoints() or 0
end
if currentCount ~= expectedCount then
return false
end
local i
for i = 1, expectedCount do
local expected = state.points[i]
local point, relativeTo, relativePoint, x, y = object:GetPoint(i)
if point ~= expected.point
or relativeTo ~= expected.relativeTo
or relativePoint ~= expected.relativePoint
or math.abs((x or 0) - expected.x) > 0.01
or math.abs((y or 0) - (expected.y + StateYOffset(state))) > 0.01 then
return false
end
end
return true
end
local function RestoreAnchorState(state, wrapper)
if not state or not state.object then return end
local object = state.object
if object.GetParent and object.SetParent and object:GetParent() ~= wrapper then
object:SetParent(wrapper)
end
-- Do nothing in the common case. Blizzard normally only rewrites a subset
-- of nameplate anchors while moving, most notably the level FontString.
if AnchorStateMatches(state) then return end
if not object.ClearAllPoints or not object.SetPoint then return end
object:ClearAllPoints()
local i
for i = 1, table.getn(state.points) do
local p = state.points[i]
object:SetPoint(
p.point,
p.relativeTo or wrapper,
p.relativePoint or p.point,
p.x,
p.y + StateYOffset(state)
)
end
end
local function SetHoverGlow(plate, shown)
if not plate or not plate.glow then return end
if shown then
if plate.glow.Show then
plate.glow:Show()
end
else
if plate.glow.Hide then
plate.glow:Hide()
end
end
end
-- SuperWoW exposes the attached unit GUID through nameplate:GetName(1).
-- Its SetMouseoverUnit(unit) API is specifically intended for custom unit
-- frames: set it on OnEnter and clear it on OnLeave. BNP's scale/Y-offset
-- wrapper owns the mouse hit area, so without this bridge the client never
-- receives a real "mouseover" unit even though clicks are forwarded to the
-- original Blizzard nameplate.
local function GetProjectedPlateUnit(plate)
if not plate or not plate.GetName then return nil end
local ok, unit = pcall(function() return plate:GetName(1) end)
if not ok or not unit or unit == "" then return nil end
return unit
end
local function UnitGUIDSafe(unit)
local guidFn = BNP._ClassicUnitGUID or UnitGUID
if not guidFn or not unit then return nil end
local ok, guid = pcall(guidFn, unit)
if ok then return guid end
return nil
end
local function FindExactClassicPlateToken(plate)
if not plate or not UnitExists then return nil end
-- Do not trust a cached nameplateN token blindly. Nameplate frames are
-- recycled and NAME_PLATE_UNIT_REMOVED / ADDED can land around the same
-- frame that the cursor enters a reused plate. Resolve the CURRENT mapping
-- on demand. This work only happens on mouse enter, never in the background.
local getPlate = BNP._ClassicGetNamePlateForUnit
if not getPlate and C_NamePlate then
getPlate = C_NamePlate.GetNamePlateForUnit
end
if getPlate then
local projected = GetProjectedPlateUnit(plate)
local projectedGUID = UnitGUIDSafe(projected) or projected
local i
for i = 1, 40 do
local token = "nameplate" .. i
local existsOK, exists = pcall(UnitExists, token)
if existsOK and exists then
local mapOK, mappedPlate = pcall(getPlate, token)
if mapOK and mappedPlate == plate then
local tokenGUID = UnitGUIDSafe(token)
if not projectedGUID or not tokenGUID or tokenGUID == projectedGUID then
plate.BNPClassicUnitToken = token
if tokenGUID then plate.BNPClassicGUID = tokenGUID end
return token
end
end
end
end
end
-- Older ClassicAPI fallback: a cached token is still usable, but verify its
-- GUID against the unit currently attached to the SuperWoW nameplate first.
local cached = plate.BNPClassicUnitToken
if cached then
local existsOK, exists = pcall(UnitExists, cached)
if existsOK and exists then
local projected = GetProjectedPlateUnit(plate)
local projectedGUID = UnitGUIDSafe(projected) or projected
local cachedGUID = UnitGUIDSafe(cached)
if not projectedGUID or not cachedGUID or cachedGUID == projectedGUID then
return cached
end
end
-- Never carry a proven stale token into GameTooltip.
plate.BNPClassicUnitToken = nil
plate.BNPClassicGUID = nil
end
return nil
end
local function GetPlateMouseoverUnit(plate)
if not plate then return nil end
-- Prefer a freshly resolved ClassicAPI nameplateN token. It carries the
-- cleanest reaction data for GameTooltip and avoids recycled-token races.
local classicToken = FindExactClassicPlateToken(plate)
if classicToken then return classicToken end
-- SuperWoW fallback: frame:GetName(1) returns the GUID attached to the
-- nameplate. SuperWoW extends unit-taking functions to accept that GUID.
local unit = GetProjectedPlateUnit(plate)
if not unit then return nil end
if UnitExists then
local existsOK, exists = pcall(UnitExists, unit)
if existsOK and not exists then return nil end
end
return unit
end
local function ForceTooltipNPCReactionColor(unit)
if not unit or not UnitReaction then return end
-- Leave player tooltip coloring alone (class/faction/PvP coloring belongs to
-- Blizzard). The reported bug is NPC reaction color only.
if UnitIsPlayer then
local playerOK, isPlayer = pcall(UnitIsPlayer, unit)
if playerOK and isPlayer then return end
end
local reactionOK, reaction = pcall(UnitReaction, unit, "player")
if not reactionOK or not reaction then return end
local r, g, b
if FACTION_BAR_COLORS and FACTION_BAR_COLORS[reaction] then
local c = FACTION_BAR_COLORS[reaction]
r, g, b = c.r, c.g, c.b
elseif reaction <= 3 then
r, g, b = 1.0, 0.0, 0.0
elseif reaction == 4 then
r, g, b = 1.0, 1.0, 0.0
else
r, g, b = 0.0, 1.0, 0.0
end
local title
if getglobal then title = getglobal("GameTooltipTextLeft1") end
if not title and _G then title = _G["GameTooltipTextLeft1"] end
if title and title.SetTextColor then
title:SetTextColor(r, g, b)
end
end
local function ShowPlateTooltip(wrapper, unit)
if not wrapper or not unit or not GameTooltip or not GameTooltip.SetUnit then return end
if GameTooltip_SetDefaultAnchor then
GameTooltip_SetDefaultAnchor(GameTooltip, wrapper)
elseif GameTooltip.SetOwner then
GameTooltip:SetOwner(wrapper, "ANCHOR_CURSOR")
end
-- Do NOT build the tooltip from the synthetic "mouseover" token.
-- SetMouseoverUnit() is still required for mouseover macros/addons. Feed the
-- tooltip the exact current plate unit and then explicitly apply the NPC
-- reaction color. Some custom-client builds populate the correct unit/name
-- but leave line 1 with the previous neutral/hostile color.
local ok = pcall(function() GameTooltip:SetUnit(unit) end)
if not ok then
-- Compatibility fallback for older clients that cannot tooltip a GUID.
pcall(function() GameTooltip:SetUnit("mouseover") end)
end
if GameTooltip.Show then GameTooltip:Show() end
ForceTooltipNPCReactionColor(unit)
end
local function SetWrapperMouseover(plate, wrapper)
local unit = GetPlateMouseoverUnit(plate)
if not unit then return end
-- Keep ownership so a late OnLeave from an overlapping/recycled plate cannot
-- clear the mouseover unit that a newer wrapper has just established.
BNP.BNPMouseoverOwner = wrapper
wrapper.BNPMouseoverUnit = unit
if SetMouseoverUnit then
local ok = pcall(SetMouseoverUnit, unit)
if ok then
ShowPlateTooltip(wrapper, unit)
end
end
end
local function ClearWrapperMouseover(wrapper)
if BNP.BNPMouseoverOwner ~= wrapper then return end
BNP.BNPMouseoverOwner = nil
wrapper.BNPMouseoverUnit = nil
if SetMouseoverUnit then
pcall(SetMouseoverUnit)
end
if GameTooltip and GameTooltip.Hide then
GameTooltip:Hide()
end
end
local function SetYOffsetMouseMode(plate, wrapper, offset)
if not plate or not wrapper then return end
-- Always use the visual wrapper as BNP's interactive frame, including at
-- Y Offset 0. Previously offset 0 used Blizzard's native Button while
-- positive offsets used BNPScaleWrapper. That split mouse path caused the
-- original Blizzard mouseover glow to disappear at offset 0.
--
-- Keeping clicks, hit geometry and hover feedback on the same frame also
-- guarantees that the clickable area follows the visible plate at every
-- configured Y offset.
if plate.EnableMouse then
plate:EnableMouse(false)
end
if wrapper.EnableMouse then
wrapper:EnableMouse(true)
end
if wrapper.RegisterForClicks then
-- Preserve the classic Blizzard nameplate mouse behavior:
-- left click selects the unit, right click selects it and starts auto attack.
wrapper:RegisterForClicks("LeftButtonUp", "RightButtonUp")
end
if not wrapper.BNPClickForwardInstalled then
wrapper:SetScript("OnClick", function()
-- The wrapper exists only because BNP visually offsets/scales the original
-- Blizzard plate. Keep Blizzard's native nameplate click semantics by
-- forwarding the ACTUAL mouse button to the original Button.
--
-- parent:Click() without an argument always simulates LeftButton. That was
-- the reason right click turned into a plain target-select while BNP was
-- enabled. Do not call AttackTarget() manually here: it toggles auto attack
-- and is not equivalent to Blizzard's native nameplate right-click path.
local mouseButton = arg1 or "LeftButton"
local current = this or wrapper
local parent = current and current.plate or plate
if parent and parent:IsShown() and parent.Click then
parent:Click(mouseButton)
end
end)
wrapper.BNPClickForwardInstalled = true
end
if not wrapper.BNPMouseoverFeedbackInstalled then
wrapper:SetScript("OnEnter", function()
local current = this or wrapper
local parent = current and current.plate or plate
SetHoverGlow(parent, true)
SetWrapperMouseover(parent, current)
end)
wrapper:SetScript("OnLeave", function()
local current = this or wrapper
local parent = current and current.plate or plate
SetHoverGlow(parent, false)
ClearWrapperMouseover(current)
end)
-- A projected nameplate can disappear/recycle while the cursor is still
-- inside its old bounds. Clear SuperWoW's synthetic mouseover in that case
-- as well so "mouseover" can never remain stuck on a vanished unit.
wrapper:SetScript("OnHide", function()
local current = this or wrapper
ClearWrapperMouseover(current)
end)
wrapper.BNPMouseoverFeedbackInstalled = true
end
end
local function ApplyWrapperOffset(plate, wrapper)
if not plate or not wrapper then return end
local offset = 0
if BNP.GetNameplateYOffset then
offset = BNP:GetNameplateYOffset()
end
-- Move only the child visual container. The WorldFrame-projected Blizzard
-- nameplate itself keeps its original anchor and can continue to be
-- repositioned by the client every frame.
wrapper:ClearAllPoints()
wrapper:SetPoint("TOPLEFT", plate, "TOPLEFT", 0, offset)
wrapper:SetPoint("BOTTOMRIGHT", plate, "BOTTOMRIGHT", 0, offset)
SetYOffsetMouseMode(plate, wrapper, offset)
end
local function EnsureScaleWrapper(plate)
if not plate or plate.BNPScaleWrapper then return end
local originalWidth = plate:GetWidth()
local originalHeight = plate:GetHeight()
local wrapper = CreateFrame("Button", nil, plate)
wrapper:SetAllPoints(plate)
wrapper.plate = plate
wrapper.originalWidth = originalWidth
wrapper.originalHeight = originalHeight
wrapper.BNPAnchorStates = {}
wrapper:EnableMouse(false)
plate.BNPScaleWrapper = wrapper
-- Capture the ORIGINAL Blizzard anchors before changing parentage.
-- Those stored anchors become BNP's authoritative visual layout whenever
-- Y-offset is enabled.
if plate.healthbar then
local state = CaptureAnchorState(plate.healthbar, plate, wrapper)
if state then
table.insert(wrapper.BNPAnchorStates, state)
end
plate.healthbar:SetParent(wrapper)
RestoreAnchorState(state, wrapper)
plate.healthbar:SetFrameLevel(1)
end
local regions = { plate:GetRegions() }
local i, object
for i, object in pairs(regions) do
if object and object.SetParent then
local state = CaptureAnchorState(object, plate, wrapper)
if state then
if object == plate.name then
state.BNPNameAnchor = true
end
table.insert(wrapper.BNPAnchorStates, state)
end
object:SetParent(wrapper)
RestoreAnchorState(state, wrapper)
end
end
ApplyWrapperOffset(plate, wrapper)
end
function BNP:ApplyNameplateScale(plate)
if not plate then return end
EnsureScaleWrapper(plate)
local wrapper = plate.BNPScaleWrapper
if not wrapper then return end
local targetMultiplier = GetTargetScaleMultiplier(plate)
local scale = EffectiveScale(plate)
-- Target identity is checked every frame, but only touch frame geometry when
-- the actual scale changes. This keeps target switching instant without
-- continuously rewriting width/height on every visible nameplate.
if wrapper.BNPLastAppliedScale ~= scale then
wrapper:SetScale(scale)
-- ShaguTweaks also adjusts the original frame bounds so Blizzard's world
-- positioning remains correct while the child visuals honor UI scale.
plate:SetWidth(wrapper.originalWidth * scale)
plate:SetHeight(wrapper.originalHeight * scale)
wrapper.BNPLastAppliedScale = scale
end
wrapper.BNPLastTargetScaleMultiplier = targetMultiplier
ApplyDetachedTargetScale(plate, targetMultiplier)
ApplyWrapperOffset(plate, wrapper)
end
function BNP:MaintainTargetScale(plate)
if not plate or not plate.IsShown or not plate:IsShown() then return end
EnsureScaleWrapper(plate)
local wrapper = plate.BNPScaleWrapper
if not wrapper then return end
local targetMultiplier = GetTargetScaleMultiplier(plate)
if wrapper.BNPLastTargetScaleMultiplier ~= targetMultiplier then
self:ApplyNameplateScale(plate)
return
end
-- Detached frames can be created after a unit was already targeted. Keep
-- their cached multiplier in sync without doing any wrapper geometry work.
ApplyDetachedTargetScale(plate, targetMultiplier)
end
function BNP:ApplyNameFontYOffset(plate)
if not plate then return end
EnsureScaleWrapper(plate)
local wrapper = plate.BNPScaleWrapper
if not wrapper then return end
local states = wrapper.BNPAnchorStates or {}
local i
for i = 1, table.getn(states) do
local state = states[i]
if state and state.BNPNameAnchor then
RestoreAnchorState(state, wrapper)
end
end
end
function BNP:ApplyNameFontYOffsetAll()
local plate
for plate in pairs(self.plates or {}) do
if plate then self:ApplyNameFontYOffset(plate) end
end
end
function BNP:ApplyNameplateScaleAll()
local plate
for plate in pairs(self.plates) do
self:ApplyNameplateScale(plate)
end
end
function BNP:RefreshTargetScale()
self:ApplyNameplateScaleAll()
end
function BNP:ApplyNameplateYOffsetAll()
local plate
for plate in pairs(self.plates or {}) do
if plate then
self:ApplyNameplateScale(plate)
end
end
end
function BNP:MaintainNameplateYOffset(plate)
if not plate or not plate:IsShown() then return end
if not self.GetNameplateYOffset or self:GetNameplateYOffset() == 0 then return end
EnsureScaleWrapper(plate)
local wrapper = plate.BNPScaleWrapper
if not wrapper then return end
-- Never rediscover regions through plate:GetRegions() here. Once BNP moves
-- Blizzard regions into the wrapper they are no longer guaranteed to be
-- returned by plate:GetRegions(). Keep and maintain the original region
-- objects captured during wrapper creation instead.
local states = wrapper.BNPAnchorStates or {}
local i
for i = 1, table.getn(states) do
local state = states[i]
-- Raid marks own their custom Top/Left/Right anchor. Restoring the native
-- anchor here every frame fights raidmarks.lua and makes the icon jump
-- between both positions whenever the nameplate Y offset is enabled.
if state.object ~= plate.raidicon or not self.MaintainRaidMarkPosition then
RestoreAnchorState(state, wrapper)
end
end
ApplyWrapperOffset(plate, wrapper)
end
function BNP:SetNameplateScale(value)
value = tonumber(value)
if not value then
self:Print("Usage: /bnp scale 0.5-1.5")
return
end
value = Clamp(value)
BNP_DB = BNP_DB or {}
BNP_DB.nameplateScale = value
self:ApplyNameplateScaleAll()
self:Print("Nameplate scale multiplier set to " .. tostring(value) .. ".")
end
function BNP:ResetNameplateScale()
BNP_DB = BNP_DB or {}
BNP_DB.nameplateScale = DEFAULT_SCALE
self:ApplyNameplateScaleAll()
self:Print("Nameplate scale multiplier reset to 1.0 (Shagu/UI scale behavior).")
end
if BNP.libnameplate then
table.insert(BNP.libnameplate.OnInit, function(plate)
local current = plate or this
if not current then return end
BNP:ApplyNameplateScale(current)
end)
table.insert(BNP.libnameplate.OnShow, function(plate)
local current = plate or this
if not current then return end
-- Blizzard can reset projected nameplate geometry while a plate is hidden.
-- When the same target reappears, the cached multiplier is still identical
-- (for example 1.20 -> 1.20), so MaintainTargetScale would previously
-- assume nothing changed and skip reapplying the target geometry. That left
-- a recycled/reappearing target visually broken. Invalidate only BNP's
-- scale caches on every OnShow so the current plate is rebuilt once from
-- the known original dimensions. The strict target resolver in fullalpha
-- immediately corrects the multiplier afterwards if the recycled frame now
-- belongs to a different unit.
local wrapper = current.BNPScaleWrapper
if wrapper then
wrapper.BNPLastAppliedScale = nil
wrapper.BNPLastTargetScaleMultiplier = nil
end
local function InvalidateDetached(object)
if object then object.BNPTargetScaleApplied = nil end
end
InvalidateDetached(current.BNPAuraContainer)
InvalidateDetached(current.BNPCCContainer)
InvalidateDetached(current.BNPImmunityContainer)
InvalidateDetached(current.BNPTotemIndicator)
BNP:ApplyNameplateScale(current)
end)
end