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.
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
} );
}
}
}