// SPDX-FileCopyrightText: 2025 Ark
// SPDX-FileCopyrightText: 2025 Redrover1760
// SPDX-FileCopyrightText: 2025 RikuTheKiller
//
// SPDX-License-Identifier: AGPL-3.0-or-later
using System.ComponentModel.DataAnnotations;
using System.Numerics;
using Content.Shared.Interaction;
using Content.Server.Shuttles.Components;
using Content.Shared.Projectiles;
using Robust.Server.GameObjects;
using Robust.Shared.Physics.Components;
using Robust.Shared.Timing;
namespace Content.Server._Mono.Projectiles.TargetSeeking;
///
/// Handles the logic for target-seeking projectiles.
///
public sealed class TargetSeekingSystem : EntitySystem
{
[Dependency] private readonly SharedTransformSystem _transform = null!;
[Dependency] private readonly RotateToFaceSystem _rotateToFace = null!;
[Dependency] private readonly PhysicsSystem _physics = null!;
[Dependency] private readonly IGameTiming _gameTiming = default!; // Mono
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent(OnProjectileHit);
SubscribeLocalEvent(OnParentChanged);
}
///
/// Called when a target-seeking projectile hits something.
///
private void OnProjectileHit(EntityUid uid, TargetSeekingComponent component, ref ProjectileHitEvent args)
{
// If we hit our actual target, we could perform additional effects here
if (component.CurrentTarget.HasValue && component.CurrentTarget.Value == args.Target)
{
// Target hit successfully
}
// Reset the target since we've hit something
component.CurrentTarget = null;
}
///
/// Called when a target-seeking projectile changes parent (e.g., enters a grid).
///
private void OnParentChanged(EntityUid uid, TargetSeekingComponent component, EntParentChangedMessage args)
{
// Check if the projectile has entered a grid
if (args.Transform.GridUid == null)
return;
// Get the shooter's grid to compare
if (!TryComp(uid, out var projectile) ||
!TryComp(projectile.Shooter, out var shooterTransform))
return;
var shooterGridUid = shooterTransform.GridUid;
var currentGridUid = args.Transform.GridUid;
// If we've entered a different grid than the shooter's grid, disable seeking
if (currentGridUid != shooterGridUid)
{
component.SeekingDisabled = true;
}
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var ticktime = _gameTiming.TickPeriod;
var query = EntityQueryEnumerator();
while (query.MoveNext(out var uid, out var seekingComp, out var body, out var xform))
{
// Mono Begin
var acceleration = seekingComp.Acceleration * frameTime;
// Initialize launch speed.
if (seekingComp.Launched == false)
{
acceleration += seekingComp.LaunchSpeed;
seekingComp.Launched = true;
}
// Apply acceleration in the direction the projectile is facing
_physics.SetLinearVelocity(uid, body.LinearVelocity + _transform.GetWorldRotation(xform).ToWorldVec() * acceleration, body: body);
// Damping applied for missiles above max speed.
if (body.LinearVelocity.Length() > seekingComp.MaxSpeed)
_physics.SetLinearDamping(uid, body, seekingComp.Acceleration * (float)ticktime.TotalSeconds * 1.5f);
else
{
_physics.SetLinearDamping(uid, body, 0f);
}
// Skip seeking behavior if disabled (e.g., after entering an enemy grid)
if (seekingComp.SeekingDisabled)
continue;
if (seekingComp.TrackDelay > 0f)
{
seekingComp.TrackDelay -= frameTime;
continue;
}
// If we have a target, track it using the selected algorithm
if (seekingComp.CurrentTarget.HasValue)
{
if (seekingComp.TrackingAlgorithm == TrackingMethod.Predictive)
{
ApplyPredictiveTracking(uid, seekingComp, xform, frameTime);
}
else
{
ApplyDirectTracking(uid, seekingComp, xform, frameTime);
}
}
else
{
// Try to acquire a new target
AcquireTarget(uid, seekingComp, xform);
}
}
}
///
/// Finds the closest valid target within range and tracking parameters.
///
public void AcquireTarget(EntityUid uid, TargetSeekingComponent component, TransformComponent transform)
{
var closestDistance = float.MaxValue;
EntityUid? bestTarget = null;
// Look for shuttles to target
var shuttleQuery = EntityQueryEnumerator();
while (shuttleQuery.MoveNext(out var targetUid, out _, out var targetXform))
{
// If this entity has a grid UID, use that as our actual target
// This targets the ship grid rather than just the console
var actualTarget = targetXform.GridUid ?? targetUid;
// Get angle to the target
var targetPos = _transform.ToMapCoordinates(targetXform.Coordinates).Position;
var sourcePos = _transform.ToMapCoordinates(transform.Coordinates).Position;
var angleToTarget = (targetPos - sourcePos).ToWorldAngle();
// Get current direction of the projectile
var currentRotation = _transform.GetWorldRotation(transform);
// Check if target is within field of view
var angleDifference = Angle.ShortestDistance(currentRotation, angleToTarget).Degrees;
if (MathF.Abs((float)angleDifference) > component.ScanArc / 2)
{
continue; // Target is outside our field of view
}
// Calculate distance to target
var distance = Vector2.Distance(sourcePos, targetPos);
// Skip if target is out of range
if (distance > component.DetectionRange)
{
continue;
}
// Skip if the target is our own launcher (don't target our own ship)
if (TryComp(uid, out var projectile) &&
TryComp(projectile.Shooter, out var shooterTransform))
{
var shooterGridUid = shooterTransform.GridUid;
// If the shooter is on the same grid as this potential target, skip it
if (targetXform.GridUid.HasValue && shooterGridUid == targetXform.GridUid)
{
continue;
}
}
// If this is closer than our previous best target, update
if (closestDistance > distance)
{
closestDistance = distance;
bestTarget = actualTarget;
}
}
// Set our new target
if (bestTarget.HasValue)
{
component.CurrentTarget = bestTarget;
// Initialize tracking data
if (TryComp(bestTarget, out var targetXform))
{
component.PreviousTargetPosition = _transform.ToMapCoordinates(targetXform.Coordinates).Position;
component.PreviousDistance = closestDistance;
}
}
}
///
/// Advanced tracking that predicts where the target will be based on its velocity.
///
public void ApplyPredictiveTracking(EntityUid uid, TargetSeekingComponent comp, TransformComponent xform, float frameTime)
{
if (!comp.CurrentTarget.HasValue || !TryComp(comp.CurrentTarget.Value, out var targetXform))
{
return;
}
// Get current positions
var currentTargetPosition = _transform.ToMapCoordinates(targetXform.Coordinates).Position;
var sourcePosition = _transform.ToMapCoordinates(xform.Coordinates).Position;
// Calculate current distance
var currentDistance = Vector2.Distance(sourcePosition, currentTargetPosition);
// Calculate target velocity
var targetVelocity = currentTargetPosition - comp.PreviousTargetPosition;
// Calculate time to intercept (using closing rate)
var closingRate = (comp.PreviousDistance - currentDistance);
var timeToIntercept = closingRate > 0.01f ?
currentDistance / closingRate :
currentDistance / comp.CurrentSpeed;
// Prevent negative or very small intercept times that could cause erratic behavior
timeToIntercept = MathF.Max(timeToIntercept, 0.1f);
// Predict where the target will be when we reach it
var predictedPosition = currentTargetPosition + (targetVelocity * timeToIntercept);
// Calculate angle to the predicted position
var targetAngle = (predictedPosition - sourcePosition).ToWorldAngle();
// Rotate toward that angle at our turn rate
_rotateToFace.TryRotateTo(
uid,
targetAngle,
frameTime,
comp.Tolerance,
comp.TurnRate?.Theta ?? MathF.PI * 2,
xform
);
// Update tracking data for next frame
comp.PreviousTargetPosition = currentTargetPosition;
comp.PreviousDistance = currentDistance;
}
///
/// Basic tracking that points directly at the current target position.
///
public void ApplyDirectTracking(EntityUid uid, TargetSeekingComponent comp, TransformComponent xform, float frameTime)
{
if (!comp.CurrentTarget.HasValue || !TryComp(comp.CurrentTarget.Value, out var targetXform))
{
return;
}
// Get the angle directly toward the target
var angleToTarget = (
_transform.ToMapCoordinates(targetXform.Coordinates).Position -
_transform.ToMapCoordinates(xform.Coordinates).Position
).ToWorldAngle();
// Rotate toward that angle at our turn rate
_rotateToFace.TryRotateTo(
uid,
angleToTarget,
frameTime,
comp.Tolerance,
comp.TurnRate?.Theta ?? MathF.PI * 2,
xform
);
}
}