Files
KnackAtNite 95c42af5b8 Combo highlight config, tooltips, nameplates, hotbars fixes
- 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>
2026-01-31 02:05:30 -05:00

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
}