Vehicle/VehicleController.Engine.cs
using Sandbox;

namespace Bugges.VehicleController;

partial class VehicleController : Component
{
	public const string ENGINE = "Engine";
	public const string RPM = "RPM";

	[Order( -9 )]
	[Property, Feature( ENGINE ), ReadOnly]
	public float Kmh
	{
		get
		{
			Vector3 forward = WorldTransform.Forward;
			Vector3 velocity = Body.Velocity;
			float msToKmh = 1f / 100.0f * 3.6f;
			float ms = Vector3.Dot( forward, velocity );
			float kmh = ms * msToKmh;
			return kmh;
		}
	}

	[Property( Title = "Top Speed" ), Feature( ENGINE )]
	private float Engine_TopSpeed { get; set; } = 3600.0f;

	[Property( Title = "Torque" ), Feature( ENGINE )]
	private float Engine_Torque { get; set; } = 20000.0f;

	[Property( Title = "Start Delay" ), Feature( ENGINE )]
	private float Engine_StartDelay { get; set; } = 0.5f;

	[Property( Title = "Idle RPM" ), Feature( ENGINE ), Group( RPM )]
	public float Engine_IdleRPM { get; set; } = 800f;

	[Property( Title = "Low RPM" ), Feature( ENGINE ), Group( RPM )]
	public float Engine_LowRPM { get; set; } = 1000f;

	[Property( Title = "High RPM" ), Feature( ENGINE ), Group( RPM )]
	public float Engine_HighRPM { get; set; } = 2000f;

	[Property( Title = "Max RPM" ), Feature( ENGINE ), Group( RPM )]
	public float Engine_MaxRPM { get; set; } = 7000f;

	[Property( Title = "Smoothing" ), Feature( ENGINE ), Group( RPM ), Description( "How quickly the engine's audio RPM catches up to the actual wheel RPM" )]
	public float Engine_RPMSmoothing { get; set; } = 8f;

	[Property, Feature( ENGINE ), Group( RPM ), ReadOnly]
	public float CurrentRPM { get; private set; }

	private bool _isEngineOn = false;
	[Property, Feature( ENGINE ), ReadOnly]
	public bool IsEngineOn
	{
		get => _isEngineOn;
		private set
		{
			bool isSameAsValue = _isEngineOn == value;
			if ( isSameAsValue ) return;

			_isEngineOn = value;
			OnEngineStateChanged?.Invoke( value );

			if ( value ) OnEngineStart?.Invoke();
			else OnEngineStop?.Invoke();
		}
	}


	private void ApplyAcceleration( Wheel wheel, SceneTraceResult trace )
	{
		bool isAccelerating = true;
		if ( !trace.Hit ) isAccelerating = false;
		if ( !IsEngineOn ) isAccelerating = false;
		if ( !wheel.IsMotor ) isAccelerating = false;
		if ( IsBraking() ) isAccelerating = false;
		if ( Gear_Current == Gears.Neutral ) isAccelerating = false;

		wheel.IsAccelerating = isAccelerating;
		if ( !isAccelerating ) return;

		float accelerateInput = AccelerateInput;
		float absAccelerateInput = float.Abs( accelerateInput );
		if ( absAccelerateInput <= 0.1f ) return;

		Vector3 contactPoint = GetWheelContactPoint( wheel, trace );
		Vector3 accelDir = wheel.Forward;
		float gearMultiplier = Gear_CurrentRatio * Gear_FinalDriveRatio;
		float availableTorque = accelerateInput * Engine_Torque * float.Abs( gearMultiplier );
		Body.ApplyForceAt( contactPoint, accelDir * availableTorque );
	}

	public bool IsAccelerating()
	{
		if ( !IsEngineOn ) return false;
		if ( IsBraking() ) return false;
		if ( Gear_Current == Gears.Neutral ) return false;

		float absAccelInput = float.Abs( AccelerateInput );
		if ( absAccelInput <= 0.1f ) return false;

		return true;
	}

	private void UpdateEngine()
	{
		UpdateEngineRPM();

		if ( !EngineInput ) return;
		if ( IsEngineOn ) StopEngine();
		else StartEngine();
	}

	private async void StartEngine()
	{
		await Task.DelaySeconds( Engine_StartDelay );
		IsEngineOn = true;
	}

	private void StopEngine() => IsEngineOn = false;

	private void UpdateEngineRPM()
	{
		if ( !IsEngineOn )
		{
			CurrentRPM = 0f;
			return;
		}

		float targetRpm = CalculateTargetRPM();
		CurrentRPM += (targetRpm - CurrentRPM) * Time.Delta * 5f;
		CurrentRPM = float.Clamp( CurrentRPM, Engine_IdleRPM, Engine_MaxRPM );
	}

	private float CalculateTargetRPM()
	{
		float absAccelerateInput = float.Abs( AccelerateInput );
		bool inGear = Gear_Current == Gears.Drive || Gear_Current == Gears.Reverse;

		if ( !inGear )
			return Engine_IdleRPM + (absAccelerateInput * (Engine_MaxRPM - Engine_IdleRPM));

		float avgMotorSpeed = GetAverageDrivenWheelRPS( out bool anyGrounded );
		if ( !anyGrounded )
			return Engine_IdleRPM + (absAccelerateInput * (Engine_MaxRPM - Engine_IdleRPM));

		float driveAxleRpm = avgMotorSpeed * 60f;
		float targetRpm = float.Clamp( float.Abs( driveAxleRpm * Gear_FinalDriveRatio * Gear_CurrentRatio ), Engine_IdleRPM, Engine_MaxRPM );

		// Add a slight RPM bump when pressing the gas while in gear, so you hear the engine working under load
		targetRpm += absAccelerateInput * 800f;
		return float.Min( targetRpm, Engine_MaxRPM );
	}
}