using Dalamud.Bindings.ImGui; using Dalamud.Game.ClientState.Objects.SubKinds; using Dalamud.Game.ClientState.Objects.Types; using Dalamud.Interface; using Dalamud.Plugin; using Dalamud.Plugin.Services; using FFXIVClientStructs.FFXIV.Client.Game.Object; using LightlessSync.LightlessConfiguration; using LightlessSync.Services.Mediator; using LightlessSync.UI; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using System.Collections.Immutable; using System.Numerics; namespace LightlessSync.Services.LightFinder { public class LightFinderPlateHandler : IHostedService, IMediatorSubscriber { private readonly ILogger _logger; private readonly LightlessConfigService _configService; private readonly IDalamudPluginInterface _pluginInterface; private readonly IObjectTable _gameObjects; private readonly IGameGui _gameGui; private const float _defaultNameplateDistance = 15.0f; private ImmutableHashSet _activeBroadcastingCids = []; private readonly Dictionary _smoothed = []; private readonly float _defaultHeightOffset = 0f; public LightlessMediator Mediator { get; } public LightFinderPlateHandler( ILogger logger, LightlessMediator mediator, IDalamudPluginInterface dalamudPluginInterface, LightlessConfigService configService, IObjectTable gameObjects, IGameGui gameGui) { _logger = logger; Mediator = mediator; _pluginInterface = dalamudPluginInterface; _configService = configService; _gameObjects = gameObjects; _gameGui = gameGui; } public Task StartAsync(CancellationToken cancellationToken) { _logger.LogInformation("Starting LightFinderPlateHandler..."); _pluginInterface.UiBuilder.Draw += OnDraw; _logger.LogInformation("LightFinderPlateHandler started."); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation("Stopping LightFinderPlateHandler..."); _pluginInterface.UiBuilder.Draw -= OnDraw; _logger.LogInformation("LightFinderPlateHandler stopped."); return Task.CompletedTask; } private unsafe void OnDraw() { if (!_configService.Current.BroadcastEnabled) return; if (_activeBroadcastingCids.Count == 0) return; var drawList = ImGui.GetForegroundDrawList(); foreach (var obj in _gameObjects.PlayerObjects.OfType()) { //Double check to be sure, should always be true due to OfType filter above if (obj is not IPlayerCharacter player) continue; if (player.Address == IntPtr.Zero) continue; var hashedCID = DalamudUtilService.GetHashedCIDFromPlayerPointer(player.Address); if (!_activeBroadcastingCids.Contains(hashedCID)) continue; //Approximate check if nameplate should be visible (at short distances) if (!ShouldApproximateNameplateVisible(player)) continue; if (!TryGetApproxNameplateScreenPos(player, out var rawScreenPos)) continue; var rawVector3 = new Vector3(rawScreenPos.X, rawScreenPos.Y, 0f); if (rawVector3 == Vector3.Zero) { _smoothed.Remove(obj); continue; } //Possible have to rework this. Currently just a simple distance check to avoid jitter. Vector3 smoothedVector3; if (_smoothed.TryGetValue(obj, out var lastVector3)) { var deltaVector2 = new Vector2(rawVector3.X - lastVector3.X, rawVector3.Y - lastVector3.Y); if (deltaVector2.Length() < 1f) smoothedVector3 = lastVector3; else smoothedVector3 = rawVector3; } else { smoothedVector3 = rawVector3; } _smoothed[obj] = smoothedVector3; var screenPos = new Vector2(smoothedVector3.X, smoothedVector3.Y); var radiusWorld = Math.Max(player.HitboxRadius, 0.5f); var radiusPx = radiusWorld * 8.0f; var offsetPx = GetScreenOffset(player); var drawPos = new Vector2(screenPos.X, screenPos.Y - offsetPx); var fillColor = ImGui.GetColorU32(UiSharedService.Color(UIColors.Get("Lightfinder"))); var outlineColor = ImGui.GetColorU32(UiSharedService.Color(UIColors.Get("LightfinderEdge"))); drawList.AddCircleFilled(drawPos, radiusPx, fillColor); drawList.AddCircle(drawPos, radiusPx, outlineColor, 0, 2.0f); var label = "LightFinder"; var icon = FontAwesomeIcon.Bullseye.ToIconString(); ImGui.PushFont(UiBuilder.IconFont); var iconSize = ImGui.CalcTextSize(icon); var iconPos = new Vector2(drawPos.X - iconSize.X / 2f, drawPos.Y - radiusPx - iconSize.Y - 2f); drawList.AddText(iconPos, fillColor, icon); ImGui.PopFont(); /* var scale = 1.4f; var font = ImGui.GetFont(); var baseFontSize = ImGui.GetFontSize(); var fontSize = baseFontSize * scale; var baseTextSize = ImGui.CalcTextSize(label); var textSize = baseTextSize * scale; var textPos = new Vector2( drawPos.X - textSize.X / 2f, drawPos.Y - radiusPx - textSize.Y - 2f ); drawList.AddText(font, fontSize, textPos, fillColor, label); */ } } // Get screen offset based on distance to local player (to scale size appropriately) // I need to fine tune these values still private float GetScreenOffset(IPlayerCharacter player) { var local = _gameObjects.LocalPlayer; if (local == null) return 32.1f; var delta = player.Position - local.Position; var dist = MathF.Sqrt(delta.X * delta.X + delta.Z * delta.Z); const float minDist = 2.1f; const float maxDist = 30.4f; dist = Math.Clamp(dist, minDist, maxDist); var t = 1f - (dist - minDist) / (maxDist - minDist); const float minOffset = 24.4f; const float maxOffset = 56.4f; return minOffset + (maxOffset - minOffset) * t; } private bool TryGetApproxNameplateScreenPos(IPlayerCharacter player, out Vector2 screenPos) { screenPos = default; var worldPos = player.Position; var visualHeight = GetVisualHeight(player); worldPos.Y += (visualHeight + 1.2f) + _defaultHeightOffset; if (!_gameGui.WorldToScreen(worldPos, out var raw)) return false; screenPos = raw; return true; } // Approximate check to see if nameplate would be visible based on distance and screen position // Also has to be fine tuned still private bool ShouldApproximateNameplateVisible(IPlayerCharacter player) { var local = _gameObjects.LocalPlayer; if (local == null) return false; var delta = player.Position - local.Position; var distance2D = MathF.Sqrt(delta.X * delta.X + delta.Z * delta.Z); if (distance2D > _defaultNameplateDistance) return false; var verticalDelta = MathF.Abs(delta.Y); if (verticalDelta > 3.4f) return false; return TryGetApproxNameplateScreenPos(player, out _); } private static unsafe float GetVisualHeight(IPlayerCharacter player) { var gameObject = (GameObject*)player.Address; if (gameObject == null) return Math.Max(player.HitboxRadius * 2.0f, 1.7f); // fallback // This should account for transformations (sitting, crouching, etc.) var radius = gameObject->GetRadius(adjustByTransformation: true); if (radius <= 0) radius = Math.Max(player.HitboxRadius * 2.0f, 1.7f); return radius; } // Update the set of active broadcasting CIDs (Same uses as in NameplateHnadler before) public void UpdateBroadcastingCids(IEnumerable cids) { var newSet = cids.ToImmutableHashSet(StringComparer.Ordinal); if (ReferenceEquals(_activeBroadcastingCids, newSet) || _activeBroadcastingCids.SetEquals(newSet)) return; _activeBroadcastingCids = newSet; if (_logger.IsEnabled(LogLevel.Information)) _logger.LogInformation("Active broadcast CIDs: {Cids}", string.Join(',', _activeBroadcastingCids)); } } }