Player/Car/Gameplay/CarSpinout.cs

Component that handles a car spin-out mechanic. Applies an impulse, suppresses input while spinning, rotates the car yaw over the spin duration, and spawns a one-shot visual effect; spin state is authority-validated and synced for visuals.

NetworkingFile Access
using Machines.Player;

namespace Machines.Items;

/// <summary>
/// Handles car spin out mechanic
/// </summary>
public sealed class CarSpinout : Component
{
	[RequireComponent]
	public Car Car { get; private set; }

	/// <summary>
	/// Knockback impulse strength applied along the hit direction
	/// </summary>
	[Property]
	public float Knockback { get; set; } = 350f;

	/// <summary>
	/// Yaw spin rate (deg/s) applied to the car body while spinning out
	/// </summary>
	[Property]
	public float SpinRate { get; set; } = 1080f;

	/// <summary>
	/// One-shot FX spawned on the car when it spins out (stars/smoke)
	/// </summary>
	[Property]
	public GameObject SpinEffect { get; set; }

	/// <summary>
	/// Time.Now when the current spin-out ends, synced for visuals and input suppression.
	/// </summary>
	[Sync]
	public float SpinUntil { get; set; }

	/// <summary>
	/// True while the car is spun out and shouldn't accept input
	/// </summary>
	public bool IsSpinning => Time.Now < SpinUntil;

	private bool IsAuthority => Car.IsValid() && Car.IsAuthority;

	// Deterministic spin in whole turns so it ends on the pre-hit heading.
	private float _spinStartTime;
	private float _spinStartYaw;
	private float _spinTotalDegrees;
	private bool _wasSpinning;

	/// <summary>
	/// Spin the car out for <paramref name="duration"/> seconds along <paramref name="hitDir"/>. Consumes shield if active. Broadcast, gated by <see cref="IsAuthority"/>.
	/// </summary>
	[Rpc.Broadcast]
	public void Spin( float duration, Vector3 hitDir )
	{
		if ( !IsAuthority || duration <= 0f )
			return;

		// Shield absorbs the hit.
		if ( Car.Inventory.IsValid() && Car.Inventory.TryAbsorbHit() )
			return;

		// Already spinning: keep the longer window, don't stack.
		SpinUntil = MathF.Max( SpinUntil, Time.Now + duration );

		if ( Car.Movement.IsValid() )
		{
			var dir = hitDir.WithZ( 0f );
			dir = dir.IsNearlyZero() ? Rotation.FromYaw( Car.Movement.Yaw ).Forward : dir.Normal;
			Car.Movement.ApplyImpulse( dir * Knockback, gripSuppressSeconds: duration );

			// Plan whole turns so the spin ends on the pre-hit heading.
			_spinStartTime = Time.Now;
			_spinStartYaw = Car.Movement.Yaw;
			var spinSeconds = SpinUntil - Time.Now;
			var turns = MathF.Max( 1f, MathF.Round( SpinRate * spinSeconds / 360f ) );
			_spinTotalDegrees = turns * 360f;
		}

		SpawnSpinFx();
	}

	protected override void OnFixedUpdate()
	{
		if ( !IsAuthority || !Car.Movement.IsValid() )
			return;

		if ( IsSpinning )
		{
			// Ease out: spin starts violent and settles onto the final heading.
			var window = MathF.Max( 0.01f, SpinUntil - _spinStartTime );
			var t = MathX.Clamp( (Time.Now - _spinStartTime) / window, 0f, 1f );
			var eased = 1f - (1f - t) * (1f - t);

			Car.Movement.SetYaw( _spinStartYaw + _spinTotalDegrees * eased );
			_wasSpinning = true;
		}
		else if ( _wasSpinning )
		{
			// Snap exactly onto the pre-hit heading when done.
			_wasSpinning = false;
			Car.Movement.SetYaw( _spinStartYaw + _spinTotalDegrees );
		}
	}

	[Rpc.Broadcast( NetFlags.OwnerOnly )]
	private void SpawnSpinFx()
	{
		if ( !SpinEffect.IsValid() )
			return;

		SpinEffect.Clone( new CloneConfig
		{
			Parent = GameObject,
			Transform = new Transform( Vector3.Zero ),
			StartEnabled = true
		} );
	}
}