Player/Car/Gameplay/CarDrift.cs

Component that implements car drifting mechanics. It tracks drift state, hop phase, charge levels, slip angle, applies yaw and velocity angle changes, awards speed boosts and spawns visual effects, and listens for impacts to cancel drifts.

NetworkingFile Access
using Machines.Events;
using Machines.Systems;

namespace Machines.Player;

/// <summary>
/// Handles the car drifting mechanics
/// </summary>
public sealed class CarDrift : Component, ICarImpactListener
{
	[RequireComponent]
	public Car Car { get; private set; }

	/// <summary>
	/// Child GameObject enabled while drifting (particle effect or other visual, starts disabled).
	/// </summary>
	[Property]
	public GameObject DriftEffect { get; set; }

	/// <summary>
	/// Prefab spawned on drift boost payout, destroyed after <see cref="BoostEffectLifetime"/>.
	/// </summary>
	[Property]
	public GameObject BoostEffectPrefab { get; set; }

	/// <summary>
	/// How long the boost effect lives before being destroyed.
	/// </summary>
	[Property]
	public float BoostEffectLifetime { get; set; } = 1.5f;

	/// <summary>
	/// Hop phase duration before drift mechanics start; 0 = no hop.
	/// </summary>
	[Property]
	public float HopDuration { get; set; } = 0.3f;

	/// <summary>
	/// Cooldown after a drift ends before a new one can start.
	/// </summary>
	[Property]
	public float DriftCooldown { get; set; } = 0.5f;

	/// <summary>
	/// Impact speed at/above which a wall or car hit cancels the drift (with no boost payout).
	/// </summary>
	[Property]
	public float ImpactExitSpeed { get; set; } = 250f;

	/// <summary>
	/// Speed added on a successful drift boost (units/s). Same for every car.
	/// </summary>
	[ConVar( "drift_boost_speed", Flags = ConVarFlags.Replicated )]
	public static float DriftBoostSpeed { get; set; } = 250f;

	/// <summary>
	/// How long the drift boost lasts (seconds). Same for every car.
	/// </summary>
	[ConVar( "drift_boost_duration", Flags = ConVarFlags.Replicated )]
	public static float DriftBoostDuration { get; set; } = 1f;

	/// <summary>
	/// True while the player is actively drifting.
	/// </summary>
	[Sync]
	public bool IsDrifting { get; private set; }

	/// <summary>
	/// The locked drift direction: -1 = drifting left, +1 = drifting right.
	/// </summary>
	[Sync]
	public int DriftDirection { get; private set; }

	/// <summary>
	/// How long the current drift has been held (seconds).
	/// </summary>
	public float DriftTime { get; private set; }

	/// <summary>
	/// Current boost charge level (0 = none, 1 = mini, 2 = super, 3 = ultra).
	/// </summary>
	public int ChargeLevel { get; private set; }

	/// <summary>
	/// Angle between facing and travel direction (degrees). Positive = drifting right.
	/// </summary>
	public float SlipAngle { get; private set; }

	private float _targetDriftYawOffset;
	private float _currentDriftYawOffset;
	private bool _isHopping;
	private float _hopEndTime;

	/// <summary>
	/// True while the drift hop arc is playing (before the car has landed).
	/// </summary>
	public bool IsHopping => _isHopping;
	private float _nextDriftTime;

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

		if ( !Car.Movement.IsValid() || !Car.Input.IsValid() )
			return;

		var dt = Time.Delta;
		var driftHeld = Car.Input.Current.Drift;
		var steerInput = Car.Input.Current.Steer;
		var hasForwardSpeed = Car.Movement.CurrentSpeed > 50f;

		// Hold-friendly: drift+steer or steer+drift both start the drift immediately.
		if ( driftHeld && !IsDrifting && hasForwardSpeed && System.MathF.Abs( steerInput ) > 0.1f && Time.Now >= _nextDriftTime )
		{
			EnterDrift( steerInput > 0f ? 1 : -1 );
		}

		if ( IsDrifting )
		{
			// Check if hop phase just ended
			if ( _isHopping && Time.Now >= _hopEndTime )
			{
				_isHopping = false;
			}

			if ( !driftHeld || Car.Movement.CurrentSpeed <= 10f )
			{
				ExitDrift();
			}
			else if ( !_isHopping )
			{
				TickDrift( dt, steerInput );
			}
		}

