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