1372 lines
44 KiB
C#
1372 lines
44 KiB
C#
using Dalamud.Bindings.ImGui;
|
||
using Dalamud.Game.Addon.Lifecycle;
|
||
using Dalamud.Game.Addon.Lifecycle.AddonArgTypes;
|
||
using Dalamud.Game.ClientState.Objects.Enums;
|
||
using Dalamud.Game.Text;
|
||
using Dalamud.Interface;
|
||
using Dalamud.Interface.Utility;
|
||
using Dalamud.Plugin;
|
||
using Dalamud.Plugin.Services;
|
||
using FFXIVClientStructs.FFXIV.Client.System.Framework;
|
||
using FFXIVClientStructs.FFXIV.Client.UI;
|
||
using FFXIVClientStructs.FFXIV.Component.GUI;
|
||
using LightlessSync.LightlessConfiguration;
|
||
using LightlessSync.Services.Mediator;
|
||
using LightlessSync.Services.Rendering;
|
||
using LightlessSync.UI;
|
||
using LightlessSync.UI.Services;
|
||
using LightlessSync.Utils;
|
||
using LightlessSync.UtilsEnum.Enum;
|
||
using Microsoft.Extensions.Hosting;
|
||
using Microsoft.Extensions.Logging;
|
||
using Pictomancy;
|
||
using System.Collections.Immutable;
|
||
using System.Globalization;
|
||
using System.Numerics;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Runtime.InteropServices;
|
||
using Task = System.Threading.Tasks.Task;
|
||
|
||
namespace LightlessSync.Services.LightFinder;
|
||
|
||
/// <summary>
|
||
/// The new lightfinder nameplate handler using ImGUI (pictomancy) for rendering the icon/labels.
|
||
/// </summary>
|
||
public unsafe class LightFinderPlateHandler : IHostedService, IMediatorSubscriber
|
||
{
|
||
private readonly ILogger<LightFinderPlateHandler> _logger;
|
||
private readonly IAddonLifecycle _addonLifecycle;
|
||
private readonly IGameGui _gameGui;
|
||
private readonly IObjectTable _objectTable;
|
||
private readonly IClientState _clientState;
|
||
private readonly LightlessConfigService _configService;
|
||
private readonly PairUiService _pairUiService;
|
||
private readonly LightlessMediator _mediator;
|
||
|
||
public LightlessMediator Mediator => _mediator;
|
||
|
||
private readonly IUiBuilder _uiBuilder;
|
||
private bool _mEnabled;
|
||
private bool _needsLabelRefresh;
|
||
private bool _drawSubscribed;
|
||
private AddonNamePlate* _mpNameplateAddon;
|
||
private readonly Lock _labelLock = new();
|
||
private readonly NameplateBuffers _buffers = new();
|
||
private int _labelRenderCount;
|
||
private LightfinderLabelRenderer _lastRenderer;
|
||
|
||
private const string _defaultLabelText = "LightFinder";
|
||
private const SeIconChar _defaultIcon = SeIconChar.Hyadelyn;
|
||
private static readonly string _defaultIconGlyph = SeIconCharExtensions.ToIconString(_defaultIcon);
|
||
private static readonly Vector2 _defaultPivot = new(0.5f, 1f);
|
||
private uint _lastNamePlateDrawFrame;
|
||
|
||
// / Overlay window flags
|
||
private const ImGuiWindowFlags _overlayFlags =
|
||
ImGuiWindowFlags.NoDecoration |
|
||
ImGuiWindowFlags.NoBackground |
|
||
ImGuiWindowFlags.NoMove |
|
||
ImGuiWindowFlags.NoSavedSettings |
|
||
ImGuiWindowFlags.NoNav |
|
||
ImGuiWindowFlags.NoInputs;
|
||
|
||
private readonly List<RectF> _uiRects = new(128);
|
||
private ImmutableHashSet<string> _activeBroadcastingCids = [];
|
||
|
||
#if DEBUG
|
||
// Debug controls
|
||
|
||
// Debug counters (read-only from UI)
|
||
#endif
|
||
|
||
private bool IsPictomancyRenderer => _configService.Current.LightfinderLabelRenderer == LightfinderLabelRenderer.Pictomancy;
|
||
|
||
public LightFinderPlateHandler(
|
||
ILogger<LightFinderPlateHandler> logger,
|
||
IAddonLifecycle addonLifecycle,
|
||
IGameGui gameGui,
|
||
IClientState clientState,
|
||
LightlessConfigService configService,
|
||
LightlessMediator mediator,
|
||
IObjectTable objectTable,
|
||
PairUiService pairUiService,
|
||
IDalamudPluginInterface pluginInterface,
|
||
PictomancyService pictomancyService)
|
||
{
|
||
_logger = logger;
|
||
_addonLifecycle = addonLifecycle;
|
||
_gameGui = gameGui;
|
||
_clientState = clientState;
|
||
_configService = configService;
|
||
_mediator = mediator;
|
||
_objectTable = objectTable;
|
||
_pairUiService = pairUiService;
|
||
_uiBuilder = pluginInterface.UiBuilder ?? throw new ArgumentNullException(nameof(pluginInterface));
|
||
_ = pictomancyService ?? throw new ArgumentNullException(nameof(pictomancyService));
|
||
_lastRenderer = _configService.Current.LightfinderLabelRenderer;
|
||
}
|
||
|
||
private void RefreshRendererState()
|
||
{
|
||
var renderer = _configService.Current.LightfinderLabelRenderer;
|
||
if (renderer == _lastRenderer)
|
||
return;
|
||
|
||
_lastRenderer = renderer;
|
||
|
||
if (renderer == LightfinderLabelRenderer.Pictomancy)
|
||
{
|
||
FlagRefresh();
|
||
}
|
||
else
|
||
{
|
||
ClearNameplateCaches();
|
||
_lastNamePlateDrawFrame = 0;
|
||
}
|
||
}
|
||
|
||
internal void Init()
|
||
{
|
||
if (!_drawSubscribed)
|
||
{
|
||
_uiBuilder.Draw += OnUiBuilderDraw;
|
||
_drawSubscribed = true;
|
||
}
|
||
|
||
EnableNameplate();
|
||
_mediator.Subscribe<PriorityFrameworkUpdateMessage>(this, OnTick);
|
||
}
|
||
|
||
internal void Uninit()
|
||
{
|
||
DisableNameplate();
|
||
if (_drawSubscribed)
|
||
{
|
||
_uiBuilder.Draw -= OnUiBuilderDraw;
|
||
_drawSubscribed = false;
|
||
}
|
||
ClearLabelBuffer();
|
||
_mediator.Unsubscribe<PriorityFrameworkUpdateMessage>(this);
|
||
_mpNameplateAddon = null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Enable nameplate handling.
|
||
/// </summary>
|
||
internal void EnableNameplate()
|
||
{
|
||
if (!_mEnabled)
|
||
{
|
||
try
|
||
{
|
||
_addonLifecycle.RegisterListener(AddonEvent.PostDraw, "NamePlate", NameplateDrawDetour);
|
||
_mEnabled = true;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
_logger.LogError(e, "Unknown error while trying to enable nameplate.");
|
||
DisableNameplate();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Disable nameplate handling.
|
||
/// </summary>
|
||
internal void DisableNameplate()
|
||
{
|
||
if (_mEnabled)
|
||
{
|
||
try
|
||
{
|
||
_addonLifecycle.UnregisterListener(NameplateDrawDetour);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
_logger.LogError(e, "Unknown error while unregistering nameplate listener.");
|
||
}
|
||
|
||
_mEnabled = false;
|
||
ClearNameplateCaches();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Draw detour for nameplate addon.
|
||
/// </summary>
|
||
private void NameplateDrawDetour(AddonEvent type, AddonArgs args)
|
||
{
|
||
RefreshRendererState();
|
||
if (!IsPictomancyRenderer)
|
||
{
|
||
ClearLabelBuffer();
|
||
_lastNamePlateDrawFrame = 0;
|
||
return;
|
||
}
|
||
|
||
// Hide our overlay when the user hides the entire game UI (ScrollLock).
|
||
if (_gameGui.GameUiHidden)
|
||
{
|
||
ClearLabelBuffer();
|
||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||
_lastNamePlateDrawFrame = 0;
|
||
return;
|
||
}
|
||
|
||
// gpose: do not draw.
|
||
if (_clientState.IsGPosing)
|
||
{
|
||
ClearLabelBuffer();
|
||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||
_lastNamePlateDrawFrame = 0;
|
||
return;
|
||
}
|
||
|
||
if (args.Addon.Address == nint.Zero)
|
||
{
|
||
if (_logger.IsEnabled(LogLevel.Warning))
|
||
_logger.LogWarning("Nameplate draw detour received a null addon address, skipping update.");
|
||
return;
|
||
}
|
||
|
||
var fw = Framework.Instance();
|
||
if (fw != null)
|
||
_lastNamePlateDrawFrame = fw->FrameCounter;
|
||
|
||
#if DEBUG
|
||
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||
#endif
|
||
|
||
var pNameplateAddon = (AddonNamePlate*)args.Addon.Address;
|
||
|
||
if (_mpNameplateAddon != pNameplateAddon)
|
||
{
|
||
ClearNameplateCaches();
|
||
_mpNameplateAddon = pNameplateAddon;
|
||
}
|
||
|
||
UpdateNameplateNodes();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Updates the nameplate nodes with LightFinder objects.
|
||
/// </summary>
|
||
private void UpdateNameplateNodes()
|
||
{
|
||
// If the user has hidden the UI, don't compute any labels.
|
||
if (_gameGui.GameUiHidden)
|
||
{
|
||
ClearLabelBuffer();
|
||
return;
|
||
}
|
||
|
||
var currentHandle = _gameGui.GetAddonByName("NamePlate");
|
||
if (currentHandle.Address == nint.Zero)
|
||
{
|
||
if (_logger.IsEnabled(LogLevel.Debug))
|
||
_logger.LogDebug("NamePlate addon unavailable during update, skipping label refresh.");
|
||
ClearLabelBuffer();
|
||
return;
|
||
}
|
||
|
||
var currentAddon = (AddonNamePlate*)currentHandle.Address;
|
||
if (_mpNameplateAddon == null || currentAddon == null || currentAddon != _mpNameplateAddon)
|
||
{
|
||
if (_mpNameplateAddon != null && _logger.IsEnabled(LogLevel.Debug))
|
||
_logger.LogDebug("Cached NamePlate addon pointer differs from current: waiting for new hook (cached {Cached}, current {Current}).", (IntPtr)_mpNameplateAddon, (IntPtr)currentAddon);
|
||
return;
|
||
}
|
||
|
||
if (!IsNamePlateAddonVisible())
|
||
{
|
||
ClearLabelBuffer();
|
||
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)
|
||
{
|
||
ClearLabelBuffer();
|
||
return;
|
||
}
|
||
|
||
var visibleUserIdsSnapshot = VisibleUserIds;
|
||
var safeCount = Math.Min(ui3DModule->NamePlateObjectInfoCount, vec.Length);
|
||
var currentConfig = _configService.Current;
|
||
var labelColor = UIColors.Get("Lightfinder");
|
||
var edgeColor = UIColors.Get("LightfinderEdge");
|
||
var scratchCount = 0;
|
||
|
||
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;
|
||
|
||
// CID gating - only show for active broadcasters
|
||
var cid = DalamudUtilService.GetHashedCIDFromPlayerPointer((nint)gameObject);
|
||
if (cid == null || !_activeBroadcastingCids.Contains(cid))
|
||
continue;
|
||
|
||
var local = _objectTable.LocalPlayer;
|
||
if (!currentConfig.LightfinderLabelShowOwn && local != null &&
|
||
objectInfo->GameObject->GetGameObjectId() == local.GameObjectId)
|
||
continue;
|
||
|
||
var hidePaired = !currentConfig.LightfinderLabelShowPaired;
|
||
var goId = gameObject->GetGameObjectId();
|
||
if (hidePaired && visibleUserIdsSnapshot.Contains(goId))
|
||
continue;
|
||
|
||
var nameplateObject = _mpNameplateAddon->NamePlateObjectArray[nameplateIndex];
|
||
var root = nameplateObject.RootComponentNode;
|
||
var nameContainer = nameplateObject.NameContainer;
|
||
var nameText = nameplateObject.NameText;
|
||
|
||
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;
|
||
}
|
||
|
||
root->Component->UldManager.UpdateDrawNodeList();
|
||
|
||
bool isNameplateVisible =
|
||
nameContainer->IsVisible() &&
|
||
nameText->AtkResNode.IsVisible();
|
||
|
||
if (!currentConfig.LightfinderLabelShowHidden && !isNameplateVisible)
|
||
continue;
|
||
|
||
// Prepare label content and scaling factors
|
||
var scaleMultiplier = Math.Clamp(currentConfig.LightfinderLabelScale, 0.5f, 2.0f);
|
||
var baseScale = currentConfig.LightfinderLabelUseIcon ? 1.0f : 0.5f;
|
||
var effectiveScale = baseScale * scaleMultiplier;
|
||
var baseFontSize = currentConfig.LightfinderLabelUseIcon ? 36f : 24f;
|
||
var targetFontSize = (int)Math.Round(baseFontSize * scaleMultiplier);
|
||
var labelContent = currentConfig.LightfinderLabelUseIcon
|
||
? NormalizeIconGlyph(currentConfig.LightfinderLabelIconGlyph)
|
||
: _defaultLabelText;
|
||
|
||
if (!currentConfig.LightfinderLabelUseIcon && (string.IsNullOrWhiteSpace(labelContent) || string.Equals(labelContent, "-", StringComparison.Ordinal)))
|
||
labelContent = _defaultLabelText;
|
||
|
||
var nodeWidth = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeWidth * effectiveScale);
|
||
var nodeHeight = (int)Math.Round(AtkNodeHelpers.DefaultTextNodeHeight * effectiveScale);
|
||
AlignmentType alignment;
|
||
|
||
var textScaleY = nameText->AtkResNode.ScaleY;
|
||
if (textScaleY <= 0f)
|
||
textScaleY = 1f;
|
||
|
||
var blockHeight = ResolveCache(
|
||
_buffers.TextHeights,
|
||
nameplateIndex,
|
||
Math.Abs((int)nameplateObject.TextH),
|
||
() => GetScaledTextHeight(nameText),
|
||
nodeHeight);
|
||
|
||
var containerHeight = ResolveCache(
|
||
_buffers.ContainerHeights,
|
||
nameplateIndex,
|
||
(int)nameContainer->Height,
|
||
() =>
|
||
{
|
||
var computed = blockHeight + (int)Math.Round(8 * textScaleY);
|
||
return computed <= blockHeight ? blockHeight + 1 : computed;
|
||
},
|
||
blockHeight + 1);
|
||
|
||
var blockTop = containerHeight - blockHeight;
|
||
if (blockTop < 0)
|
||
blockTop = 0;
|
||
var verticalPadding = (int)Math.Round(4 * effectiveScale);
|
||
|
||
var positionY = blockTop - verticalPadding;
|
||
|
||
var rawTextWidth = (int)nameplateObject.TextW;
|
||
var textWidth = ResolveCache(
|
||
_buffers.TextWidths,
|
||
nameplateIndex,
|
||
Math.Abs(rawTextWidth),
|
||
() => GetScaledTextWidth(nameText),
|
||
nodeWidth);
|
||
|
||
// Text offset caching
|
||
var textOffset = (int)Math.Round(nameText->AtkResNode.X);
|
||
var hasValidOffset = TryCacheTextOffset(nameplateIndex, rawTextWidth, textOffset);
|
||
|
||
var res = nameContainer;
|
||
|
||
// X scale
|
||
var worldScaleX = GetWorldScaleX(res);
|
||
if (worldScaleX <= 0f) worldScaleX = 1f;
|
||
|
||
// Y scale
|
||
var worldScaleY = GetWorldScaleY(res);
|
||
if (worldScaleY <= 0f) worldScaleY = 1f;
|
||
|
||
positionY += currentConfig.LightfinderLabelOffsetY;
|
||
var positionYScreen = positionY * worldScaleY;
|
||
|
||
float finalX;
|
||
if (currentConfig.LightfinderAutoAlign)
|
||
{
|
||
// auto X positioning
|
||
var measuredWidth = Math.Max(1, textWidth > 0 ? textWidth : nodeWidth);
|
||
var measuredWidthF = (float)measuredWidth;
|
||
|
||
// consider icon width
|
||
var containerWidthLocal = res->Width > 0 ? res->Width : measuredWidthF;
|
||
var containerWidthScreen = containerWidthLocal * worldScaleX;
|
||
|
||
// container bounds for positions
|
||
var containerLeft = res->ScreenX;
|
||
var containerRight = containerLeft + containerWidthScreen;
|
||
var containerCenter = containerLeft + (containerWidthScreen * 0.5f);
|
||
|
||
var iconMargin = currentConfig.LightfinderLabelUseIcon
|
||
? MathF.Min(containerWidthScreen * 0.1f, 14f * worldScaleX)
|
||
: 0f;
|
||
|
||
var offsetXScreen = currentConfig.LightfinderLabelOffsetX * worldScaleX;
|
||
|
||
// alignment based on config setting
|
||
switch (currentConfig.LabelAlignment)
|
||
{
|
||
case LabelAlignment.Left:
|
||
finalX = containerLeft + iconMargin + offsetXScreen;
|
||
alignment = AlignmentType.BottomLeft;
|
||
break;
|
||
case LabelAlignment.Right:
|
||
finalX = containerRight - iconMargin + offsetXScreen;
|
||
alignment = AlignmentType.BottomRight;
|
||
break;
|
||
default:
|
||
finalX = containerCenter + offsetXScreen;
|
||
alignment = AlignmentType.Bottom;
|
||
break;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// manual X positioning with optional cached offset
|
||
var cachedTextOffset = _buffers.TextOffsets[nameplateIndex];
|
||
var hasCachedOffset = cachedTextOffset != int.MinValue;
|
||
var baseOffsetXLocal = (!currentConfig.LightfinderLabelUseIcon && hasValidOffset && hasCachedOffset)
|
||
? cachedTextOffset
|
||
: 0;
|
||
|
||
finalX =
|
||
res->ScreenX
|
||
+ (baseOffsetXLocal * worldScaleX)
|
||
+ (58f * worldScaleX)
|
||
+ (currentConfig.LightfinderLabelOffsetX * worldScaleX);
|
||
|
||
alignment = AlignmentType.Bottom;
|
||
}
|
||
|
||
alignment = (AlignmentType)Math.Clamp((int)alignment, 0, 8);
|
||
|
||
// final position before smoothing
|
||
var finalPosition = new Vector2(finalX, res->ScreenY + positionYScreen);
|
||
var dpiScale = ImGui.GetIO().DisplayFramebufferScale.X;
|
||
var fw = Framework.Instance();
|
||
float dt = fw->RealFrameDeltaTime;
|
||
|
||
//smoothing.. snap.. smooth.. snap
|
||
finalPosition = SnapToPixels(finalPosition, dpiScale);
|
||
finalPosition = SmoothPosition(nameplateIndex, finalPosition, dt);
|
||
finalPosition = SnapToPixels(finalPosition, dpiScale);
|
||
|
||
// prepare label info for rendering
|
||
var pivot = (currentConfig.LightfinderAutoAlign || currentConfig.LightfinderLabelUseIcon)
|
||
? AlignmentToPivot(alignment)
|
||
: _defaultPivot;
|
||
var textColorPacked = PackColor(labelColor);
|
||
var edgeColorPacked = PackColor(edgeColor);
|
||
|
||
_buffers.LabelScratch[scratchCount++] = new NameplateLabelInfo(
|
||
finalPosition,
|
||
labelContent,
|
||
textColorPacked,
|
||
edgeColorPacked,
|
||
targetFontSize,
|
||
pivot,
|
||
currentConfig.LightfinderLabelUseIcon);
|
||
}
|
||
|
||
lock (_labelLock)
|
||
{
|
||
if (scratchCount == 0)
|
||
{
|
||
_labelRenderCount = 0;
|
||
}
|
||
else
|
||
{
|
||
Array.Copy(_buffers.LabelScratch, _buffers.LabelRender, scratchCount);
|
||
_labelRenderCount = scratchCount;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// On each tick, process any needed updates for the UI Builder.
|
||
/// </summary>
|
||
private void OnUiBuilderDraw()
|
||
{
|
||
RefreshRendererState();
|
||
if (!IsPictomancyRenderer)
|
||
return;
|
||
|
||
if (!_mEnabled)
|
||
return;
|
||
|
||
var fw = Framework.Instance();
|
||
if (fw == null)
|
||
return;
|
||
|
||
// If UI is hidden, do not render.
|
||
if (_gameGui.GameUiHidden)
|
||
{
|
||
ClearLabelBuffer();
|
||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||
_lastNamePlateDrawFrame = 0;
|
||
|
||
#if DEBUG
|
||
DebugLabelCountLastFrame = 0;
|
||
DebugUiRectCountLastFrame = 0;
|
||
DebugOccludedCountLastFrame = 0;
|
||
DebugLastNameplateFrame = 0;
|
||
#endif
|
||
return;
|
||
}
|
||
|
||
// Frame skip check - skip if more than 1 frame has passed since last nameplate draw.
|
||
var frame = fw->FrameCounter;
|
||
|
||
if (_lastNamePlateDrawFrame == 0 || (frame - _lastNamePlateDrawFrame) > 1)
|
||
{
|
||
ClearLabelBuffer();
|
||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||
|
||
#if DEBUG
|
||
DebugLabelCountLastFrame = 0;
|
||
DebugUiRectCountLastFrame = 0;
|
||
DebugOccludedCountLastFrame = 0;
|
||
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||
#endif
|
||
return;
|
||
}
|
||
|
||
// Gpose Check - do not render.
|
||
if (_clientState.IsGPosing)
|
||
{
|
||
ClearLabelBuffer();
|
||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||
_lastNamePlateDrawFrame = 0;
|
||
|
||
#if DEBUG
|
||
DebugLabelCountLastFrame = 0;
|
||
DebugUiRectCountLastFrame = 0;
|
||
DebugOccludedCountLastFrame = 0;
|
||
DebugLastNameplateFrame = 0;
|
||
#endif
|
||
return;
|
||
}
|
||
|
||
// If nameplate addon is not visible, skip rendering entirely.
|
||
if (!IsNamePlateAddonVisible())
|
||
{
|
||
#if DEBUG
|
||
DebugLabelCountLastFrame = 0;
|
||
DebugUiRectCountLastFrame = 0;
|
||
DebugOccludedCountLastFrame = 0;
|
||
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||
#endif
|
||
return;
|
||
}
|
||
|
||
int copyCount;
|
||
lock (_labelLock)
|
||
{
|
||
copyCount = _labelRenderCount;
|
||
if (copyCount == 0)
|
||
{
|
||
#if DEBUG
|
||
DebugLabelCountLastFrame = 0;
|
||
DebugUiRectCountLastFrame = 0;
|
||
DebugOccludedCountLastFrame = 0;
|
||
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||
#endif
|
||
return;
|
||
}
|
||
|
||
Array.Copy(_buffers.LabelRender, _buffers.LabelCopy, copyCount);
|
||
}
|
||
|
||
var uiModule = fw->GetUIModule();
|
||
if (uiModule != null)
|
||
{
|
||
var rapture = uiModule->GetRaptureAtkModule();
|
||
if (rapture != null)
|
||
RefreshUiRects(&rapture->RaptureAtkUnitManager);
|
||
else
|
||
_uiRects.Clear();
|
||
}
|
||
else
|
||
{
|
||
_uiRects.Clear();
|
||
}
|
||
|
||
// Needed for imgui overlay viewport for the multi window view.
|
||
var vp = ImGui.GetMainViewport();
|
||
var vpPos = vp.Pos;
|
||
|
||
ImGuiHelpers.ForceNextWindowMainViewport();
|
||
ImGui.SetNextWindowPos(vp.Pos);
|
||
ImGui.SetNextWindowSize(vp.Size);
|
||
|
||
ImGui.PushStyleVar(ImGuiStyleVar.WindowRounding, 0);
|
||
ImGui.PushStyleVar(ImGuiStyleVar.WindowBorderSize, 0);
|
||
|
||
ImGui.Begin("##LightFinderOverlay", _overlayFlags);
|
||
|
||
ImGui.PopStyleVar(2);
|
||
|
||
// Debug flags
|
||
bool dbgEnabled = false;
|
||
bool dbgDisableOcc = false;
|
||
bool dbgDrawUiRects = false;
|
||
bool dbgDrawLabelRects = false;
|
||
#if DEBUG
|
||
dbgEnabled = DebugEnabled;
|
||
dbgDisableOcc = DebugDisableOcclusion;
|
||
dbgDrawUiRects = DebugDrawUiRects;
|
||
dbgDrawLabelRects = DebugDrawLabelRects;
|
||
#endif
|
||
|
||
int occludedThisFrame = 0;
|
||
|
||
try
|
||
{
|
||
using var drawList = PictoService.Draw();
|
||
if (drawList == null)
|
||
return;
|
||
|
||
// Debug drawing uses the window drawlist (so it always draws in the correct viewport).
|
||
var dbgDl = ImGui.GetWindowDrawList();
|
||
var useViewportOffset = ImGui.GetIO().ConfigFlags.HasFlag(ImGuiConfigFlags.ViewportsEnable);
|
||
|
||
for (int i = 0; i < copyCount; ++i)
|
||
{
|
||
ref var info = ref _buffers.LabelCopy[i];
|
||
|
||
// final draw position with viewport offset (only when viewports are enabled)
|
||
var drawPos = info.ScreenPosition;
|
||
if (useViewportOffset)
|
||
drawPos += vpPos;
|
||
|
||
var font = default(ImFontPtr);
|
||
if (info.UseIcon)
|
||
{
|
||
var ioFonts = ImGui.GetIO().Fonts;
|
||
font = ioFonts.Fonts.Size > 1 ? new ImFontPtr(ioFonts.Fonts[1]) : ImGui.GetFont();
|
||
}
|
||
else
|
||
{
|
||
font = ImGui.GetFont();
|
||
}
|
||
|
||
if (!font.IsNull)
|
||
ImGui.PushFont(font);
|
||
|
||
// calculate size for occlusion checking
|
||
var baseSize = ImGui.CalcTextSize(info.Text);
|
||
var baseFontSize = ImGui.GetFontSize();
|
||
|
||
if (!font.IsNull)
|
||
ImGui.PopFont();
|
||
|
||
// scale size based on font size
|
||
var scale = baseFontSize > 0 ? (info.FontSize / baseFontSize) : 1f;
|
||
var size = baseSize * scale;
|
||
|
||
var topLeft = info.ScreenPosition - new Vector2(size.X * info.Pivot.X, size.Y * info.Pivot.Y);
|
||
var labelRect = new RectF(topLeft.X, topLeft.Y, topLeft.X + size.X, topLeft.Y + size.Y);
|
||
|
||
bool wouldOcclude = IsOccludedByAnyUi(labelRect);
|
||
if (wouldOcclude)
|
||
occludedThisFrame++;
|
||
|
||
// Debug: draw label rects
|
||
if (dbgEnabled && dbgDrawLabelRects)
|
||
{
|
||
var tl = new Vector2(labelRect.L, labelRect.T);
|
||
var br = new Vector2(labelRect.R, labelRect.B);
|
||
|
||
if (useViewportOffset) { tl += vpPos; br += vpPos; }
|
||
|
||
// green = visible, red = would be occluded (even if forced)
|
||
var col = wouldOcclude
|
||
? ImGui.GetColorU32(new Vector4(1f, 0f, 0f, 0.6f))
|
||
: ImGui.GetColorU32(new Vector4(0f, 1f, 0f, 0.6f));
|
||
|
||
dbgDl.AddRect(tl, br, col);
|
||
}
|
||
|
||
// occlusion check (allow debug to disable)
|
||
if (!dbgDisableOcc && wouldOcclude)
|
||
continue;
|
||
|
||
drawList.AddScreenText(drawPos, info.Text, info.TextColor, info.FontSize, info.Pivot, info.EdgeColor, font);
|
||
}
|
||
|
||
// Debug: draw UI rects if any
|
||
if (dbgEnabled && dbgDrawUiRects && _uiRects.Count > 0)
|
||
{
|
||
var useOff = useViewportOffset ? vpPos : Vector2.Zero;
|
||
var col = ImGui.GetColorU32(new Vector4(1f, 1f, 1f, 0.35f));
|
||
|
||
for (int i = 0; i < _uiRects.Count; i++)
|
||
{
|
||
var r = _uiRects[i];
|
||
dbgDl.AddRect(new Vector2(r.L, r.T) + useOff, new Vector2(r.R, r.B) + useOff, col);
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
ImGui.End();
|
||
}
|
||
|
||
#if DEBUG
|
||
DebugLabelCountLastFrame = copyCount;
|
||
DebugUiRectCountLastFrame = _uiRects.Count;
|
||
DebugOccludedCountLastFrame = occludedThisFrame;
|
||
DebugLastNameplateFrame = _lastNamePlateDrawFrame;
|
||
#endif
|
||
}
|
||
|
||
private static Vector2 AlignmentToPivot(AlignmentType alignment) => alignment switch
|
||
{
|
||
AlignmentType.BottomLeft => new Vector2(0f, 1f),
|
||
AlignmentType.BottomRight => new Vector2(1f, 1f),
|
||
AlignmentType.TopLeft => new Vector2(0f, 0f),
|
||
AlignmentType.TopRight => new Vector2(1f, 0f),
|
||
AlignmentType.Top => new Vector2(0.5f, 0f),
|
||
AlignmentType.Left => new Vector2(0f, 0.5f),
|
||
AlignmentType.Right => new Vector2(1f, 0.5f),
|
||
_ => _defaultPivot
|
||
};
|
||
|
||
private static uint PackColor(Vector4 color)
|
||
{
|
||
var r = (byte)Math.Clamp(color.X * 255f, 0f, 255f);
|
||
var g = (byte)Math.Clamp(color.Y * 255f, 0f, 255f);
|
||
var b = (byte)Math.Clamp(color.Z * 255f, 0f, 255f);
|
||
var a = (byte)Math.Clamp(color.W * 255f, 0f, 255f);
|
||
return (uint)((a << 24) | (b << 16) | (g << 8) | r);
|
||
}
|
||
|
||
private void ClearLabelBuffer()
|
||
{
|
||
lock (_labelLock)
|
||
{
|
||
_labelRenderCount = 0;
|
||
}
|
||
}
|
||
|
||
private static unsafe 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 unsafe 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>
|
||
/// Resolves a cached value for the given index.
|
||
/// </summary>
|
||
private static int ResolveCache(
|
||
int[] cache,
|
||
int index,
|
||
int rawValue,
|
||
Func<int> fallback,
|
||
int fallbackWhenZero)
|
||
{
|
||
if (rawValue > 0)
|
||
{
|
||
cache[index] = rawValue;
|
||
return rawValue;
|
||
}
|
||
|
||
var cachedValue = cache[index];
|
||
if (cachedValue > 0)
|
||
return cachedValue;
|
||
|
||
var computed = fallback();
|
||
if (computed <= 0)
|
||
computed = fallbackWhenZero;
|
||
|
||
cache[index] = computed;
|
||
return computed;
|
||
}
|
||
|
||
private bool TryCacheTextOffset(int nameplateIndex, int measuredTextWidth, int textOffset)
|
||
{
|
||
if (Math.Abs(measuredTextWidth) > 0 || textOffset != 0)
|
||
{
|
||
_buffers.TextOffsets[nameplateIndex] = textOffset;
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Snapping a position to pixel grid based on DPI scale.
|
||
/// </summary>
|
||
private static Vector2 SnapToPixels(Vector2 p, float dpiScale)
|
||
{
|
||
// snap to pixel grid
|
||
var x = MathF.Round(p.X * dpiScale) / dpiScale;
|
||
var y = MathF.Round(p.Y * dpiScale) / dpiScale;
|
||
return new Vector2(x, y);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Smooths the position using exponential smoothing.
|
||
/// </summary>
|
||
private Vector2 SmoothPosition(int idx, Vector2 target, float dt, float responsiveness = 24f)
|
||
{
|
||
// exponential smoothing
|
||
if (!_buffers.HasSmoothed[idx])
|
||
{
|
||
_buffers.HasSmoothed[idx] = true;
|
||
_buffers.SmoothedPos[idx] = target;
|
||
return target;
|
||
}
|
||
|
||
// get current smoothed position
|
||
var cur = _buffers.SmoothedPos[idx];
|
||
|
||
// compute smoothing factor
|
||
var a = 1f - MathF.Exp(-responsiveness * dt);
|
||
|
||
// snap if close enough
|
||
if (Vector2.DistanceSquared(cur, target) < 0.25f)
|
||
return cur;
|
||
|
||
// lerp towards target
|
||
cur = Vector2.Lerp(cur, target, a);
|
||
_buffers.SmoothedPos[idx] = cur;
|
||
return cur;
|
||
}
|
||
|
||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||
private static bool IsFinite(float f) => !(float.IsNaN(f) || float.IsInfinity(f));
|
||
|
||
private static bool TryGetAddonRect(AtkUnitBase* addon, Vector2 screen, out RectF rect)
|
||
{
|
||
rect = default;
|
||
if (addon == null)
|
||
return false;
|
||
|
||
// Addon must be visible
|
||
if (!addon->IsVisible)
|
||
return false;
|
||
|
||
// Root must be visible
|
||
var root = addon->RootNode;
|
||
if (root == null || !root->IsVisible())
|
||
return false;
|
||
|
||
// Must have multiple nodes to be useful
|
||
var nodeCount = addon->UldManager.NodeListCount;
|
||
var nodeList = addon->UldManager.NodeList;
|
||
if (nodeCount <= 1 || nodeList == null)
|
||
return false;
|
||
|
||
float rsx = GetWorldScaleX(root);
|
||
float rsy = GetWorldScaleY(root);
|
||
if (!IsFinite(rsx) || rsx <= 0f) rsx = 1f;
|
||
if (!IsFinite(rsy) || rsy <= 0f) rsy = 1f;
|
||
|
||
// clamp insane root scales (rare but prevents explosions)
|
||
rsx = MathF.Min(rsx, 6f);
|
||
rsy = MathF.Min(rsy, 6f);
|
||
|
||
float rw = root->Width * rsx;
|
||
float rh = root->Height * rsy;
|
||
if (!IsFinite(rw) || !IsFinite(rh) || rw <= 2f || rh <= 2f)
|
||
return false;
|
||
|
||
float rl = root->ScreenX;
|
||
float rt = root->ScreenY;
|
||
if (!IsFinite(rl) || !IsFinite(rt))
|
||
return false;
|
||
|
||
float rr = rl + rw;
|
||
float rb = rt + rh;
|
||
|
||
// If root is basically fullscreen, it<69>s not a useful occluder for our purpose.
|
||
if (rw >= screen.X * 0.98f && rh >= screen.Y * 0.98f)
|
||
return false;
|
||
|
||
// Clip root to screen so it stays sane
|
||
float rootL = MathF.Max(0f, rl);
|
||
float rootT = MathF.Max(0f, rt);
|
||
float rootR = MathF.Min(screen.X, rr);
|
||
float rootB = MathF.Min(screen.Y, rb);
|
||
if (rootR <= rootL || rootB <= rootT)
|
||
return false;
|
||
|
||
// Root dimensions
|
||
var rootW = rootR - rootL;
|
||
var rootH = rootB - rootT;
|
||
|
||
// Find union of all probably-drawable nodes intersecting root
|
||
bool any = false;
|
||
float l = float.MaxValue, t = float.MaxValue, r = float.MinValue, b = float.MinValue;
|
||
|
||
// Allow a small bleed outside root; some addons draw small bits outside their root container.
|
||
const float rootPad = 24f;
|
||
float padL = rootL - rootPad;
|
||
float padT = rootT - rootPad;
|
||
float padR = rootR + rootPad;
|
||
float padB = rootB + rootPad;
|
||
|
||
for (int i = 1; i < nodeCount; i++)
|
||
{
|
||
var n = nodeList[i];
|
||
if (!IsProbablyDrawableNode(n))
|
||
continue;
|
||
|
||
float w = n->Width;
|
||
float h = n->Height;
|
||
if (!IsFinite(w) || !IsFinite(h) || w <= 1f || h <= 1f)
|
||
continue;
|
||
|
||
float sx = GetWorldScaleX(n);
|
||
float sy = GetWorldScaleY(n);
|
||
|
||
if (!IsFinite(sx) || sx <= 0f) sx = 1f;
|
||
if (!IsFinite(sy) || sy <= 0f) sy = 1f;
|
||
|
||
sx = MathF.Min(sx, 6f);
|
||
sy = MathF.Min(sy, 6f);
|
||
|
||
w *= sx;
|
||
h *= sy;
|
||
|
||
if (!IsFinite(w) || !IsFinite(h) || w < 2f || h < 2f)
|
||
continue;
|
||
|
||
float nl = n->ScreenX;
|
||
float nt = n->ScreenY;
|
||
if (!IsFinite(nl) || !IsFinite(nt))
|
||
continue;
|
||
|
||
float nr = nl + w;
|
||
float nb = nt + h;
|
||
|
||
// Must intersect root (with padding). This is the big mitigation.
|
||
if (nr <= padL || nb <= padT || nl >= padR || nt >= padB)
|
||
continue;
|
||
|
||
// Reject nodes that are wildly larger than the root (common on targeting).
|
||
if (w > rootW * 2.0f || h > rootH * 2.0f)
|
||
continue;
|
||
|
||
// Clip node to root and then to screen (prevents offscreen junk stretching union)
|
||
float cl = MathF.Max(rootL, nl);
|
||
float ct = MathF.Max(rootT, nt);
|
||
float cr = MathF.Min(rootR, nr);
|
||
float cb = MathF.Min(rootB, nb);
|
||
|
||
cl = MathF.Max(0f, cl);
|
||
ct = MathF.Max(0f, ct);
|
||
cr = MathF.Min(screen.X, cr);
|
||
cb = MathF.Min(screen.Y, cb);
|
||
|
||
if (cr <= cl || cb <= ct)
|
||
continue;
|
||
|
||
any = true;
|
||
if (cl < l) l = cl;
|
||
if (ct < t) t = ct;
|
||
if (cr > r) r = cr;
|
||
if (cb > b) b = cb;
|
||
}
|
||
|
||
// If nothing usable, fallback to root rect (still a sane occluder)
|
||
if (!any)
|
||
{
|
||
rect = new RectF(rootL, rootT, rootR, rootB);
|
||
return true;
|
||
}
|
||
|
||
// Validate final union rect
|
||
var uw = r - l;
|
||
var uh = b - t;
|
||
if (uw < 4f || uh < 4f)
|
||
{
|
||
rect = new RectF(rootL, rootT, rootR, rootB);
|
||
return true;
|
||
}
|
||
|
||
// If union is excessively larger than root, fallback to root rect
|
||
if (uw > rootW * 1.35f || uh > rootH * 1.35f)
|
||
{
|
||
rect = new RectF(rootL, rootT, rootR, rootB);
|
||
return true;
|
||
}
|
||
|
||
rect = new RectF(l, t, r, b);
|
||
return true;
|
||
}
|
||
|
||
private static bool IsProbablyDrawableNode(AtkResNode* n)
|
||
{
|
||
if (n == null || !n->IsVisible())
|
||
return false;
|
||
|
||
// Check alpha
|
||
if (n->Color.A == 16)
|
||
return false;
|
||
|
||
// Check node type
|
||
return n->Type switch
|
||
{
|
||
NodeType.Text => true,
|
||
NodeType.Image => true,
|
||
NodeType.NineGrid => true,
|
||
NodeType.Counter => true,
|
||
NodeType.Component => true,
|
||
_ => false,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Refreshes the cached UI rects for occlusion checking.
|
||
/// </summary>
|
||
private void RefreshUiRects(RaptureAtkUnitManager* unitMgr)
|
||
{
|
||
_uiRects.Clear();
|
||
if (unitMgr == null)
|
||
return;
|
||
|
||
var screen = ImGui.GetIO().DisplaySize;
|
||
|
||
ref var list = ref unitMgr->AllLoadedUnitsList;
|
||
var count = (int)list.Count;
|
||
|
||
for (int i = 0; i < count; i++)
|
||
{
|
||
var addon = list.Entries[i].Value;
|
||
if (addon == null)
|
||
continue;
|
||
|
||
if (_mpNameplateAddon != null && addon == (AtkUnitBase*)_mpNameplateAddon)
|
||
continue;
|
||
|
||
if (TryGetAddonRect(addon, screen, out var r))
|
||
_uiRects.Add(r);
|
||
}
|
||
|
||
#if DEBUG
|
||
DebugUiRectCountLastFrame = _uiRects.Count;
|
||
#endif
|
||
}
|
||
|
||
/// <summary>
|
||
/// Is the given label rect occluded by any UI rects?
|
||
/// </summary>
|
||
private bool IsOccludedByAnyUi(RectF labelRect)
|
||
{
|
||
for (int i = 0; i < _uiRects.Count; i++)
|
||
{
|
||
if (_uiRects[i].Intersects(labelRect))
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets the world scale X of the given node.
|
||
/// </summary>
|
||
private static float GetWorldScaleX(AtkResNode* n)
|
||
{
|
||
var t = n->Transform;
|
||
return MathF.Sqrt(t.M11 * t.M11 + t.M12 * t.M12);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gets the world scale Y of the given node.
|
||
/// </summary>
|
||
private static float GetWorldScaleY(AtkResNode* n)
|
||
{
|
||
var t = n->Transform;
|
||
return MathF.Sqrt(t.M21 * t.M21 + t.M22 * t.M22);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Normalize an icon glyph input into a valid string.
|
||
/// </summary>
|
||
internal static string NormalizeIconGlyph(string? rawInput)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(rawInput))
|
||
return _defaultIconGlyph;
|
||
|
||
var trimmed = rawInput.Trim();
|
||
|
||
if (Enum.TryParse<SeIconChar>(trimmed, true, out var iconEnum))
|
||
return SeIconCharExtensions.ToIconString(iconEnum);
|
||
|
||
var hexCandidate = trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase)
|
||
? trimmed[2..]
|
||
: trimmed;
|
||
|
||
if (ushort.TryParse(hexCandidate, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var hexValue))
|
||
return char.ConvertFromUtf32(hexValue);
|
||
|
||
var enumerator = trimmed.EnumerateRunes();
|
||
if (enumerator.MoveNext())
|
||
return enumerator.Current.ToString();
|
||
|
||
return _defaultIconGlyph;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Is the nameplate addon visible?
|
||
/// </summary>
|
||
private bool IsNamePlateAddonVisible()
|
||
{
|
||
if (_mpNameplateAddon == null)
|
||
return false;
|
||
|
||
var root = _mpNameplateAddon->AtkUnitBase.RootNode;
|
||
return root != null && root->IsVisible();
|
||
}
|
||
|
||
private readonly struct NameplateLabelInfo
|
||
{
|
||
public NameplateLabelInfo(
|
||
Vector2 screenPosition,
|
||
string text,
|
||
uint textColor,
|
||
uint edgeColor,
|
||
float fontSize,
|
||
Vector2 pivot,
|
||
bool useIcon)
|
||
{
|
||
ScreenPosition = screenPosition;
|
||
Text = text;
|
||
TextColor = textColor;
|
||
EdgeColor = edgeColor;
|
||
FontSize = fontSize;
|
||
Pivot = pivot;
|
||
UseIcon = useIcon;
|
||
}
|
||
|
||
public Vector2 ScreenPosition { get; }
|
||
public string Text { get; }
|
||
public uint TextColor { get; }
|
||
public uint EdgeColor { get; }
|
||
public float FontSize { get; }
|
||
public Vector2 Pivot { get; }
|
||
public bool UseIcon { get; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// Visible paired user IDs snapshot.
|
||
/// </summary>
|
||
private HashSet<ulong> VisibleUserIds
|
||
=> [.. _pairUiService.GetSnapshot().PairsByUid.Values
|
||
.Where(u => u.IsVisible && u.PlayerCharacterId != uint.MaxValue)
|
||
.Select(u => (ulong)u.PlayerCharacterId)];
|
||
|
||
public int DebugLabelCountLastFrame { get; set; }
|
||
public int DebugUiRectCountLastFrame { get; set; }
|
||
public int DebugOccludedCountLastFrame { get; set; }
|
||
public uint DebugLastNameplateFrame { get; set; }
|
||
public bool DebugDrawUiRects { get; set; }
|
||
public bool DebugDrawLabelRects { get; set; } = true;
|
||
public bool DebugDisableOcclusion { get; set; }
|
||
public bool DebugEnabled { get; set; }
|
||
|
||
public void FlagRefresh()
|
||
{
|
||
_needsLabelRefresh = true;
|
||
}
|
||
|
||
public void OnTick(PriorityFrameworkUpdateMessage _)
|
||
{
|
||
if (!IsPictomancyRenderer)
|
||
{
|
||
_needsLabelRefresh = false;
|
||
return;
|
||
}
|
||
|
||
if (_needsLabelRefresh)
|
||
{
|
||
UpdateNameplateNodes();
|
||
_needsLabelRefresh = false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Update the active broadcasting CIDs.
|
||
/// </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: {Cids}", string.Join(',', _activeBroadcastingCids));
|
||
FlagRefresh();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Clears all nameplate related caches.
|
||
/// </summary>
|
||
public void ClearNameplateCaches()
|
||
{
|
||
_buffers.Clear();
|
||
ClearLabelBuffer();
|
||
|
||
Array.Clear(_buffers.HasSmoothed, 0, _buffers.HasSmoothed.Length);
|
||
Array.Clear(_buffers.SmoothedPos, 0, _buffers.SmoothedPos.Length);
|
||
}
|
||
|
||
private sealed class NameplateBuffers
|
||
{
|
||
public NameplateBuffers()
|
||
{
|
||
TextOffsets = new int[AddonNamePlate.NumNamePlateObjects];
|
||
Array.Fill(TextOffsets, int.MinValue);
|
||
}
|
||
|
||
public int[] TextWidths { get; } = new int[AddonNamePlate.NumNamePlateObjects];
|
||
public int[] TextHeights { get; } = new int[AddonNamePlate.NumNamePlateObjects];
|
||
public int[] ContainerHeights { get; } = new int[AddonNamePlate.NumNamePlateObjects];
|
||
public int[] TextOffsets { get; }
|
||
public NameplateLabelInfo[] LabelScratch { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
|
||
public NameplateLabelInfo[] LabelRender { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
|
||
public NameplateLabelInfo[] LabelCopy { get; } = new NameplateLabelInfo[AddonNamePlate.NumNamePlateObjects];
|
||
|
||
public Vector2[] SmoothedPos = new Vector2[AddonNamePlate.NumNamePlateObjects];
|
||
public bool[] HasSmoothed = new bool[AddonNamePlate.NumNamePlateObjects];
|
||
|
||
public void Clear()
|
||
{
|
||
Array.Clear(TextWidths, 0, TextWidths.Length);
|
||
Array.Clear(TextHeights, 0, TextHeights.Length);
|
||
Array.Clear(ContainerHeights, 0, ContainerHeights.Length);
|
||
Array.Fill(TextOffsets, int.MinValue);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Starts the LightFinder Plate Handler.
|
||
/// </summary>
|
||
public Task StartAsync(CancellationToken cancellationToken)
|
||
{
|
||
Init();
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stops the LightFinder Plate Handler.
|
||
/// </summary>
|
||
public Task StopAsync(CancellationToken cancellationToken)
|
||
{
|
||
Uninit();
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Rectangle with float coordinates for intersection testing.
|
||
/// </summary>
|
||
[StructLayout(LayoutKind.Auto)]
|
||
private readonly struct RectF
|
||
{
|
||
public readonly float L, T, R, B;
|
||
public RectF(float l, float t, float r, float b) { L = l; T = t; R = r; B = b; }
|
||
|
||
public bool Intersects(in RectF o) =>
|
||
!(R <= o.L || o.R <= L || B <= o.T || o.B <= T);
|
||
}
|
||
}
|