diff --git a/Helpers/DrawHelper.cs b/Helpers/DrawHelper.cs index f06ea5f..6f6207c 100644 --- a/Helpers/DrawHelper.cs +++ b/Helpers/DrawHelper.cs @@ -176,6 +176,57 @@ namespace HSUI.Helpers } + /// Draw a rect outline with optional glow and line style (solid, dashed, dotted). + public static void DrawComboHighlightRect(ImDrawListPtr drawList, Vector2 min, Vector2 max, uint color, float thickness, bool showGlow, int lineStyle) + { + const float inset = 2f; + Vector2 innerMin = min + new Vector2(inset, inset); + Vector2 innerMax = max - new Vector2(inset, inset); + if (innerMax.X <= innerMin.X || innerMax.Y <= innerMin.Y) return; + + if (showGlow) + { + for (int g = 5; g >= 1; g--) + { + float o = g; + uint alpha = (uint)(byte)(70 * (6 - g)) << 24; + drawList.AddRect(innerMin - new Vector2(o, o), innerMax + new Vector2(o, o), + alpha | (color & 0x00FFFFFF), 0, ImDrawFlags.None, 2f); + } + } + + if (lineStyle == 0) // Solid + { + drawList.AddRect(innerMin, innerMax, color, 0, ImDrawFlags.None, thickness); + return; + } + + // Dashed or Dotted: draw each side as segments + float dashLen = lineStyle == 1 ? 4f : 2f; + float gapLen = lineStyle == 1 ? 3f : 3f; + float step = dashLen + gapLen; + + void DrawSegmentedLine(Vector2 a, Vector2 b) + { + Vector2 d = b - a; + float len = d.Length(); + if (len < 0.001f) return; + Vector2 u = d / len; + float t = 0; + while (t < len) + { + float tEnd = Math.Min(t + dashLen, len); + drawList.AddLine(a + u * t, a + u * tEnd, color, thickness); + t += step; + } + } + + DrawSegmentedLine(innerMin, new Vector2(innerMax.X, innerMin.Y)); // top + DrawSegmentedLine(new Vector2(innerMax.X, innerMin.Y), innerMax); // right + DrawSegmentedLine(innerMax, new Vector2(innerMin.X, innerMax.Y)); // bottom + DrawSegmentedLine(new Vector2(innerMin.X, innerMax.Y), innerMin); // left + } + public static void DrawIcon(uint iconId, Vector2 position, Vector2 size, bool drawBorder, uint color, ImDrawListPtr drawList) { IDalamudTextureWrap? texture = TexturesHelper.GetTextureFromIconId(iconId); diff --git a/Helpers/TooltipsHelper.cs b/Helpers/TooltipsHelper.cs index c71ae0c..5fd95ef 100644 --- a/Helpers/TooltipsHelper.cs +++ b/Helpers/TooltipsHelper.cs @@ -42,8 +42,8 @@ namespace HSUI.Helpers } #endregion - private float MaxWidth => 340 * ImGuiHelpers.GlobalScale; - private float Margin => 5 * ImGuiHelpers.GlobalScale; + private float MaxWidth => Math.Max(160, 340 * ImGuiHelpers.GlobalScale * _config.TooltipScale); + private float Margin => Math.Max(4, 5 * ImGuiHelpers.GlobalScale * _config.TooltipScale); private TooltipsConfig _config => ConfigurationManager.Instance.GetConfigObject(); @@ -95,7 +95,8 @@ namespace HSUI.Helpers _currentTooltipTitle += " (ID: " + id + ")"; } - using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + string fontId = _config.FontID ?? FontsConfig.DefaultSmallFontKey; + using (FontsManager.Instance.PushFont(fontId)) { _titleSize = ImGui.CalcTextSize(_currentTooltipTitle, false, MaxWidth); _titleSize.Y += Margin; @@ -103,7 +104,8 @@ namespace HSUI.Helpers } // calculate text size - using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + string fontIdForText = _config.FontID ?? FontsConfig.DefaultSmallFontKey; + using (FontsManager.Instance.PushFont(fontIdForText)) { _textSize = ImGui.CalcTextSize(_currentTooltipText, false, MaxWidth); } @@ -170,25 +172,27 @@ namespace HSUI.Helpers // no idea why i have to do this float globalScaleCorrection = -15 + 15 * ImGuiHelpers.GlobalScale; + string fontId = _config.FontID ?? FontsConfig.DefaultSmallFontKey; + float wrapWidth = Math.Max(_titleSize.X, _textSize.X) + Margin; if (_currentTooltipTitle != null) { // title Vector2 cursorPos; - using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + using (FontsManager.Instance.PushFont(fontId)) { cursorPos = new Vector2(windowMargin.X + _size.X / 2f - _titleSize.X / 2f, Margin); ImGui.SetCursorPos(cursorPos); - ImGui.PushTextWrapPos(cursorPos.X + _titleSize.X + globalScaleCorrection + Margin); + ImGui.PushTextWrapPos(cursorPos.X + wrapWidth + globalScaleCorrection); ImGui.TextColored(_config.TitleColor.Vector, _currentTooltipTitle); ImGui.PopTextWrapPos(); } // text - using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + using (FontsManager.Instance.PushFont(fontId)) { cursorPos = new Vector2(windowMargin.X + _size.X / 2f - _textSize.X / 2f, Margin + _titleSize.Y); ImGui.SetCursorPos(cursorPos); - ImGui.PushTextWrapPos(cursorPos.X + _textSize.X + globalScaleCorrection + Margin); + ImGui.PushTextWrapPos(cursorPos.X + wrapWidth + globalScaleCorrection); ImGui.TextColored(_config.TextColor.Vector, _currentTooltipText); ImGui.PopTextWrapPos(); } @@ -196,13 +200,13 @@ namespace HSUI.Helpers else { // text - using (FontsManager.Instance.PushFont(FontsConfig.DefaultSmallFontKey)) + using (FontsManager.Instance.PushFont(fontId)) { var cursorPos = windowMargin + new Vector2(Margin, Margin); var textWidth = _size.X - Margin * 2; ImGui.SetCursorPos(cursorPos); - ImGui.PushTextWrapPos(cursorPos.X + textWidth + globalScaleCorrection + Margin); + ImGui.PushTextWrapPos(cursorPos.X + textWidth + globalScaleCorrection); ImGui.TextColored(_config.TextColor.Vector, _currentTooltipText); ImGui.PopTextWrapPos(); } @@ -248,8 +252,16 @@ namespace HSUI.Helpers [Order(3)] public bool DebugTooltips = false; + [Font] + [Order(4)] + public string? FontID = null; + + [DragFloat("Tooltip Scale", min = 0.5f, max = 2f, help = "Scale the tooltip window size. Useful for large resolutions.")] + [Order(6)] + public float TooltipScale = 1f; + [Checkbox("Show Status Effects IDs")] - [Order(5)] + [Order(7)] public bool ShowStatusIDs = false; [Checkbox("Show Source Name")] diff --git a/Interface/GeneralElements/ActionBarsHud.cs b/Interface/GeneralElements/ActionBarsHud.cs index b31a350..e472d06 100644 --- a/Interface/GeneralElements/ActionBarsHud.cs +++ b/Interface/GeneralElements/ActionBarsHud.cs @@ -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 diff --git a/Interface/GeneralElements/HotbarsConfig.cs b/Interface/GeneralElements/HotbarsConfig.cs index 7cf37db..6ab3f3b 100644 --- a/Interface/GeneralElements/HotbarsConfig.cs +++ b/Interface/GeneralElements/HotbarsConfig.cs @@ -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.")] diff --git a/Interface/HudHelper.cs b/Interface/HudHelper.cs index 16b69fe..5e8c3aa 100644 --- a/Interface/HudHelper.cs +++ b/Interface/HudHelper.cs @@ -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(); if (nameplatesConfig?.Enabled == true) AddHashes("NamePlate"); diff --git a/Interface/Nameplates/Nameplate.cs b/Interface/Nameplates/Nameplate.cs index e6935ef..93237b7 100644 --- a/Interface/Nameplates/Nameplate.cs +++ b/Interface/Nameplates/Nameplate.cs @@ -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; } diff --git a/Interface/Nameplates/NameplateConfig.cs b/Interface/Nameplates/NameplateConfig.cs index 6338534..9fb472b 100644 --- a/Interface/Nameplates/NameplateConfig.cs +++ b/Interface/Nameplates/NameplateConfig.cs @@ -413,6 +413,16 @@ namespace HSUI.Interface.GeneralElements [NestedConfig("Health Bar", 40)] public NameplateBarConfig BarConfig = null!; + /// Quest/state icon (e.g. ! ? above NPCs). Use Position and Size to resize and reposition around the nameplate. + [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( diff --git a/Interface/Nameplates/NameplatesManager.cs b/Interface/Nameplates/NameplatesManager.cs index afde57b..f415f36 100644 --- a/Interface/Nameplates/NameplatesManager.cs +++ b/Interface/Nameplates/NameplatesManager.cs @@ -75,10 +75,14 @@ namespace HSUI.Interface.Nameplates public IReadOnlyCollection Data => _data.AsReadOnly(); private NameplatesCache _cache = new NameplatesCache(50); + private Dictionary _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(); int activeCount = ui3DModule->NamePlateObjectInfoCount; + var nextSmoothed = new Dictionary(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(); + 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; }