Player/Items/RocketProjectile.cs

A rocket projectile component used as a pickup item and minimap blip. It spawns in front of the firing car, follows the racing line (or free-flights) toward a target car, performs armed hit detection, applies a spinout on hit, awards score to the owner, and spawns an explosion effect on impact or expiry.

NetworkingFile Access
using Machines.Player;
using Machines.Race;
using Machines.UI;

namespace Machines.Items;

/// <summary>
/// Rocket projectile that homes along the racing line toward a target car.
/// </summary>
public sealed class RocketProjectile : Component, IPickupItem, IMinimapBlip
{
	Color IMinimapBlip.BlipColor => _owner.IsValid() ? _owner.PlayerColor : Color.White;
	string IMinimapBlip.BlipClass => "item rocket";
	int IMinimapBlip.BlipPriority => 1;

	/// <summary>
	/// Forward distance from the firer to spawn at.
	/// </summary>
	[Property]
	public float SpawnForward { get; set; } = 60f;

	/// <summary>
	/// Height above the firer to spawn at.
	/// </summary>
	[Property]
	public float SpawnUp { get; set; } = 20f;

	/// <summary>
	/// Cruise speed along the racing line (units/s).
	/// </summary>
	[Property]
	public float Speed { get; set; } = 1400f;

	/// <summary>
	/// Speed at close range, eased down so the rocket settles onto the target.
	/// </summary>
	[Property]
	public float MinSpeed { get; set; } = 700f;

	/// <summary>
	/// Line-distance at which the rocket peels off the centerline for the final approach.
	/// </summary>
	[Property]
	public float StrikeRange { get; set; } = 450f;

	/// <summary>
	/// Height above the target's origin to aim at (hits the body, not the wheels).
	/// </summary>
	[Property]
	public float HitHeight { get; set; } = 20f;

	/// <summary>
	/// Radius used for the car-overlap hit test.
	/// </summary>
	[Property]
	public float HitRadius { get; set; } = 40f;

	/// <summary>
	/// Arm delay so the rocket clears the firer before hitting anything.
	/// </summary>
	[Property]
	public float ArmTime { get; set; } = 0.1f;

	/// <summary>
	/// Max lifetime before the rocket self-destructs.
	/// </summary>
	[Property]
	public float Lifetime { get; set; } = 6f;

	/// <summary>
	/// Seconds the victim spins out for.
	/// </summary>
	[Property]
	public float SpinDuration { get; set; } = 1.5f;

	/// <summary>
	/// Explosion FX spawned at the hit / expiry point.
	/// </summary>
	[Property]
	public GameObject ExplosionEffect { get; set; }

	private Car _owner;
	private GameObject _target;
	private float _spawnTime;

	private RacingLine _line;
	private float _pathDistance;

	public bool Activate( Car owner )
	{
		_owner = owner;
		_target = owner.Inventory?.FindCarAhead();
		_spawnTime = Time.Now;

		// Spawn just ahead of the firer.
		var yaw = owner.Movement.IsValid() ? owner.Movement.Yaw : owner.WorldRotation.Yaw();
		var rot = Rotation.FromYaw( yaw );
		WorldRotation = rot;
		WorldPosition = owner.WorldPosition + rot.Forward * SpawnForward + Vector3.Up * SpawnUp;

		// Cache the racing line for flight guidance.
		_line = RacingPath.Current?.Optimal;
		if ( _line is { IsValid: true } )
			_pathDistance = _line.GetDistanceAtPosition( WorldPosition );

		GameObject.NetworkSpawn();
		return true;
	}

	private bool IsAuthority => !Networking.IsActive || GameObject.Network.IsOwner;

	protected override void OnFixedUpdate()
	{
		// Only the owner simulates flight and hit detection.
		if ( !IsAuthority )
			return;

		var dt = Time.Delta;

		if ( _line is { IsValid: true } && _line.TotalLength > 1f )
			FollowLine( dt );
		else
			FreeFlight( dt );

		var armed = Time.Now - _spawnTime >= ArmTime;
		if ( armed )
		{
			foreach ( var hit in Scene.Trace.Sphere( HitRadius, WorldPosition, WorldPosition )
				.WithTag( "car" )
				.RunAll() )
			{
				var car = hit.GameObject?.GetComponentInParent<Car>();
				if ( !car.IsValid() || car == _owner )
					continue;

				car.Spinout?.Spin( SpinDuration, WorldRotation.Forward );
				_owner?.Score?.RpcAdd( "Rocket Hit", 50 );
				_owner?.Score?.RpcRecordHit( "rocket-hits" );
				Explode();
				return;
			}
		}

		if ( Time.Now - _spawnTime >= Lifetime )
			Explode();
	}

	/// <summary>
	/// Rides the centerline by arc distance, then peels off for the final strike.
	/// </summary>
	private void FollowLine( float dt )
	{
		var len = _line.TotalLength;

		// Arc distance to target along the line.
		var gap = float.MaxValue;
		if ( _target.IsValid() )
		{
			var targetDist = _line.GetDistanceAtPosition( _target.WorldPosition );
			gap = Mod( targetDist - _pathDistance, len );
		}

		// Ease speed as we close the final gap.
		var speed = gap < StrikeRange
			? MathX.Lerp( MinSpeed, Speed, MathX.Clamp( gap / StrikeRange, 0f, 1f ) )
			: Speed;

		// Advance along the centerline.
		_pathDistance = Mod( _pathDistance + speed * dt, len );
		var goal = _line.GetPointAtDistance( _pathDistance ) + Vector3.Up * SpawnUp;

		// Final approach: blend toward the car's actual position.
		if ( _target.IsValid() && gap < StrikeRange )
		{
			var t = 1f - MathX.Clamp( gap / StrikeRange, 0f, 1f );
			var aim = _target.WorldPosition + Vector3.Up * HitHeight;
			goal = Vector3.Lerp( goal, aim, t );
		}

		// Glide toward the goal and face movement direction.
		var toGoal = goal - WorldPosition;
		var step = speed * dt;
		WorldPosition = toGoal.Length <= step ? goal : WorldPosition + toGoal.Normal * step;

		if ( toGoal.Length > 0.01f )
		{
			var look = Rotation.LookAt( toGoal.Normal, Vector3.Up );
			WorldRotation = Rotation.Slerp( WorldRotation, look, MathX.Clamp( 12f * dt, 0f, 1f ) );
		}
	}

	/// <summary>
	/// Fallback flight with no racing line: fly straight toward the target.
	/// </summary>
	private void FreeFlight( float dt )
	{
		var dir = WorldRotation.Forward;
		if ( _target.IsValid() )
			dir = (_target.WorldPosition + Vector3.Up * HitHeight - WorldPosition).Normal;

		WorldRotation = Rotation.LookAt( dir, Vector3.Up );
		WorldPosition += dir * Speed * dt;
	}

	private static float Mod( float a, float m ) => ((a % m) + m) % m;

	private void Explode()
	{
		SpawnExplosionFx( WorldPosition );
		GameObject.Destroy();
	}

	[Rpc.Broadcast]
	private void SpawnExplosionFx( Vector3 point )
	{
		if ( ExplosionEffect.IsValid() )
		{
			ExplosionEffect.Clone( new CloneConfig
			{
				Transform = new Transform( point ),
				StartEnabled = true
			} );
		}
	}
}