diff --git a/Content.Client/_Mono/FireControl/UI/FireControlLeadSolver.cs b/Content.Client/_Mono/FireControl/UI/FireControlLeadSolver.cs new file mode 100644 index 00000000000..bba7e8f8e4a --- /dev/null +++ b/Content.Client/_Mono/FireControl/UI/FireControlLeadSolver.cs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2026 HardLight contributors +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using System.Numerics; + +namespace Content.Client._Mono.FireControl.UI; + +/// +/// Client-side helper that mirrors the firing-solution math used by +/// ShipTargetingSystem.FireWeapons. Given a gun position, a target +/// position+velocity and a projectile speed, it returns the world-space +/// point where the gunner should aim so the projectile intercepts the +/// target. +/// +public static class FireControlLeadSolver +{ + /// + /// Compute the predicted intercept point in world space. + /// + /// World position of the firing weapon. + /// Current world position of the target. + /// Target's world linear velocity (m/s). + /// Firing platform's world linear velocity (m/s). + /// Projectile muzzle speed (m/s). + /// Outputs the lead displacement applied to the target (= relVel * hitTime). + /// Predicted travel time, in seconds. + /// True if a real intercept exists; false otherwise (target uncatchable). + public static bool TrySolve( + Vector2 gunWorldPos, + Vector2 targetWorldPos, + Vector2 targetVel, + Vector2 ourVel, + float projectileSpeed, + out Vector2 interceptWorldPos, + out Vector2 lead, + out float hitTime) + { + interceptWorldPos = targetWorldPos; + lead = Vector2.Zero; + hitTime = 0f; + + if (projectileSpeed <= 0f) + return false; + + var gunToDest = targetWorldPos - gunWorldPos; + if (gunToDest.LengthSquared() < 0.0001f) + return false; + + var dir = Vector2.Normalize(gunToDest); + var relVel = targetVel - ourVel; + + // Decompose relative velocity into along-axis (normVel) and cross-axis (tgVel). + var normVel = dir * Vector2.Dot(relVel, dir); + var tgVel = relVel - normVel; + + // Tangential component too fast to ever catch (>= so the sqrt below stays > 0). + var projSpeedSq = projectileSpeed * projectileSpeed; + var tgVelLenSq = tgVel.LengthSquared(); + if (tgVelLenSq >= projSpeedSq) + return false; + + var normTarget = dir * MathF.Sqrt(projSpeedSq - tgVelLenSq); + + // Target receding faster than we can chase. + if (Vector2.Dot(normTarget, normVel) > 0f && normVel.Length() > normTarget.Length()) + return false; + + var approachVel = (normTarget - normVel).Length(); + if (approachVel < 0.001f) + return false; + + hitTime = gunToDest.Length() / approachVel; + lead = relVel * hitTime; + interceptWorldPos = targetWorldPos + lead; + return true; + } +} diff --git a/Content.Client/_Mono/FireControl/UI/FireControlNavControl.cs b/Content.Client/_Mono/FireControl/UI/FireControlNavControl.cs index b4c5b7793bb..a463f053a73 100644 --- a/Content.Client/_Mono/FireControl/UI/FireControlNavControl.cs +++ b/Content.Client/_Mono/FireControl/UI/FireControlNavControl.cs @@ -15,6 +15,7 @@ using Content.Client._Mono.Radar; using Content.Shared._Mono.Radar; using Content.Shared._Crescent.ShipShields; +using Content.Shared.Weapons.Ranged.Components; using Robust.Shared.Physics.Collision.Shapes; using Robust.Client.Graphics; using Robust.Client.UserInterface; @@ -61,7 +62,19 @@ public sealed class FireControlNavControl : BaseShuttleControl private float _lastCursorUpdateTime; private const float CursorUpdateInterval = 0.05f; + // Lock-on state. A locked target is either a single tagged radar blip + // (entity-level lock, e.g. an individual cannon / thruster / generator if + // it carries a RadarBlipComponent) or a whole enemy grid. + private NetEntity? _lockedBlip; + private EntityUid? _lockedGrid; + // Hit-test radius (pixels) when right-clicking a blip on the radar. + private const float LockHitRadiusPx = 14f; + // Cap for the world-space hit radius so a far zoom-out doesn't grab + // distant blips the player didn't visually click on. + private const float LockHitRadiusWorldMax = 8f; + public Action? OnRadarClick; + public Action? OnLockChanged; public bool ShowIFF { get; set; } = true; public bool RotateWithEntity { get; set; } = true; @@ -104,6 +117,18 @@ protected override void KeyBindDown(GUIBoundKeyEventArgs args) { base.KeyBindDown(args); + if (args.Function == EngineKeyFunctions.UIRightClick) + { + // Only intercept the click if we're actually wired up to a console; + // otherwise let it propagate (e.g. for a context menu elsewhere). + if (_consoleEntity == null || _coordinates == null || _rotation == null) + return; + + TryToggleLockAtPosition(args.RelativePosition); + args.Handle(); + return; + } + if (args.Function != EngineKeyFunctions.UIClick) return; @@ -164,6 +189,108 @@ private void TryFireAtPosition(Vector2 relativePosition) OnRadarClick?.Invoke(coords); } + /// + /// Convert a UI-relative mouse position to map coordinates using the + /// same transform as . + /// + private MapCoordinates? RelativeToMapCoords(Vector2 relativePosition) + { + if (_coordinates == null || _rotation == null) + return null; + + var a = InverseScalePosition(relativePosition); + var relativeWorldPos = new Vector2(a.X, -a.Y); + relativeWorldPos = _rotation.Value.RotateVec(relativeWorldPos); + var coords = _coordinates.Value.Offset(relativeWorldPos); + return _transform.ToMapCoordinates(coords); + } + + /// + /// Right-click handler. Tries to lock onto whatever's under the cursor + /// (preferring a tagged radar blip, then an enemy grid). Right-clicking + /// empty space clears any existing lock. + /// + private void TryToggleLockAtPosition(Vector2 relativePosition) + { + var clickMap = RelativeToMapCoords(relativePosition); + if (clickMap == null) + return; + + // Resolve our own grid up-front so we can exclude self from both passes. + var ourGrid = _coordinates?.EntityId is { } ent && EntManager.TryGetComponent(ent, out var ourXform) + ? ourXform.GridUid + : null; + + // Use the world-space hit radius derived from the on-screen radius, + // clamped so far-zoom right-clicks stay "near the cursor". + var worldHitRadius = Math.Min(LockHitRadiusPx / Math.Max(MinimapScale, 0.0001f), LockHitRadiusWorldMax); + var worldHitRadiusSq = worldHitRadius * worldHitRadius; + + // 1) Closest tagged blip within hit radius (excluding our own grid). + NetEntity? bestBlip = null; + var bestDistSq = worldHitRadiusSq; + foreach (var blip in _blips.GetCurrentBlips()) + { + if (ourGrid != null && blip.GridUid == ourGrid) + continue; + var blipMap = _transform.ToMapCoordinates(blip.Position); + if (blipMap.MapId != clickMap.Value.MapId) + continue; + var dSq = Vector2.DistanceSquared(blipMap.Position, clickMap.Value.Position); + if (dSq < bestDistSq) + { + bestDistSq = dSq; + bestBlip = blip.NetUid; + } + } + + if (bestBlip != null) + { + SetLock(bestBlip, null); + return; + } + + // 2) Otherwise try an enemy grid under the cursor. + var grids = new List>(); + var pad = new Vector2(0.5f, 0.5f); + _mapManager.FindGridsIntersecting(clickMap.Value.MapId, new Box2(clickMap.Value.Position - pad, clickMap.Value.Position + pad), ref grids, approx: true, includeMap: false); + foreach (var grid in grids) + { + if (grid.Owner == ourGrid) + continue; + SetLock(null, grid.Owner); + return; + } + + // 3) Empty space -> clear lock. + ClearLock(); + } + + private void SetLock(NetEntity? blip, EntityUid? grid) + { + if (_lockedBlip == blip && _lockedGrid == grid) + return; + + _lockedBlip = blip; + _lockedGrid = grid; + OnLockChanged?.Invoke(); + } + + /// + /// Currently locked target (blip uid, or null if grid/none). + /// + public NetEntity? LockedBlip => _lockedBlip; + + /// + /// Currently locked grid (or null if blip/none). + /// + public EntityUid? LockedGrid => _lockedGrid; + + public void ClearLock() + { + SetLock(null, null); + } + public void SetMatrix(EntityCoordinates? coordinates, Angle? angle) { _coordinates = coordinates; @@ -176,6 +303,7 @@ public void SetConsole(EntityUid? consoleEntity) return; _consoleEntity = consoleEntity; + ClearLock(); _requestAccumulator = 0f; if (_consoleEntity != null) @@ -422,10 +550,216 @@ protected override void Draw(DrawingHandleScreen handle) } } + // Lock-on reticle + lead indicator (drawn last so it sits on top). + DrawLockOn(handle, xform, worldToView); + ClearShader(handle); #endregion } + /// + /// Draw a red diamond reticle around the locked target and a "shoot here" + /// marker at the predicted lead intercept point, computed from the + /// currently selected weapons' projectile speeds. + /// + private void DrawLockOn(DrawingHandleScreen handle, TransformComponent ourXform, Matrix3x2 worldToView) + { + if (_lockedBlip == null && _lockedGrid == null) + return; + + // Resolve target world position, velocity, and bounding radius. + Vector2 targetWorldPos; + Vector2 targetVel; + float targetWorldRadius; + EntityUid? targetGrid = null; + + if (_lockedBlip is { } blipUid) + { + BlipData? found = null; + foreach (var b in _blips.GetCurrentBlips()) + { + if (b.NetUid == blipUid) + { + found = b; + break; + } + } + + if (found == null) + { + ClearLock(); + return; + } + + var blipMap = _transform.ToMapCoordinates(found.Value.Position); + if (blipMap.MapId != ourXform.MapID) + { + ClearLock(); + return; + } + + targetWorldPos = blipMap.Position; + targetGrid = found.Value.GridUid; + targetVel = targetGrid != null + ? _physics.GetMapLinearVelocity(targetGrid.Value) + : Vector2.Zero; + // Tagged blips are usually point-like; pick a small reticle. + var cfg = found.Value.Config; + targetWorldRadius = MathF.Max(0.6f, (cfg.Bounds.Width + cfg.Bounds.Height) * 0.5f); + } + else + { + var gridUid = _lockedGrid!.Value; + if (!EntManager.TryGetComponent(gridUid, out var gxform) + || gxform.MapID != ourXform.MapID + || !EntManager.TryGetComponent(gridUid, out var gridComp)) + { + // Target gone or out of map -> drop lock. + ClearLock(); + return; + } + + targetWorldPos = _transform.GetWorldPosition(gxform); + targetVel = _physics.GetMapLinearVelocity(gridUid); + targetGrid = gridUid; + var aabb = gridComp.LocalAABB; + targetWorldRadius = MathF.Max(aabb.Width, aabb.Height) * 0.5f; + } + + var ourGridUid = ourXform.GridUid; + var ourVel = ourGridUid != null + ? _physics.GetMapLinearVelocity(ourGridUid.Value) + : Vector2.Zero; + + var lockColor = Color.Red; + var leadColor = new Color(1f, 0.45f, 0.1f); + + // Red diamond reticle around the target. + var targetView = Vector2.Transform(targetWorldPos, worldToView); + var reticlePx = MathF.Max(targetWorldRadius * MinimapScale + 6f, 14f); + DrawDiamondOutline(handle, targetView, reticlePx, lockColor, 2f); + + // Compute average lead point across selected weapons. + if (_controllables == null || _selectedWeapons.Count == 0) + return; + + var sumIntercept = Vector2.Zero; + var solved = 0; + var gunQuery = EntManager.GetEntityQuery(); + + foreach (var ctrl in _controllables) + { + if (!_selectedWeapons.Contains(ctrl.NetEntity)) + continue; + + var gunEnt = EntManager.GetEntity(ctrl.NetEntity); + if (!gunQuery.TryGetComponent(gunEnt, out var gun)) + continue; + + // Don't try to lead hitscan weapons; they hit instantly. + if (EntManager.HasComponent(gunEnt)) + { + sumIntercept += targetWorldPos; + solved++; + continue; + } + + var projSpeed = gun.ProjectileSpeedModified > 0f ? gun.ProjectileSpeedModified : gun.ProjectileSpeed; + var gunCoords = EntManager.GetCoordinates(ctrl.Coordinates); + var gunMap = _transform.ToMapCoordinates(gunCoords); + if (gunMap.MapId != ourXform.MapID) + continue; + + if (FireControlLeadSolver.TrySolve(gunMap.Position, targetWorldPos, targetVel, ourVel, projSpeed, + out var intercept, out _, out _)) + { + sumIntercept += intercept; + solved++; + } + } + + if (solved == 0) + return; + + var avgIntercept = sumIntercept / solved; + var leadView = Vector2.Transform(avgIntercept, worldToView); + + // Clip the lead indicator to the radar viewport so a far-future + // intercept doesn't draw across (or way outside of) the control. + var viewBox = new Box2(0f, 0f, Size.X, Size.Y); + var leadInside = viewBox.Contains(leadView); + var lineEnd = leadInside ? leadView : ClipSegmentToBox(targetView, leadView, viewBox); + + // Connect target -> lead point with a faint line. + handle.DrawLine(targetView, lineEnd, leadColor.WithAlpha(0.5f)); + + if (leadInside) + { + // Diamond marker + inner dot at the precise aim point. + DrawDiamondOutline(handle, leadView, 8f, leadColor, 2f); + handle.DrawCircle(leadView, 2f, leadColor); + } + else + { + // Off-screen: small arrow-style marker at the edge so the player + // still has a directional cue toward where to aim. + handle.DrawCircle(lineEnd, 3f, leadColor); + } + } + + /// + /// Returns the point where the segment -> + /// exits . Assumes is inside (or at worst on) + /// the box; if not, returns clamped to the box. + /// + private static Vector2 ClipSegmentToBox(Vector2 from, Vector2 to, Box2 box) + { + var dir = to - from; + if (dir.LengthSquared() < 1e-6f) + return Vector2.Clamp(to, new Vector2(box.Left, box.Bottom), new Vector2(box.Right, box.Top)); + + var tMax = 1f; + if (dir.X > 0f) + tMax = MathF.Min(tMax, (box.Right - from.X) / dir.X); + else if (dir.X < 0f) + tMax = MathF.Min(tMax, (box.Left - from.X) / dir.X); + + if (dir.Y > 0f) + tMax = MathF.Min(tMax, (box.Top - from.Y) / dir.Y); + else if (dir.Y < 0f) + tMax = MathF.Min(tMax, (box.Bottom - from.Y) / dir.Y); + + tMax = Math.Clamp(tMax, 0f, 1f); + return from + dir * tMax; + } + + private static void DrawDiamondOutline(DrawingHandleScreen handle, Vector2 center, float size, Color color, float thickness) + { + var top = center + new Vector2(0, -size); + var right = center + new Vector2(size, 0); + var bottom = center + new Vector2(0, size); + var left = center + new Vector2(-size, 0); + + handle.DrawLine(top, right, color); + handle.DrawLine(right, bottom, color); + handle.DrawLine(bottom, left, color); + handle.DrawLine(left, top, color); + + // Cheap "thickness": draw a second concentric diamond one pixel out. + if (thickness > 1f) + { + var s2 = size + 1f; + var top2 = center + new Vector2(0, -s2); + var right2 = center + new Vector2(s2, 0); + var bottom2 = center + new Vector2(0, s2); + var left2 = center + new Vector2(-s2, 0); + handle.DrawLine(top2, right2, color); + handle.DrawLine(right2, bottom2, color); + handle.DrawLine(bottom2, left2, color); + handle.DrawLine(left2, top2, color); + } + } + private void ClearShader(DrawingHandleScreen handle) { // No-op placeholder to maintain compatibility with previous shader clearing behavior. diff --git a/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml b/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml index 6ca1ccaa02a..e54ff2539ce 100644 --- a/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml +++ b/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml @@ -12,6 +12,10 @@