864 lines
31 KiB
C#
864 lines
31 KiB
C#
using Dalamud.Game.ClientState.Objects.Enums;
|
|
using Dalamud.Plugin.Services;
|
|
using FFXIVClientStructs.FFXIV.Client.System.Framework;
|
|
using FFXIVClientStructs.FFXIV.Client.Game.Character;
|
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
|
using FFXIVClientStructs.FFXIV.Component.GUI;
|
|
using LightlessSync.LightlessConfiguration;
|
|
using LightlessSync.Services.Mediator;
|
|
using LightlessSync.UI;
|
|
using LightlessSync.UI.Services;
|
|
using LightlessSync.Utils;
|
|
using LightlessSync.UtilsEnum.Enum;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Collections.Immutable;
|
|
using Task = System.Threading.Tasks.Task;
|
|
|
|
namespace LightlessSync.Services.LightFinder;
|
|
|
|
/// <summary>
|
|
/// Native nameplate handler that injects LightFinder labels via the signature hook path.
|
|
/// </summary>
|
|
public unsafe class LightFinderNativePlateHandler : DisposableMediatorSubscriberBase, IHostedService
|
|
{
|
|
private const uint NameplateNodeIdBase = 0x7D99D500;
|
|
private const string DefaultLabelText = "LightFinder";
|
|
|
|
private readonly ILogger<LightFinderNativePlateHandler> _logger;
|
|
private readonly IClientState _clientState;
|
|
private readonly IObjectTable _objectTable;
|
|
private readonly LightlessConfigService _configService;
|
|
private readonly PairUiService _pairUiService;
|
|
private readonly NameplateUpdateHookService _nameplateUpdateHookService;
|
|
|
|
private readonly int[] _cachedNameplateTextWidths = new int[AddonNamePlate.NumNamePlateObjects];
|
|
private readonly int[] _cachedNameplateTextHeights = new int[AddonNamePlate.NumNamePlateObjects];
|
|
private readonly int[] _cachedNameplateContainerHeights = new int[AddonNamePlate.NumNamePlateObjects];
|
|
private readonly int[] _cachedNameplateTextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
|
|
private readonly string?[] _lastLabelByIndex = new string?[AddonNamePlate.NumNamePlateObjects];
|
|
|
|
private ImmutableHashSet<string> _activeBroadcastingCids = [];
|
|
private LightfinderLabelRenderer _lastRenderer;
|
|
private uint _lastSignatureUpdateFrame;
|
|
private bool _isUpdating;
|
|
private string _lastLabelContent = DefaultLabelText;
|
|
|
|
public LightFinderNativePlateHandler(
|
|
ILogger<LightFinderNativePlateHandler> logger,
|
|
IClientState clientState,
|
|
LightlessConfigService configService,
|
|
LightlessMediator mediator,
|
|
IObjectTable objectTable,
|
|
PairUiService pairUiService,
|
|
NameplateUpdateHookService nameplateUpdateHookService) : base(logger, mediator)
|
|
{
|
|
_logger = logger;
|
|
_clientState = clientState;
|
|
_configService = configService;
|
|
_objectTable = objectTable;
|
|
_pairUiService = pairUiService;
|
|
_nameplateUpdateHookService = nameplateUpdateHookService;
|
|
_lastRenderer = _configService.Current.LightfinderLabelRenderer;
|
|
|
|
Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
|
}
|
|
|
|
private bool IsSignatureMode => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.SignatureHook;
|
|
|
|
/// <summary>
|
|
/// Starts listening for nameplate updates from the hook service.
|
|
/// </summary>
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
_nameplateUpdateHookService.NameplateUpdated += OnNameplateUpdated;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops listening for nameplate updates and tears down any constructed nodes.
|
|
/// </summary>
|
|
public Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
_nameplateUpdateHookService.NameplateUpdated -= OnNameplateUpdated;
|
|
UnsubscribeAll();
|
|
TryDestroyNameplateNodes();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Triggered by the sig hook to refresh native nameplate labels.
|
|
/// </summary>
|
|
private void HandleNameplateUpdate(RaptureAtkModule* raptureAtkModule)
|
|
{
|
|
if (_isUpdating)
|
|
return;
|
|
|
|
_isUpdating = true;
|
|
try
|
|
{
|
|
RefreshRendererState();
|
|
if (!IsSignatureMode)
|
|
return;
|
|
|
|
if (raptureAtkModule == null)
|
|
return;
|
|
|
|
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
|
if (namePlateAddon == null)
|
|
return;
|
|
|
|
if (_clientState.IsGPosing)
|
|
{
|
|
HideAllNameplateNodes(namePlateAddon);
|
|
return;
|
|
}
|
|
|
|
var fw = Framework.Instance();
|
|
if (fw == null)
|
|
return;
|
|
|
|
var frame = fw->FrameCounter;
|
|
if (_lastSignatureUpdateFrame == frame)
|
|
return;
|
|
|
|
_lastSignatureUpdateFrame = frame;
|
|
UpdateNameplateNodes(namePlateAddon);
|
|
}
|
|
finally
|
|
{
|
|
_isUpdating = false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hook callback from the nameplate update signature.
|
|
/// </summary>
|
|
private void OnNameplateUpdated(RaptureAtkModule* raptureAtkModule, RaptureAtkModule.NamePlateInfo* namePlateInfo,
|
|
NumberArrayData* numArray, StringArrayData* stringArray, BattleChara* battleChara, int numArrayIndex, int stringArrayIndex)
|
|
{
|
|
HandleNameplateUpdate(raptureAtkModule);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the active broadcasting CID set and requests a nameplate redraw.
|
|
/// </summary>
|
|
public void UpdateBroadcastingCids(IEnumerable<string> cids)
|
|
{
|
|
var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal);
|
|
if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet))
|
|
return;
|
|
|
|
_activeBroadcastingCids = newSet;
|
|
if (_logger.IsEnabled(LogLevel.Trace))
|
|
_logger.LogTrace("Active broadcast IDs (native): {Cids}", string.Join(',', _activeBroadcastingCids));
|
|
RequestNameplateRedraw();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sync renderer state with config and clear/remove native nodes if needed.
|
|
/// </summary>
|
|
private void RefreshRendererState()
|
|
{
|
|
var renderer = _configService.Current.LightfinderLabelRenderer;
|
|
if (renderer == _lastRenderer)
|
|
return;
|
|
|
|
_lastRenderer = renderer;
|
|
|
|
if (renderer == LightfinderLabelRenderer.SignatureHook)
|
|
{
|
|
ClearNameplateCaches();
|
|
RequestNameplateRedraw();
|
|
}
|
|
else
|
|
{
|
|
TryDestroyNameplateNodes();
|
|
ClearNameplateCaches();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Requests a full nameplate update through the native addon.
|
|
/// </summary>
|
|
private void RequestNameplateRedraw()
|
|
{
|
|
if (!IsSignatureMode)
|
|
return;
|
|
|
|
var raptureAtkModule = GetRaptureAtkModule();
|
|
if (raptureAtkModule == null)
|
|
return;
|
|
|
|
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
|
if (namePlateAddon == null)
|
|
return;
|
|
|
|
namePlateAddon->DoFullUpdate = 1;
|
|
}
|
|
|
|
private HashSet<ulong> VisibleUserIds
|
|
=> [.. _pairUiService.GetSnapshot().PairsByUid.Values
|
|
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
|
.Select(u => (ulong)u.PlayerCharacterId)];
|
|
|
|
/// <summary>
|
|
/// Creates/updates LightFinder label nodes for active broadcasts.
|
|
/// </summary>
|
|
private void UpdateNameplateNodes(AddonNamePlate* namePlateAddon)
|
|
{
|
|
if (namePlateAddon == null)
|
|
{
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
|
|
return;
|
|
}
|
|
|
|
if (!IsNameplateAddonVisible(namePlateAddon))
|
|
return;
|
|
|
|
if (!IsSignatureMode)
|
|
{
|
|
HideAllNameplateNodes(namePlateAddon);
|
|
return;
|
|
}
|
|
|
|
if (_activeBroadcastingCids.Count == 0)
|
|
{
|
|
HideAllNameplateNodes(namePlateAddon);
|
|
return;
|
|
}
|
|
|
|
var framework = Framework.Instance();
|
|
if (framework == null)
|
|
{
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
_logger.LogDebug("Framework instance unavailable during nameplate update, skipping.");
|
|
return;
|
|
}
|
|
|
|
var uiModule = framework->GetUIModule();
|
|
if (uiModule == null)
|
|
{
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
_logger.LogDebug("UI module unavailable during nameplate update, skipping.");
|
|
return;
|
|
}
|
|
|
|
var ui3DModule = uiModule->GetUI3DModule();
|
|
if (ui3DModule == null)
|
|
{
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
_logger.LogDebug("UI3D module unavailable during nameplate update, skipping.");
|
|
return;
|
|
}
|
|
|
|
var vec = ui3DModule->NamePlateObjectInfoPointers;
|
|
if (vec.IsEmpty)
|
|
return;
|
|
|
|
var config = _configService.Current;
|
|
var visibleUserIdsSnapshot = VisibleUserIds;
|
|
var labelColor = UIColors.Get("Lightfinder");
|
|
var edgeColor = UIColors.Get("LightfinderEdge");
|
|
var scaleMultiplier = Math.Clamp(config.LightfinderLabelScale, 0.5f, 2.0f);
|
|
var baseScale = config.LightfinderLabelUseIcon ? 1.0f : 0.5f;
|
|
var effectiveScale = baseScale * scaleMultiplier;
|
|
var labelContent = config.LightfinderLabelUseIcon
|
|
? LightFinderPlateHandler.NormalizeIconGlyph(config.LightfinderLabelIconGlyph)
|
|
: DefaultLabelText;
|
|
|
|
if (!config.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
|
|
labelContent = DefaultLabelText;
|
|
|
|
if (!string.Equals(_lastLabelContent, labelContent, StringComparison.Ordinal))
|
|
{
|
|
_lastLabelContent = labelContent;
|
|
Array.Fill(_lastLabelByIndex, null);
|
|
}
|
|
|
|
var desiredFontType = config.LightfinderLabelUseIcon ? FontType.Axis : FontType.MiedingerMed;
|
|
var baseFontSize = config.LightfinderLabelUseIcon ? 36f : 24f;
|
|
var desiredFontSize = (byte)Math.Clamp((int)Math.Round(baseFontSize * scaleMultiplier), 1, 255);
|
|
var desiredFlags = config.LightfinderLabelUseIcon
|
|
? TextFlags.Edge | TextFlags.Glare | TextFlags.AutoAdjustNodeSize
|
|
: TextFlags.Edge | TextFlags.Glare;
|
|
var desiredLineSpacing = (byte)Math.Clamp((int)Math.Round(24 * scaleMultiplier), 0, byte.MaxValue);
|
|
var defaultNodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
|
var defaultNodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
|
|
|
|
var safeCount = Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length);
|
|
var visibleIndices = new bool[AddonNamePlate.NumNamePlateObjects];
|
|
|
|
for (int i = 0; i < safeCount; ++i)
|
|
{
|
|
var objectInfoPtr = vec[i];
|
|
if (objectInfoPtr == null)
|
|
continue;
|
|
|
|
var objectInfo = objectInfoPtr.Value;
|
|
if (objectInfo == null || objectInfo->GameObject == null)
|
|
continue;
|
|
|
|
var nameplateIndex = objectInfo->NamePlateIndex;
|
|
if (nameplateIndex < 0 || nameplateIndex >= AddonNamePlate.NumNamePlateObjects)
|
|
continue;
|
|
|
|
var gameObject = objectInfo->GameObject;
|
|
if ((ObjectKind)gameObject->ObjectKind != ObjectKind.Player)
|
|
continue;
|
|
|
|
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
|
|
if (cid == null || !_activeBroadcastingCids.Contains(cid))
|
|
continue;
|
|
|
|
var local = _objectTable.LocalPlayer;
|
|
if (!config.LightfinderLabelShowOwn && local != null &&
|
|
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
|
|
continue;
|
|
|
|
var hidePaired = !config.LightfinderLabelShowPaired;
|
|
var goId = (ulong)gameObject->GetGameObjectId();
|
|
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
|
|
continue;
|
|
|
|
var nameplateObject = namePlateAddon->NamePlateObjectArray[nameplateIndex];
|
|
var root = nameplateObject.RootComponentNode;
|
|
var nameContainer = nameplateObject.NameContainer;
|
|
var nameText = nameplateObject.NameText;
|
|
var marker = nameplateObject.MarkerIcon;
|
|
|
|
if (root == null || root->Component == null || nameContainer == null || nameText == null)
|
|
{
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
_logger.LogDebug("Nameplate {Index} missing required nodes during update, skipping.", nameplateIndex);
|
|
continue;
|
|
}
|
|
|
|
var nodeId = GetNameplateNodeId(nameplateIndex);
|
|
var pNode = EnsureNameplateTextNode(nameContainer, root, nodeId, out var nodeCreated);
|
|
if (pNode == null)
|
|
continue;
|
|
|
|
bool isVisible =
|
|
((marker != null) && marker->AtkResNode.IsVisible()) ||
|
|
(nameContainer->IsVisible() && nameText->AtkResNode.IsVisible()) ||
|
|
config.LightfinderLabelShowHidden;
|
|
|
|
if (!isVisible)
|
|
continue;
|
|
|
|
if (!pNode->AtkResNode.IsVisible())
|
|
pNode->AtkResNode.ToggleVisibility(enable: true);
|
|
visibleIndices[nameplateIndex] = true;
|
|
|
|
if (nodeCreated)
|
|
pNode->AtkResNode.SetUseDepthBasedPriority(enable: true);
|
|
|
|
var scaleMatches = NearlyEqual(pNode->AtkResNode.ScaleX, effectiveScale) &&
|
|
NearlyEqual(pNode->AtkResNode.ScaleY, effectiveScale);
|
|
if (!scaleMatches)
|
|
pNode->AtkResNode.SetScale(effectiveScale, effectiveScale);
|
|
|
|
var fontTypeChanged = pNode->FontType != desiredFontType;
|
|
if (fontTypeChanged)
|
|
pNode->FontType = desiredFontType;
|
|
|
|
var fontSizeChanged = pNode->FontSize != desiredFontSize;
|
|
if (fontSizeChanged)
|
|
pNode->FontSize = desiredFontSize;
|
|
|
|
var needsTextUpdate = nodeCreated ||
|
|
!string.Equals(_lastLabelByIndex[nameplateIndex], labelContent, StringComparison.Ordinal);
|
|
if (needsTextUpdate)
|
|
{
|
|
pNode->SetText(labelContent);
|
|
_lastLabelByIndex[nameplateIndex] = labelContent;
|
|
}
|
|
|
|
var flagsChanged = pNode->TextFlags != desiredFlags;
|
|
var nodeWidth = (int)pNode->AtkResNode.GetWidth();
|
|
if (nodeWidth <= 0)
|
|
nodeWidth = defaultNodeWidth;
|
|
var nodeHeight = defaultNodeHeight;
|
|
AlignmentType alignment;
|
|
|
|
var textScaleY = nameText->AtkResNode.ScaleY;
|
|
if (textScaleY <= 0f)
|
|
textScaleY = 1f;
|
|
|
|
var blockHeight = Math.Abs((int)nameplateObject.TextH);
|
|
if (blockHeight > 0)
|
|
{
|
|
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
|
|
}
|
|
else
|
|
{
|
|
blockHeight = _cachedNameplateTextHeights[nameplateIndex];
|
|
}
|
|
|
|
if (blockHeight <= 0)
|
|
{
|
|
blockHeight = GetScaledTextHeight(nameText);
|
|
if (blockHeight <= 0)
|
|
blockHeight = nodeHeight;
|
|
|
|
_cachedNameplateTextHeights[nameplateIndex] = blockHeight;
|
|
}
|
|
|
|
var containerHeight = (int)nameContainer->Height;
|
|
if (containerHeight > 0)
|
|
{
|
|
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
|
|
}
|
|
else
|
|
{
|
|
containerHeight = _cachedNameplateContainerHeights[nameplateIndex];
|
|
}
|
|
|
|
if (containerHeight <= 0)
|
|
{
|
|
containerHeight = blockHeight + (int)Math.Round(8 * textScaleY);
|
|
if (containerHeight <= blockHeight)
|
|
containerHeight = blockHeight + 1;
|
|
|
|
_cachedNameplateContainerHeights[nameplateIndex] = containerHeight;
|
|
}
|
|
|
|
var blockTop = containerHeight - blockHeight;
|
|
if (blockTop < 0)
|
|
blockTop = 0;
|
|
var verticalPadding = (int)Math.Round(4 * effectiveScale);
|
|
|
|
var positionY = blockTop - verticalPadding - nodeHeight;
|
|
|
|
var textWidth = Math.Abs((int)nameplateObject.TextW);
|
|
if (textWidth <= 0)
|
|
{
|
|
textWidth = GetScaledTextWidth(nameText);
|
|
if (textWidth <= 0)
|
|
textWidth = nodeWidth;
|
|
}
|
|
|
|
if (textWidth > 0)
|
|
{
|
|
_cachedNameplateTextWidths[nameplateIndex] = textWidth;
|
|
}
|
|
|
|
var textOffset = (int)Math.Round(nameText->AtkResNode.X);
|
|
var hasValidOffset = false;
|
|
|
|
if (Math.Abs((int)nameplateObject.TextW) > 0 || textOffset != 0)
|
|
{
|
|
_cachedNameplateTextOffsets[nameplateIndex] = textOffset;
|
|
hasValidOffset = true;
|
|
}
|
|
else if (_cachedNameplateTextOffsets[nameplateIndex] != int.MinValue)
|
|
{
|
|
hasValidOffset = true;
|
|
}
|
|
|
|
int positionX;
|
|
|
|
if (!config.LightfinderLabelUseIcon)
|
|
{
|
|
var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged;
|
|
if (flagsChanged)
|
|
pNode->TextFlags = desiredFlags;
|
|
|
|
if (needsWidthRefresh)
|
|
{
|
|
if (pNode->AtkResNode.Width != 0)
|
|
pNode->AtkResNode.Width = 0;
|
|
nodeWidth = (int)pNode->AtkResNode.GetWidth();
|
|
if (nodeWidth <= 0)
|
|
nodeWidth = defaultNodeWidth;
|
|
}
|
|
|
|
if (pNode->AtkResNode.Width != (ushort)nodeWidth)
|
|
pNode->AtkResNode.Width = (ushort)nodeWidth;
|
|
}
|
|
else
|
|
{
|
|
var needsWidthRefresh = nodeCreated || needsTextUpdate || !scaleMatches || fontTypeChanged || fontSizeChanged || flagsChanged;
|
|
if (flagsChanged)
|
|
pNode->TextFlags = desiredFlags;
|
|
|
|
if (needsWidthRefresh && pNode->AtkResNode.Width != 0)
|
|
pNode->AtkResNode.Width = 0;
|
|
nodeWidth = pNode->AtkResNode.GetWidth();
|
|
}
|
|
|
|
if (config.LightfinderAutoAlign && nameContainer != null && hasValidOffset)
|
|
{
|
|
var nameplateWidth = (int)nameContainer->Width;
|
|
|
|
int leftPos = nameplateWidth / 8;
|
|
int rightPos = nameplateWidth - nodeWidth - (nameplateWidth / 8);
|
|
int centrePos = (nameplateWidth - nodeWidth) / 2;
|
|
int staticMargin = 24;
|
|
int calcMargin = (int)(nameplateWidth * 0.08f);
|
|
|
|
switch (config.LabelAlignment)
|
|
{
|
|
case LabelAlignment.Left:
|
|
positionX = config.LightfinderLabelUseIcon ? leftPos + staticMargin : leftPos;
|
|
alignment = AlignmentType.BottomLeft;
|
|
break;
|
|
case LabelAlignment.Right:
|
|
positionX = config.LightfinderLabelUseIcon ? rightPos - staticMargin : nameplateWidth - nodeWidth + calcMargin;
|
|
alignment = AlignmentType.BottomRight;
|
|
break;
|
|
default:
|
|
positionX = config.LightfinderLabelUseIcon ? centrePos : centrePos + calcMargin;
|
|
alignment = AlignmentType.Bottom;
|
|
break;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
positionX = 58 + config.LightfinderLabelOffsetX;
|
|
alignment = AlignmentType.Bottom;
|
|
}
|
|
|
|
positionY += config.LightfinderLabelOffsetY;
|
|
|
|
alignment = (AlignmentType)Math.Clamp((int)alignment, 0, 8);
|
|
if (pNode->AtkResNode.Color.A != 255)
|
|
pNode->AtkResNode.Color.A = 255;
|
|
|
|
var textR = (byte)(labelColor.X * 255);
|
|
var textG = (byte)(labelColor.Y * 255);
|
|
var textB = (byte)(labelColor.Z * 255);
|
|
var textA = (byte)(labelColor.W * 255);
|
|
|
|
if (pNode->TextColor.R != textR || pNode->TextColor.G != textG ||
|
|
pNode->TextColor.B != textB || pNode->TextColor.A != textA)
|
|
{
|
|
pNode->TextColor.R = textR;
|
|
pNode->TextColor.G = textG;
|
|
pNode->TextColor.B = textB;
|
|
pNode->TextColor.A = textA;
|
|
}
|
|
|
|
var edgeR = (byte)(edgeColor.X * 255);
|
|
var edgeG = (byte)(edgeColor.Y * 255);
|
|
var edgeB = (byte)(edgeColor.Z * 255);
|
|
var edgeA = (byte)(edgeColor.W * 255);
|
|
|
|
if (pNode->EdgeColor.R != edgeR || pNode->EdgeColor.G != edgeG ||
|
|
pNode->EdgeColor.B != edgeB || pNode->EdgeColor.A != edgeA)
|
|
{
|
|
pNode->EdgeColor.R = edgeR;
|
|
pNode->EdgeColor.G = edgeG;
|
|
pNode->EdgeColor.B = edgeB;
|
|
pNode->EdgeColor.A = edgeA;
|
|
}
|
|
|
|
var desiredAlignment = config.LightfinderLabelUseIcon ? alignment : AlignmentType.Bottom;
|
|
if (pNode->AlignmentType != desiredAlignment)
|
|
pNode->AlignmentType = desiredAlignment;
|
|
|
|
var desiredX = (short)Math.Clamp(positionX, short.MinValue, short.MaxValue);
|
|
var desiredY = (short)Math.Clamp(positionY, short.MinValue, short.MaxValue);
|
|
if (!NearlyEqual(pNode->AtkResNode.X, desiredX) || !NearlyEqual(pNode->AtkResNode.Y, desiredY))
|
|
pNode->AtkResNode.SetPositionShort(desiredX, desiredY);
|
|
|
|
if (pNode->LineSpacing != desiredLineSpacing)
|
|
pNode->LineSpacing = desiredLineSpacing;
|
|
if (pNode->CharSpacing != 1)
|
|
pNode->CharSpacing = 1;
|
|
}
|
|
|
|
HideUnmarkedNodes(namePlateAddon, visibleIndices);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve the current RaptureAtkModule for native UI access.
|
|
/// </summary>
|
|
private static RaptureAtkModule* GetRaptureAtkModule()
|
|
{
|
|
var framework = Framework.Instance();
|
|
if (framework == null)
|
|
return null;
|
|
|
|
var uiModule = framework->GetUIModule();
|
|
if (uiModule == null)
|
|
return null;
|
|
|
|
return uiModule->GetRaptureAtkModule();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolve the NamePlate addon from the given RaptureAtkModule.
|
|
/// </summary>
|
|
private static AddonNamePlate* GetNamePlateAddon(RaptureAtkModule* raptureAtkModule)
|
|
{
|
|
if (raptureAtkModule == null)
|
|
return null;
|
|
|
|
var addon = raptureAtkModule->RaptureAtkUnitManager.GetAddonByName("NamePlate");
|
|
return addon != null ? (AddonNamePlate*)addon : null;
|
|
}
|
|
|
|
private static uint GetNameplateNodeId(int index)
|
|
=> NameplateNodeIdBase + (uint)index;
|
|
|
|
/// <summary>
|
|
/// Checks if the NamePlate addon is visible and safe to touch.
|
|
/// </summary>
|
|
private static bool IsNameplateAddonVisible(AddonNamePlate* namePlateAddon)
|
|
{
|
|
if (namePlateAddon == null)
|
|
return false;
|
|
|
|
var root = namePlateAddon->AtkUnitBase.RootNode;
|
|
return root != null && root->IsVisible();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds a LightFinder text node by ID in the name container.
|
|
/// </summary>
|
|
private static AtkTextNode* FindNameplateTextNode(AtkResNode* nameContainer, uint nodeId)
|
|
{
|
|
if (nameContainer == null)
|
|
return null;
|
|
|
|
var child = nameContainer->ChildNode;
|
|
while (child != null)
|
|
{
|
|
if (child->NodeId == nodeId &&
|
|
child->Type == NodeType.Text &&
|
|
child->ParentNode == nameContainer)
|
|
return (AtkTextNode*)child;
|
|
|
|
child = child->PrevSiblingNode;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ensures a LightFinder text node exists for the given nameplate index.
|
|
/// </summary>
|
|
private static AtkTextNode* EnsureNameplateTextNode(AtkResNode* nameContainer, AtkComponentNode* root, uint nodeId, out bool created)
|
|
{
|
|
created = false;
|
|
if (nameContainer == null || root == null || root->Component == null)
|
|
return null;
|
|
|
|
var existing = FindNameplateTextNode(nameContainer, nodeId);
|
|
if (existing != null)
|
|
return existing;
|
|
|
|
if (nameContainer->ChildNode == null)
|
|
return null;
|
|
|
|
var newNode = AtkNodeHelpers.CreateOrphanTextNode(nodeId, TextFlags.Edge | TextFlags.Glare);
|
|
if (newNode == null)
|
|
return null;
|
|
|
|
var lastChild = nameContainer->ChildNode;
|
|
while (lastChild->PrevSiblingNode != null)
|
|
lastChild = lastChild->PrevSiblingNode;
|
|
|
|
newNode->AtkResNode.NextSiblingNode = lastChild;
|
|
newNode->AtkResNode.ParentNode = nameContainer;
|
|
lastChild->PrevSiblingNode = (AtkResNode*)newNode;
|
|
root->Component->UldManager.UpdateDrawNodeList();
|
|
newNode->AtkResNode.SetUseDepthBasedPriority(true);
|
|
|
|
created = true;
|
|
return newNode;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hides all native LightFinder nodes on the nameplate addon.
|
|
/// </summary>
|
|
private static void HideAllNameplateNodes(AddonNamePlate* namePlateAddon)
|
|
{
|
|
if (namePlateAddon == null)
|
|
return;
|
|
|
|
if (!IsNameplateAddonVisible(namePlateAddon))
|
|
return;
|
|
|
|
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
|
{
|
|
HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hides all LightFinder nodes not marked as visible this frame.
|
|
/// </summary>
|
|
private static void HideUnmarkedNodes(AddonNamePlate* namePlateAddon, bool[] visibleIndices)
|
|
{
|
|
if (namePlateAddon == null)
|
|
return;
|
|
|
|
if (!IsNameplateAddonVisible(namePlateAddon))
|
|
return;
|
|
|
|
var visibleLength = visibleIndices.Length;
|
|
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
|
{
|
|
if (i < visibleLength && visibleIndices[i])
|
|
continue;
|
|
|
|
HideNameplateTextNode(namePlateAddon->NamePlateObjectArray[i], GetNameplateNodeId(i));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hides the LightFinder text node for a single nameplate object.
|
|
/// </summary>
|
|
private static void HideNameplateTextNode(AddonNamePlate.NamePlateObject nameplateObject, uint nodeId)
|
|
{
|
|
var nameContainer = nameplateObject.NameContainer;
|
|
if (nameContainer == null)
|
|
return;
|
|
|
|
var node = FindNameplateTextNode(nameContainer, nodeId);
|
|
if (!IsValidNameplateTextNode(node, nameContainer))
|
|
return;
|
|
|
|
node->AtkResNode.ToggleVisibility(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to destroy all constructed LightFinder nodes safely.
|
|
/// </summary>
|
|
private void TryDestroyNameplateNodes()
|
|
{
|
|
var raptureAtkModule = GetRaptureAtkModule();
|
|
if (raptureAtkModule == null)
|
|
{
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
_logger.LogDebug("Unable to destroy nameplate nodes because the RaptureAtkModule is not available.");
|
|
return;
|
|
}
|
|
|
|
var namePlateAddon = GetNamePlateAddon(raptureAtkModule);
|
|
if (namePlateAddon == null)
|
|
{
|
|
if (_logger.IsEnabled(LogLevel.Debug))
|
|
_logger.LogDebug("Unable to destroy nameplate nodes because the NamePlate addon is not available.");
|
|
return;
|
|
}
|
|
|
|
DestroyNameplateNodes(namePlateAddon);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes all constructed LightFinder nodes from the given nameplate addon.
|
|
/// </summary>
|
|
private void DestroyNameplateNodes(AddonNamePlate* namePlateAddon)
|
|
{
|
|
if (namePlateAddon == null)
|
|
return;
|
|
|
|
for (int i = 0; i < AddonNamePlate.NumNamePlateObjects; ++i)
|
|
{
|
|
var nameplateObject = namePlateAddon->NamePlateObjectArray[i];
|
|
var root = nameplateObject.RootComponentNode;
|
|
var nameContainer = nameplateObject.NameContainer;
|
|
if (root == null || root->Component == null || nameContainer == null)
|
|
continue;
|
|
|
|
var nodeId = GetNameplateNodeId(i);
|
|
var textNode = FindNameplateTextNode(nameContainer, nodeId);
|
|
if (!IsValidNameplateTextNode(textNode, nameContainer))
|
|
continue;
|
|
|
|
try
|
|
{
|
|
var resNode = &textNode->AtkResNode;
|
|
|
|
if (resNode->PrevSiblingNode != null)
|
|
resNode->PrevSiblingNode->NextSiblingNode = resNode->NextSiblingNode;
|
|
if (resNode->NextSiblingNode != null)
|
|
resNode->NextSiblingNode->PrevSiblingNode = resNode->PrevSiblingNode;
|
|
|
|
root->Component->UldManager.UpdateDrawNodeList();
|
|
resNode->Destroy(true);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
_logger.LogError(e, "Unknown error while removing text node 0x{Node:X} for nameplate {Index} on component node 0x{Component:X}", (IntPtr)textNode, i, (IntPtr)root);
|
|
}
|
|
}
|
|
|
|
ClearNameplateCaches();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validates that a node is a LightFinder text node owned by the container.
|
|
/// </summary>
|
|
private static bool IsValidNameplateTextNode(AtkTextNode* node, AtkResNode* nameContainer)
|
|
{
|
|
if (node == null || nameContainer == null)
|
|
return false;
|
|
|
|
var resNode = &node->AtkResNode;
|
|
return resNode->Type == NodeType.Text && resNode->ParentNode == nameContainer;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Float comparison helper for UI values.
|
|
/// </summary>
|
|
private static bool NearlyEqual(float a, float b, float epsilon = 0.001f)
|
|
=> Math.Abs(a - b) <= epsilon;
|
|
|
|
private static int GetScaledTextHeight(AtkTextNode* node)
|
|
{
|
|
if (node == null)
|
|
return 0;
|
|
|
|
var resNode = &node->AtkResNode;
|
|
var rawHeight = (int)resNode->GetHeight();
|
|
if (rawHeight <= 0 && node->LineSpacing > 0)
|
|
rawHeight = node->LineSpacing;
|
|
if (rawHeight <= 0)
|
|
rawHeight = AtkNodeHelpers.DefaultTextNodeHeight;
|
|
|
|
var scale = resNode->ScaleY;
|
|
if (scale <= 0f)
|
|
scale = 1f;
|
|
|
|
var computed = (int)Math.Round(rawHeight * scale);
|
|
return Math.Max(1, computed);
|
|
}
|
|
|
|
private static int GetScaledTextWidth(AtkTextNode* node)
|
|
{
|
|
if (node == null)
|
|
return 0;
|
|
|
|
var resNode = &node->AtkResNode;
|
|
var rawWidth = (int)resNode->GetWidth();
|
|
if (rawWidth <= 0)
|
|
rawWidth = AtkNodeHelpers.DefaultTextNodeWidth;
|
|
|
|
var scale = resNode->ScaleX;
|
|
if (scale <= 0f)
|
|
scale = 1f;
|
|
|
|
var computed = (int)Math.Round(rawWidth * scale);
|
|
return Math.Max(1, computed);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears cached text sizing and label state for nameplates.
|
|
/// </summary>
|
|
public void ClearNameplateCaches()
|
|
{
|
|
Array.Clear(_cachedNameplateTextWidths, 0, _cachedNameplateTextWidths.Length);
|
|
Array.Clear(_cachedNameplateTextHeights, 0, _cachedNameplateTextHeights.Length);
|
|
Array.Clear(_cachedNameplateContainerHeights, 0, _cachedNameplateContainerHeights.Length);
|
|
Array.Fill(_cachedNameplateTextOffsets, int.MinValue);
|
|
Array.Fill(_lastLabelByIndex, null);
|
|
}
|
|
|
|
}
|