Player/Car/Gameplay/CarDrift.cs

Component that implements car drifting mechanics. It handles entering/exiting drifts, a hop phase, tracking drift time, charge levels, slip angle, applying yaw/velocity adjustments, awarding a speed boost and spawning a boost visual effect.

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 * 22.5f;
		_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 = 300f * 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;
	}
}