		SlipAngle = IsDrifting ? MathX.DeltaDegrees( Car.Movement.VelocityAngle, Car.Movement.Yaw ) : 0f;
	}

	private void EnterDrift( int direction )
	{
		IsDrifting = true;
		DriftDirection = direction;
		DriftTime = 0f;
		ChargeLevel = 0;

		_targetDriftYawOffset = DriftDirection * 35f;
		_currentDriftYawOffset = 0f;

		_isHopping = true;
		_hopEndTime = Time.Now + HopDuration;
	}

	private void TickDrift( float dt, float steerInput )
	{
		DriftTime += dt;
		var stats = Car.ActiveStats;

		var prevOffset = _currentDriftYawOffset;
		_currentDriftYawOffset = MathX.Lerp( _currentDriftYawOffset, _targetDriftYawOffset, 8f * dt );
		var kickDelta = _currentDriftYawOffset - prevOffset;

		// No counter-steer: player steers into the drift or stays neutral (Mario Kart style).
		var clampedSteer = DriftDirection > 0
			? MathF.Max( 0f, steerInput )
			: MathF.Min( 0f, steerInput );

		// Fixed drift turn rate so TurnRate changes don't cause spinning.
		var driftTurnRate = 280f * stats.DriftTurnMultiplier;
		var steerAmount = clampedSteer * driftTurnRate * 0.5f * dt;
		var driftPull = DriftDirection * driftTurnRate * 0.15f * dt;
		Car.Movement.SetYaw( Car.Movement.Yaw + steerAmount + driftPull + kickDelta );

		var currentVelAngle = Car.Movement.VelocityAngle;
		var targetVelAngle = Car.Movement.Yaw;
		var lerpSpeed = 8f * dt;
		var newVelAngle = MathX.LerpDegrees( currentVelAngle, targetVelAngle, lerpSpeed );

		SetVelocityAngle( newVelAngle );

		// Drift charge levels.

		var minTime = stats.MinDriftTimeForBoost;
		if ( DriftTime >= minTime * 3f )
			ChargeLevel = 3;
		else if ( DriftTime >= minTime * 2f )
			ChargeLevel = 2;
		else if ( DriftTime >= minTime )
			ChargeLevel = 1;
		else
			ChargeLevel = 0;
	}

	/// <summary>
	/// A hard impact while drifting cancels the drift with no boost payout.
	/// </summary>
	void ICarImpactListener.OnCarImpact( CarImpact impact )
	{
		if ( !IsDrifting || impact.Car != Car )
			return;

		if ( impact.Kind == CarImpactKind.Prop )
			return;

		if ( impact.Speed >= ImpactExitSpeed )
			ExitDrift( awardBoost: false );
	}

	private void ExitDrift( bool awardBoost = true )
	{
		IsDrifting = false;

		// Count whole seconds drifted toward the drift-time stat.
		if ( DriftTime >= 0.25f )
			GameStats.Increment( "drift-time", DriftTime, car: Car );

		// Charged drift earns a speed boost on release.
		if ( awardBoost && ChargeLevel >= 1 )
		{
			Car.Movement.StartDriftBoost( DriftBoostSpeed, DriftBoostDuration );
			SpawnBoostEffect();
		}

		// Keep velocity angle; normal physics blends it back.
		DriftTime = 0f;
		ChargeLevel = 0;
		_nextDriftTime = Time.Now + DriftCooldown;
	}

	protected override void OnDisabled()
	{
		IsDrifting = false;
		DriftDirection = 0;
		DriftTime = 0f;
		ChargeLevel = 0;
		SlipAngle = 0f;
		_currentDriftYawOffset = 0f;
		_isHopping = false;

		if ( DriftEffect.IsValid() )
			DriftEffect.Enabled = false;
	}

	/// <summary>
	/// Spawn the boost payout effect on every machine (also used by the boost pickup item).
	/// </summary>
	[Rpc.Broadcast( NetFlags.OwnerOnly )]
	public void SpawnBoostEffect()
	{
		if ( !BoostEffectPrefab.IsValid() )
			return;

		var effect = BoostEffectPrefab.Clone( new CloneConfig
		{
			Parent = GameObject,
			StartEnabled = true
		} );

		Invoke( BoostEffectLifetime, effect.Destroy );
	}

	private void SetVelocityAngle( float angle )
	{
		Car.Movement.VelocityAngleOverride = angle;
	}
}