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 @@
+
+
+
diff --git a/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml.cs b/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml.cs
index b7ed25a9229..cd68104af06 100644
--- a/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml.cs
+++ b/Content.Client/_Mono/FireControl/UI/FireControlWindow.xaml.cs
@@ -28,12 +28,33 @@ public FireControlWindow()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
+ NavRadar.OnLockChanged += UpdateLockStatus;
RefreshButton.OnPressed += _ => OnServerRefresh?.Invoke();
SelectAllButton.OnPressed += SelectAllWeapons;
UnselectAllButton.OnPressed += UnselectAllWeapons;
SelectBallisticButton.OnPressed += SelectBallisticWeapons;
SelectEnergyButton.OnPressed += SelectEnergyWeapons;
SelectMissileButton.OnPressed += SelectMissileWeapons;
+ UpdateLockStatus();
+ }
+
+ // Bright "weapons hot" red; readable on the dark gunnery panel background.
+ private static readonly Color LockedColor = new(1f, 0.32f, 0.32f);
+
+ private void UpdateLockStatus()
+ {
+ var hasLock = NavRadar.LockedBlip != null || NavRadar.LockedGrid != null;
+ LockStatus.Text = Loc.GetString(hasLock
+ ? "gunnery-window-lock-status-locked"
+ : "gunnery-window-lock-status-unlocked");
+ LockStatus.FontColorOverride = hasLock ? LockedColor : Color.LightGray;
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ NavRadar.OnLockChanged -= UpdateLockStatus;
+ base.Dispose(disposing);
}
private void SelectAllWeapons(BaseButton.ButtonEventArgs args)
@@ -128,6 +149,7 @@ private void SelectMissileWeapons(BaseButton.ButtonEventArgs args)
public void UpdateStatus(FireControlConsoleBoundInterfaceState state)
{
NavRadar.UpdateState(state.NavState);
+ UpdateLockStatus();
if (state.Connected)
{
diff --git a/Resources/Locale/en-US/_Mono/gunnery.ftl b/Resources/Locale/en-US/_Mono/gunnery.ftl
index 58621129775..51d41e0e2da 100644
--- a/Resources/Locale/en-US/_Mono/gunnery.ftl
+++ b/Resources/Locale/en-US/_Mono/gunnery.ftl
@@ -1,6 +1,8 @@
gunnery-window-title = Gunnery Control
gunnery-window-disconnected = DISCONNECTED
gunnery-window-connected = CONNECTED
+gunnery-window-lock-status-locked = LOCKED ON
+gunnery-window-lock-status-unlocked = NO LOCK
gunnery-select-all = Select All
gunnery-unselect-all = Unselect All
gunnery-guns = Guns