95c42af5b8
- Combo highlight: configurable color, glow, line style (solid/dashed/dotted), thickness - Tooltips: font selection, scaling slider, improved wrap/cramping handling - Nameplates: custom quest icons with config, position smoothing fix for jitter - Hotbars: hide keybinds on empty slots, combo highlight within icon bounds - HudHelper: restore default nameplates on plugin disable Co-authored-by: Cursor <cursoragent@cursor.com>
400 lines
16 KiB
C#
400 lines
16 KiB
C#
using Dalamud.Game.ClientState.Objects.Enums;
|
|
using Dalamud.Game.ClientState.Objects.Types;
|
|
using Dalamud.Memory;
|
|
using HSUI.Config;
|
|
using HSUI.Helpers;
|
|
using HSUI.Interface.GeneralElements;
|
|
using FFXIVClientStructs.FFXIV.Client.Game.Control;
|
|
using FFXIVClientStructs.FFXIV.Client.Graphics.Scene;
|
|
using FFXIVClientStructs.FFXIV.Client.UI;
|
|
using FFXIVClientStructs.FFXIV.Component.GUI;
|
|
using Dalamud.Bindings.ImGui;
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using static FFXIVClientStructs.FFXIV.Client.UI.AddonNamePlate;
|
|
using static FFXIVClientStructs.FFXIV.Client.UI.RaptureAtkModule;
|
|
using static FFXIVClientStructs.FFXIV.Client.UI.UI3DModule;
|
|
using StructsFramework = FFXIVClientStructs.FFXIV.Client.System.Framework.Framework;
|
|
using StructsGameObject = FFXIVClientStructs.FFXIV.Client.Game.Object.GameObject;
|
|
|
|
namespace HSUI.Interface.Nameplates
|
|
{
|
|
internal class NameplatesManager : IDisposable
|
|
{
|
|
#region Singleton
|
|
public static NameplatesManager Instance { get; private set; } = null!;
|
|
private NameplatesGeneralConfig _config = null!;
|
|
|
|
private NameplatesManager()
|
|
{
|
|
Plugin.ClientState.TerritoryChanged -= ClientStateOnTerritoryChangedEvent;
|
|
ConfigurationManager.Instance.ResetEvent += OnConfigReset;
|
|
|
|
OnConfigReset(ConfigurationManager.Instance);
|
|
}
|
|
|
|
public static void Initialize()
|
|
{
|
|
Instance = new NameplatesManager();
|
|
}
|
|
|
|
~NameplatesManager()
|
|
{
|
|
Dispose(false);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
Dispose(true);
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
protected void Dispose(bool disposing)
|
|
{
|
|
if (!disposing)
|
|
{
|
|
return;
|
|
}
|
|
|
|
Plugin.ClientState.TerritoryChanged -= ClientStateOnTerritoryChangedEvent;
|
|
|
|
Instance = null!;
|
|
}
|
|
|
|
private void OnConfigReset(ConfigurationManager sender)
|
|
{
|
|
_config = sender.GetConfigObject<NameplatesGeneralConfig>();
|
|
}
|
|
#endregion Singleton
|
|
|
|
private const int NameplateCount = 50;
|
|
private const int NameplateDataArrayIndex = 4; // TODO: Rework to use NamePlateStringArray if it exists or use AtkStage.Instance()->GetStringArrayData(StringArrayType.NamePlate)
|
|
private Vector2 _averageNameplateSize = new Vector2(250, 150);
|
|
private List<NameplateData> _data = new List<NameplateData>();
|
|
public IReadOnlyCollection<NameplateData> Data => _data.AsReadOnly();
|
|
|
|
private NameplatesCache _cache = new NameplatesCache(50);
|
|
private Dictionary<uint, Vector2> _smoothedPositions = new(50);
|
|
private const float PositionSmoothFactor = 0.3f; // Lerp factor: lower = smoother, higher = more responsive
|
|
private const float PlayerPositionSmoothFactor = 0.15f; // Stronger smoothing for player (camera-follow causes more jitter)
|
|
|
|
private void ClientStateOnTerritoryChangedEvent(ushort territoryId)
|
|
{
|
|
_cache.Clear();
|
|
_smoothedPositions.Clear();
|
|
}
|
|
|
|
public unsafe void Update()
|
|
{
|
|
if (!_config.Enabled) { return; }
|
|
|
|
UIModule* uiModule = StructsFramework.Instance()->GetUIModule();
|
|
if (uiModule == null) { return; }
|
|
|
|
UI3DModule* ui3DModule = uiModule->GetUI3DModule();
|
|
if (ui3DModule == null) { return; }
|
|
|
|
AddonNamePlate* addon = (AddonNamePlate*)Plugin.GameGui.GetAddonByName("NamePlate", 1).Address;
|
|
if (addon == null) { return; }
|
|
|
|
RaptureAtkModule* atkModule = uiModule->GetRaptureAtkModule();
|
|
if (atkModule == null || atkModule->AtkModule.AtkArrayDataHolder.StringArrayCount <= NameplateDataArrayIndex) { return; }
|
|
|
|
StringArrayData* stringArray = atkModule->AtkModule.AtkArrayDataHolder.StringArrays[NameplateDataArrayIndex];
|
|
Span<NamePlateInfo> infoArray = atkModule->NamePlateInfoEntries;
|
|
Camera camera = Control.Instance()->CameraManager.Camera->CameraBase.SceneCamera;
|
|
|
|
IGameObject? target = Plugin.TargetManager.Target;
|
|
bool foundTarget = false;
|
|
NameplateData? targetData = null;
|
|
|
|
_data = new List<NameplateData>();
|
|
int activeCount = ui3DModule->NamePlateObjectInfoCount;
|
|
var nextSmoothed = new Dictionary<uint, Vector2>(Math.Min(activeCount + 4, 54));
|
|
|
|
for (int i = 0; i < activeCount; i++)
|
|
{
|
|
try
|
|
{
|
|
ObjectInfo* objectInfo = ui3DModule->NamePlateObjectInfoPointers[i];
|
|
if (objectInfo == null || objectInfo->NamePlateIndex >= NameplateCount) { continue; }
|
|
|
|
// actor
|
|
StructsGameObject* obj = objectInfo->GameObject;
|
|
if (obj == null) { continue; }
|
|
|
|
bool isTarget = false;
|
|
IGameObject? gameObject = Plugin.ObjectTable.CreateObjectReference(new IntPtr(obj));
|
|
if (target != null && new IntPtr(obj) == target.Address)
|
|
{
|
|
isTarget = true;
|
|
foundTarget = true;
|
|
}
|
|
|
|
// ui nameplate (may be stale when addon is hidden)
|
|
NamePlateObject nameplateObject = addon->NamePlateObjectArray[objectInfo->NamePlateIndex];
|
|
|
|
Vector3 worldPos = new Vector3(obj->Position.X, obj->Position.Y + obj->Height * 2.2f, obj->Position.Z);
|
|
|
|
// Screen position: use addon when available (game's logic, stable). WorldToScreen when addon hidden/stale.
|
|
var hudConfig = ConfigurationManager.Instance?.GetConfigObject<HUDOptionsConfig>();
|
|
bool hidingAddon = _config.Enabled && (hudConfig?.HideDefaultHudWhenReplaced ?? true);
|
|
bool isPlayer = Plugin.ObjectTable.LocalPlayer != null && new IntPtr(obj) == Plugin.ObjectTable.LocalPlayer.Address;
|
|
|
|
Vector2 screenPos;
|
|
bool addonNodeValid = nameplateObject.RootComponentNode != null;
|
|
// Use addon position when visible, or when hiding (game may still update nodes). Fall back to WorldToScreen if stale.
|
|
bool useAddonPos = addonNodeValid && (addon->IsVisible || hidingAddon);
|
|
if (useAddonPos)
|
|
{
|
|
float nx = nameplateObject.RootComponentNode->AtkResNode.X;
|
|
float ny = nameplateObject.RootComponentNode->AtkResNode.Y;
|
|
float nw = nameplateObject.RootComponentNode->AtkResNode.Width;
|
|
float nh = nameplateObject.RootComponentNode->AtkResNode.Height;
|
|
screenPos = new Vector2(nx + nw / 2f, ny + nh);
|
|
// Sanity: when addon hidden, if pos looks stale (off-screen), fall back to WorldToScreen
|
|
if (hidingAddon && (screenPos.X < -500 || screenPos.X > 3000 || screenPos.Y < -500 || screenPos.Y > 3000))
|
|
{
|
|
Plugin.GameGui.WorldToScreen(worldPos, out screenPos);
|
|
useAddonPos = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Plugin.GameGui.WorldToScreen(worldPos, out screenPos);
|
|
}
|
|
screenPos = ClampScreenPosition(screenPos);
|
|
// Temporal smoothing for WorldToScreen-sourced positions (addon pos is usually stable)
|
|
uint objId = obj->GetGameObjectId().ObjectId;
|
|
if (!useAddonPos && _smoothedPositions.TryGetValue(objId, out Vector2 prev))
|
|
{
|
|
float factor = isPlayer ? PlayerPositionSmoothFactor : PositionSmoothFactor;
|
|
screenPos = Vector2.Lerp(prev, screenPos, factor);
|
|
}
|
|
nextSmoothed[objId] = screenPos;
|
|
|
|
// distance
|
|
float distance = Vector3.Distance(camera.Object.Position, worldPos);
|
|
|
|
// name
|
|
NamePlateInfo info = infoArray[objectInfo->NamePlateIndex];
|
|
string name = info.Name.ToString();
|
|
|
|
// title
|
|
string title = info.Title.ToString();
|
|
bool isTitlePrefix = info.IsPrefixTitle;
|
|
|
|
// Get the title from Honorific, if it exists
|
|
TitleData? customTitleData = HonorificHelper.Instance?.GetTitle(gameObject);
|
|
if (customTitleData != null)
|
|
{
|
|
title = customTitleData.Title;
|
|
isTitlePrefix = customTitleData.IsPrefix;
|
|
}
|
|
|
|
// Quest/state icon: use GameObject.NamePlateIconId (game logic, works when addon hidden).
|
|
// Fallback to addon's NameIcon texture if GameObject has none (addon must be visible).
|
|
int iconId = (int)obj->NamePlateIconId;
|
|
if (iconId == 0)
|
|
{
|
|
try
|
|
{
|
|
AtkUldAsset* textureInfo = nameplateObject.NameIcon->PartsList->Parts[nameplateObject.NameIcon->PartId].UldAsset;
|
|
if (textureInfo != null && textureInfo->AtkTexture.Resource != null)
|
|
iconId = (int)textureInfo->AtkTexture.Resource->IconId;
|
|
}
|
|
catch { /* addon node may be null/stale when hidden */ }
|
|
}
|
|
|
|
// order
|
|
int arrayIndex = 200 + (activeCount - nameplateObject.Priority - 1);
|
|
string order = "";
|
|
try
|
|
{
|
|
if (stringArray->AtkArrayData.Size > arrayIndex && stringArray->StringArray[arrayIndex] != null)
|
|
{
|
|
order = MemoryHelper.ReadSeStringNullTerminated(new IntPtr(stringArray->StringArray[arrayIndex])).ToString();
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
NameplateData data = new NameplateData(
|
|
gameObject,
|
|
name,
|
|
title,
|
|
isTitlePrefix,
|
|
iconId,
|
|
order,
|
|
(ObjectKind)obj->ObjectKind,
|
|
obj->SubKind,
|
|
screenPos,
|
|
worldPos,
|
|
distance
|
|
);
|
|
|
|
if (isTarget)
|
|
{
|
|
targetData = data;
|
|
}
|
|
else
|
|
{
|
|
_data.Add(data);
|
|
}
|
|
|
|
_cache.Add(obj->GetGameObjectId().ObjectId, data);
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
_smoothedPositions = nextSmoothed;
|
|
_data.Reverse();
|
|
|
|
// add target nameplate last
|
|
if (foundTarget && targetData.HasValue)
|
|
{
|
|
_data.Add(targetData.Value);
|
|
}
|
|
// create nameplate for target?
|
|
else if (_config.AlwaysShowTargetNameplate && target != null && !foundTarget)
|
|
{
|
|
StructsGameObject* obj = (StructsGameObject*)target.Address;
|
|
NameplateData? cachedData = _cache[(uint)target.GameObjectId];
|
|
|
|
Vector3 worldPos = new Vector3(target.Position.X, target.Position.Y + obj->Height * 2.2f, target.Position.Z);
|
|
float distance = Vector3.Distance(camera.Object.Position, worldPos);
|
|
|
|
Plugin.GameGui.WorldToScreen(worldPos, out Vector2 screenPos);
|
|
screenPos = ClampScreenPosition(screenPos);
|
|
|
|
targetData = new NameplateData(
|
|
target,
|
|
target.Name.ToString(),
|
|
cachedData?.Title ?? "",
|
|
cachedData?.IsTitlePrefix ?? true,
|
|
cachedData?.NamePlateIconId ?? 0,
|
|
cachedData?.Order ?? "",
|
|
target.ObjectKind,
|
|
target.SubKind,
|
|
screenPos,
|
|
worldPos,
|
|
distance,
|
|
true
|
|
);
|
|
|
|
_data.Add(targetData.Value);
|
|
}
|
|
}
|
|
|
|
private Vector2 ClampScreenPosition(Vector2 pos)
|
|
{
|
|
if (!_config.ClampToScreen) { return pos; }
|
|
|
|
Vector2 screenSize = ImGui.GetMainViewport().Size;
|
|
Vector2 nameplateSize = _averageNameplateSize / 2f;
|
|
float margin = 20;
|
|
|
|
if (pos.X + nameplateSize.X > screenSize.X)
|
|
pos.X = screenSize.X - nameplateSize.X - margin;
|
|
else if (pos.X - nameplateSize.X < 0)
|
|
pos.X = nameplateSize.X + margin;
|
|
|
|
if (pos.Y + nameplateSize.Y > screenSize.Y)
|
|
pos.Y = screenSize.Y - nameplateSize.Y - margin;
|
|
else if (pos.Y - nameplateSize.Y < 0)
|
|
pos.Y = nameplateSize.Y + margin;
|
|
|
|
return pos;
|
|
}
|
|
}
|
|
|
|
#region utils
|
|
public class NameplatesCache
|
|
{
|
|
|
|
private int _limit;
|
|
private Dictionary<uint, NameplateData> _dict;
|
|
private Queue<uint> _queue;
|
|
|
|
public NameplatesCache(int limit)
|
|
{
|
|
_limit = limit;
|
|
_dict = new Dictionary<uint, NameplateData>(limit);
|
|
_queue = new Queue<uint>(limit);
|
|
}
|
|
|
|
public void Add(uint key, NameplateData data)
|
|
{
|
|
if (key == 0 || key == 0xE0000000) { return; }
|
|
|
|
if (_dict.Count == _limit)
|
|
{
|
|
uint oldestKey = _queue.Dequeue();
|
|
_dict.Remove(oldestKey);
|
|
}
|
|
|
|
if (_dict.ContainsKey(key))
|
|
{
|
|
_dict[key] = data;
|
|
}
|
|
else
|
|
{
|
|
_dict.Add(key, data);
|
|
_queue.Enqueue(key);
|
|
}
|
|
}
|
|
|
|
public void Clear()
|
|
{
|
|
_dict.Clear();
|
|
_queue.Clear();
|
|
}
|
|
|
|
public NameplateData? this[uint key]
|
|
{
|
|
get
|
|
{
|
|
if (_dict.TryGetValue(key, out NameplateData data))
|
|
{
|
|
return data;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct NameplateData
|
|
{
|
|
public IGameObject? GameObject;
|
|
public string Name;
|
|
public string Title;
|
|
public bool IsTitlePrefix;
|
|
public int NamePlateIconId;
|
|
public string Order;
|
|
public ObjectKind Kind;
|
|
public byte SubKind;
|
|
public Vector2 ScreenPosition;
|
|
public Vector3 WorldPosition;
|
|
public float Distance;
|
|
public bool IgnoreOcclusion;
|
|
|
|
public NameplateData(IGameObject? gameObject, string name, string title, bool isTitlePrefix, int namePlateIconId, string order, ObjectKind kind, byte subKind, Vector2 screenPosition, Vector3 worldPosition, float distance, bool ignoreOcclusion = false)
|
|
{
|
|
GameObject = gameObject;
|
|
Name = name;
|
|
Title = title;
|
|
IsTitlePrefix = isTitlePrefix;
|
|
NamePlateIconId = namePlateIconId;
|
|
Order = order;
|
|
Kind = kind;
|
|
SubKind = subKind;
|
|
ScreenPosition = screenPosition;
|
|
WorldPosition = worldPosition;
|
|
Distance = distance;
|
|
IgnoreOcclusion = ignoreOcclusion;
|
|
}
|
|
}
|
|
#endregion
|
|
}
|