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>
This commit is contained in:
2026-01-31 02:05:30 -05:00
parent 47018c75a2
commit 95c42af5b8
8 changed files with 218 additions and 40 deletions
+4 -5
View File
@@ -177,10 +177,9 @@ namespace HSUI.Interface.GeneralElements
drawList.AddRect(pos, pos + size, 0xFF88CCFF, 0, ImDrawFlags.None, 2);
else if (isComboNext)
{
const uint gold = 0xFFFFD700;
for (int g = 5; g >= 1; g--)
drawList.AddRect(pos - new Vector2(g, g), pos + size + new Vector2(g, g), (uint)((byte)(70 * (6 - g)) << 24 | (gold & 0xFFFFFF)), 0, ImDrawFlags.None, 2f);
drawList.AddRect(pos, pos + size, gold, 0, ImDrawFlags.None, 4f);
var ch = Config.ComboHighlightConfig;
DrawHelper.DrawComboHighlightRect(drawList, pos, pos + size,
ch.Color.Base, ch.Thickness, ch.ShowGlow, ch.LineStyle);
}
if (showCd && slot.CooldownPercent > 0 && _pendingSlotIconIndex != i)
@@ -198,7 +197,7 @@ namespace HSUI.Interface.GeneralElements
}
}
if (Config.ShowSlotNumbers)
if (Config.ShowSlotNumbers && !slot.IsEmpty)
{
string label = !string.IsNullOrWhiteSpace(slot.KeybindHint)
? slot.KeybindHint
@@ -66,6 +66,9 @@ namespace HSUI.Interface.GeneralElements
[Order(29)]
public bool ShowComboHighlight = true;
[NestedConfig("Combo Highlight", 31)]
public ComboHighlightConfig ComboHighlightConfig = new();
[Checkbox("Debug Drag & Drop")]
[Order(30)]
public bool DebugDragDrop = false;
@@ -106,6 +109,35 @@ namespace HSUI.Interface.GeneralElements
public new static HotbarsConfig DefaultConfig() => new HotbarsConfig();
}
public enum ComboHighlightLineStyle
{
Solid = 0,
Dashed = 1,
Dotted = 2
}
[Exportable(false)]
public class ComboHighlightConfig : PluginConfigObject
{
[ColorEdit4("Color")]
[Order(1)]
public PluginConfigColor Color = PluginConfigColor.FromHex(0xFFFFD700);
[Checkbox("Show Glow")]
[Order(2)]
public bool ShowGlow = false;
[Combo("Line Style", new string[] { "Solid", "Dashed", "Dotted" })]
[Order(3)]
public int LineStyle = (int)ComboHighlightLineStyle.Solid;
[DragInt("Border Thickness", min = 1, max = 8)]
[Order(4)]
public int Thickness = 3;
public ComboHighlightConfig() { }
}
public class HotbarsGeneralOptionsConfig : PluginConfigObject
{
[Checkbox("Enable drag and drop from game UI", help = "When enabled, you can drag actions, macros, and items from the Actions menu, Macro menu, and Inventory onto HSUI hotbars.")]
+23 -3
View File
@@ -55,9 +55,7 @@ namespace HSUI.Interface
Config.ValueChangeEvent -= ConfigValueChanged;
// Only restore defaults when already on framework thread. Skip RunOnFrameworkThread
// during unload—it can deadlock. Restore is best-effort; game state may be torn down.
if (Plugin.Framework.IsInFrameworkUpdateThread && Plugin.ObjectTable.LocalPlayer != null)
void RestoreDefaults()
{
try
{
@@ -72,6 +70,26 @@ namespace HSUI.Interface
Plugin.Logger.Error($"Exception during HudHelper.Dispose restore: {ex.Message}");
}
}
if (Plugin.Framework.IsInFrameworkUpdateThread && Plugin.ObjectTable.LocalPlayer != null)
{
RestoreDefaults();
}
else
{
try
{
Plugin.Framework.RunOnFrameworkThread(() =>
{
if (Plugin.ObjectTable.LocalPlayer != null)
RestoreDefaults();
});
}
catch (Exception ex)
{
Plugin.Logger.Error($"Exception scheduling HudHelper.Dispose restore: {ex.Message}");
}
}
}
public void Update()
@@ -230,6 +248,8 @@ namespace HSUI.Interface
if (enemyList?.Enabled == true)
AddHashes("_EnemyList");
// Hide NamePlate when HSUI nameplates are enabled. We read icon IDs from the addon (still updated when hidden)
// and draw our own quest icons (! ? above NPCs) via NPC nameplate IconConfig.
var nameplatesConfig = ConfigurationManager.Instance?.GetConfigObject<NameplatesGeneralConfig>();
if (nameplatesConfig?.Enabled == true)
AddHashes("NamePlate");
+20
View File
@@ -224,6 +224,26 @@ namespace HSUI.Interface.Nameplates
NameplateAnchor? barAnchor = GetBarAnchor(data);
drawActions.AddRange(GetMainLabelDrawActions(data, barAnchor));
// Quest/state icon (e.g. ! ? above NPCs) - drawn when we have icon config and game provided icon ID
if (_config is NameplateWithNPCBarConfig npcConfig &&
npcConfig.IconConfig.Enabled &&
data.NamePlateIconId > 0)
{
float alpha = _config.RangeConfig.AlphaForDistance(data.Distance, 1f);
Vector2 anchorPos = barAnchor?.Position ?? (_config.Position + data.ScreenPosition);
Vector2 anchorSize = barAnchor?.Size ?? Vector2.Zero;
var pos = Utils.GetAnchoredPosition(anchorPos, -anchorSize, npcConfig.IconConfig.FrameAnchor);
Vector2 iconPos = Utils.GetAnchoredPosition(pos + npcConfig.IconConfig.Position, npcConfig.IconConfig.Size, npcConfig.IconConfig.Anchor);
drawActions.Add((npcConfig.IconConfig.StrataLevel, () =>
{
DrawHelper.DrawInWindow(_config.ID + "_npcIcon", iconPos, npcConfig.IconConfig.Size, false, (drawList) =>
{
DrawHelper.DrawIcon((uint)data.NamePlateIconId, iconPos, npcConfig.IconConfig.Size, false, alpha, drawList);
});
}));
}
return drawActions;
}
+10
View File
@@ -413,6 +413,16 @@ namespace HSUI.Interface.GeneralElements
[NestedConfig("Health Bar", 40)]
public NameplateBarConfig BarConfig = null!;
/// <summary>Quest/state icon (e.g. ! ? above NPCs). Use Position and Size to resize and reposition around the nameplate.</summary>
[NestedConfig("Icon (Quest/State)", 42)]
public NameplateIconConfig IconConfig = new NameplateIconConfig(
new Vector2(0, -28),
new Vector2(32, 32),
DrawAnchor.Bottom,
DrawAnchor.Top
)
{ Strata = StrataLevel.LOWEST };
public NameplateBarConfig GetBarConfig() => BarConfig;
public NameplateWithNPCBarConfig(
+55 -21
View File
@@ -75,10 +75,14 @@ namespace HSUI.Interface.Nameplates
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()
@@ -107,6 +111,7 @@ namespace HSUI.Interface.Nameplates
_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++)
{
@@ -127,18 +132,48 @@ namespace HSUI.Interface.Nameplates
foundTarget = true;
}
// ui nameplate
// ui nameplate (may be stale when addon is hidden)
NamePlateObject nameplateObject = addon->NamePlateObjectArray[objectInfo->NamePlateIndex];
// position
Vector2 screenPos = new Vector2(
nameplateObject.RootComponentNode->AtkResNode.X + nameplateObject.RootComponentNode->AtkResNode.Width / 2f,
nameplateObject.RootComponentNode->AtkResNode.Y + nameplateObject.RootComponentNode->AtkResNode.Height
);
screenPos = ClampScreenPosition(screenPos);
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);
@@ -158,12 +193,18 @@ namespace HSUI.Interface.Nameplates
isTitlePrefix = customTitleData.IsPrefix;
}
// state icon
int iconId = 0;
AtkUldAsset* textureInfo = nameplateObject.NameIcon->PartsList->Parts[nameplateObject.NameIcon->PartId].UldAsset;
if (textureInfo != null && textureInfo->AtkTexture.Resource != null)
// 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)
{
iconId = (int)textureInfo->AtkTexture.Resource->IconId;
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
@@ -206,6 +247,7 @@ namespace HSUI.Interface.Nameplates
catch { }
}
_smoothedPositions = nextSmoothed;
_data.Reverse();
// add target nameplate last
@@ -253,22 +295,14 @@ namespace HSUI.Interface.Nameplates
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;
}