Files
BlizzNameplatesPlus/modules/scale.lua
T
2026-10-01 23:32:25 +02:00

576 lines
18 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 GetPlateMouseoverUnit(plate)
if not plate then return nil end
-- Prefer ClassicAPI's exact nameplateN token when it is available. Besides
-- being stable for the lifetime of the projected plate, this is a normal
-- unit token and gives GameTooltip the same reaction/hostility information
-- it receives from Blizzard unit frames.
local classicToken = plate.BNPClassicUnitToken
if classicToken and UnitExists then
local existsOK, exists = pcall(UnitExists, classicToken)
if existsOK and exists then return classicToken end
end
-- SuperWoW fallback: frame:GetName(1) returns the GUID attached to the
-- nameplate. SuperWoW extends unit-taking functions to accept that GUID.
if 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
if UnitExists then
local existsOK, exists = pcall(UnitExists, unit)
if existsOK and not exists then return nil end
end
return unit
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, but on
-- some SuperWoW/ClassicAPI combinations the synthetic token can briefly
-- expose stale reaction data. That is visible as a hostile NPC name
-- flickering between yellow and red in GameTooltip. Feed GameTooltip the
-- exact plate unit instead (nameplateN when possible, GUID otherwise), just
-- like established custom unit-frame implementations do.
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
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
RestoreAnchorState(states[i], wrapper)
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