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