Code/Vehicle/Powertrain/Engine.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Meteor.VehicleTool.Vehicle.Powertrain;

public class Engine : PowertrainComponent, IScenePhysicsEvents
{
	protected override void OnAwake()
	{
		base.OnAwake();
		Name ??= "Engine";
		UpdatePeakPowerAndTorque();

	}

	[Hide] public new bool Input { get; set; }

	public delegate float CalculateTorque( float angularVelocity, float dt );
	/// <summary>
	///     Delegate for a function that modifies engine power.
	/// </summary>
	public delegate float PowerModifier();

	public enum EngineType
	{
		ICE,
		Electric,
	}
	/// <summary>
	///     If true starter will be ran for [starterRunTime] seconds if engine receives any throttle input.
	/// </summary>
	[Property] public bool AutoStartOnThrottle { get; set; } = true;

	/// <summary>
	///     Assign your own delegate to use different type of torque calculation.
	/// </summary>
	public CalculateTorque CalculateTorqueDelegate;


	/// <summary>
	///     Engine type. ICE (Internal Combustion Engine) supports features such as starter, stalling, etc.
	///     Electric engine (motor) can run in reverse, can not be stalled and does not use starter.
	/// </summary>
	[Property] public EngineType Type { get; set; } = EngineType.ICE;

	/// <summary>
	///     Power generated by the engine in kW
	/// </summary>
	public float generatedPower;


	/// <summary>
	///     RPM at which idler circuit will try to keep RPMs when there is no input.
	/// </summary>
	[Property] public float IdleRPM { get; set; } = 900;

	/// <summary>
	///     Maximum engine power in [kW].
	/// </summary>
	[Property, Group( "Power" )] public float MaxPower { get; set; } = 120;

	/// <summary>
	/// Loss power (pumping, friction losses) is calculated as the percentage of maxPower.
	/// Should be between 0 and 1 (100%).
	/// </summary>
	[Range( 0, 1 ), Property] public float EngineLossPercent { get; set; } = 0.8f;


	/// <summary>
	///     If true the engine will be started immediately, without running the starter, when the vehicle is enabled.
	///     Sets engine angular velocity to idle angular velocity.
	/// </summary>
	[Property] public bool FlyingStartEnabled { get; set; }

	[Property] public bool Ignition { get; set; }

	/// <summary>
	///     Power curve with RPM range [0,1] on the X axis and power coefficient [0,1] on Y axis.
	///     Both values are represented as percentages and should be in 0 to 1 range.
	///     Power coefficient is multiplied by maxPower to get the final power at given RPM.
	/// </summary>
	[Property, Group( "Power" )] public Curve PowerCurve { get; set; } = new( new List<Curve.Frame>() { new( 0, 0.4f ), new( 0.16f, 0.65f ), new( 0.32f, 0.85f ), new( 0.64f, 1f ), new( 0.8f, 0.9f ), new( 0.9f, 0.6f ), new( 1f, 0.2f ) } );

	/// <summary>
	///     Is the engine currently hitting the rev limiter?
	/// </summary>
	public bool RevLimiterActive;

	/// <summary>
	///     If engine RPM rises above revLimiterRPM, how long should fuel cutoff last?
	///     Higher values make hitting rev limiter more rough and choppy.
	/// </summary>
	[Property] public float RevLimiterCutoffDuration { get; set; } = 0.12f;

	/// <summary>
	///     Engine RPM at which rev limiter activates.
	/// </summary>
	[Property] public float RevLimiterRPM { get; set; } = 6700;

	/// <summary>
	///     Is the starter currently active?
	/// </summary>
	[Property, ReadOnly, Group( "Info" )] public bool StarterActive = false;

	/// <summary>
	///     Torque starter motor can put out. Make sure that this torque is more than loss torque
	///     at the starter RPM limit. If too low the engine will fail to start.
	/// </summary>
	[Property] public float StartDuration = 0.5f;


	/// <summary>
	/// Peak power as calculated from the power curve. If the power curve peaks at Y=1 peak power will equal max power field value.
	/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
	/// </summary>
	[Property, ReadOnly, Group( "Info" )]
	public float EstimatedPeakPower => _peakPower;
	private float _peakPower;



	/// <summary>
	/// RPM at which the peak power is achieved.
	/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
	/// </summary>
	[Property, ReadOnly, Group( "Info" )]
	public float EstimatedPeakPowerRPM => _peakPowerRpm;

	private float _peakPowerRpm;

	/// <summary>
	/// Peak torque value as calculated from the power curve.
	/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
	/// </summary>
	[Property, ReadOnly, Group( "Info" )]
	public float EstimatedPeakTorque => _peakTorque;

	private float _peakTorque;

	/// <summary>
	/// RPM at which the engine achieves the peak torque, calculated from the power curve.
	/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
	/// </summary>
	[Property, ReadOnly, Group( "Info" )]
	public float EstimatedPeakTorqueRPM => _peakTorqueRpm;

	private float _peakTorqueRpm;

	/// <summary>
	///     RPM as a percentage of maximum RPM.
	/// </summary>
	[Property, ReadOnly, Group( "Info" )]
	public float RPMPercent => _rpmPercent;

	private float _rpmPercent;
	/// <summary>
	///     Engine throttle position. 0 for no throttle and 1 for full throttle.
	/// </summary>
	[Property, ReadOnly, Group( "Info" )]
	public float ThrottlePosition { get; private set; }

	/// <summary>
	/// Is the engine currently running?
	/// Requires ignition to be enabled.
	/// </summary>
	[Property, ReadOnly, Group( "Info" )]
	public bool IsRunning { get; private set; }

	/// <summary>
	/// Is the engine currently running?
	/// Requires ignition to be enabled.
	/// </summary>
	[Property, ReadOnly, Group( "Info" )]
	public bool IsActive { get; private set; }

	private float _idleAngularVelocity;



	/// <summary>
	/// Current load of the engine, based on the power produced.
	/// </summary>
	public float Load => _load;
	public event Action OnEngineStart;
	public event Action OnEngineStop;
	public event Action OnRevLimiter;

	private float _load;


	protected override void OnStart()
	{
		base.OnStart();

		if ( Type == EngineType.ICE )
		{
			CalculateTorqueDelegate = CalculateTorqueICE;
		}
		else if ( Type == EngineType.Electric )
		{
			IdleRPM = 0f;
			FlyingStartEnabled = true;
			CalculateTorqueDelegate = CalculateTorqueElectric;
			StarterActive = false;
			StartDuration = 0.001f;
			RevLimiterCutoffDuration = 0f;
		}
	}

	public void StartEngine()
	{
		if ( IsRunning ) return;

		Ignition = true;
		OnEngineStart?.Invoke();

		if ( Type != EngineType.Electric )
		{
			if ( FlyingStartEnabled )
			{
				FlyingStart();
			}
			else if ( !StarterActive && Controller != null )
			{
				StarterCoroutine();
			}
		}
	}
	private async void StarterCoroutine()
	{
		if ( Type == EngineType.Electric || StarterActive )
			return;

		try
		{
			float startTimer = 0f;
			StarterActive = true;

			// Ensure safe start duration
			StartDuration = Math.Max( 0.1f, StartDuration );

			_starterTorque = ((_idleAngularVelocity - OutputAngularVelocity) * Inertia) / StartDuration;

			while ( startTimer <= StartDuration && StarterActive )
			{
				startTimer += 0.1f;
				await GameTask.DelaySeconds( 0.1f );
			}
		}
		finally
		{
			_starterTorque = 0;
			StarterActive = false;
			IsActive = true;
		}
	}


	private void FlyingStart()
	{
		Ignition = true;
		StarterActive = false;
		OutputAngularVelocity = IdleRPM.RPMToAngularVelocity();
	}

	public void StopEngine()
	{
		Ignition = false;
		IsActive = true;
		OnEngineStop?.Invoke();
	}


	/// <summary>
	/// Toggles engine state.
	/// </summary>
	public void StartStopEngine()
	{
		if ( IsRunning )
			StopEngine();
		else
			StartEngine();
	}

	public void UpdatePeakPowerAndTorque()
	{
		GetPeakPower( out _peakPower, out _peakPowerRpm );
		GetPeakTorque( out _peakTorque, out _peakTorqueRpm );
	}

	protected override void OnFixedUpdate()
	{
		float dt = Time.Delta;

		// Cache values
		_userThrottleInput = Controller.SwappedThrottle;
		ThrottlePosition = _userThrottleInput;
		_idleAngularVelocity = IdleRPM.RPMToAngularVelocity();
		_revLimiterAngularVelocity = RevLimiterRPM.RPMToAngularVelocity();
		if ( _revLimiterAngularVelocity == 0f )
			return;
		// Check for start on throttle
		if ( !IsRunning && !StarterActive && AutoStartOnThrottle && ThrottlePosition > 0.2f )
			StartEngine();


		bool wasRunning = IsRunning;
		IsRunning = Ignition;
		if ( wasRunning && !IsRunning )
			StopEngine();

		// Physics update
		if ( OutputNameHash == 0 )
			return;

		float drivetrainInertia = _output.QueryInertia();
		float inertiaSum = Inertia + drivetrainInertia;
		if ( inertiaSum == 0 )
			return;

		float drivetrainAngularVelocity = QueryAngularVelocity( OutputAngularVelocity, dt );
		float targetAngularVelocity = Inertia / inertiaSum * OutputAngularVelocity + drivetrainInertia / inertiaSum * drivetrainAngularVelocity;

		// Calculate generated torque and power
		float generatedTorque = CalculateTorqueDelegate( OutputAngularVelocity, dt );
		generatedPower = TorqueToPowerInKW( in OutputAngularVelocity, in generatedTorque );

		// Calculate reaction torque
		float reactionTorque = (targetAngularVelocity - OutputAngularVelocity) * Inertia / dt;

		// Calculate/get torque returned from wheels
		OutputTorque = generatedTorque - reactionTorque;
		float returnTorque = ForwardStep( OutputTorque, 0, dt );
		float totalTorque = generatedTorque + returnTorque + reactionTorque;
		OutputAngularVelocity += totalTorque / inertiaSum * dt;

		// Clamp the angular velocity to prevent any powertrain instabilities over the limits
		OutputAngularVelocity = Math.Clamp( OutputAngularVelocity, 0, _revLimiterAngularVelocity * 1.05f );

		// Calculate cached values
		_rpmPercent = Math.Clamp( OutputAngularVelocity / _revLimiterAngularVelocity, 0, 1 );
		_load = Math.Clamp( generatedPower / MaxPower, 0, 1 );
	}

	private float _starterTorque;
	private float _revLimiterAngularVelocity;
	private float _userThrottleInput;

	private async void RevLimiter()
	{
		if ( RevLimiterActive || Type == EngineType.Electric || RevLimiterCutoffDuration == 0 )
			return;

		RevLimiterActive = true;
		OnRevLimiter?.Invoke();
		await GameTask.DelayRealtimeSeconds( RevLimiterCutoffDuration );
		RevLimiterActive = false;
	}


	/// <summary>
	///     Calculates torque for electric engine type.
	/// </summary>
	public float CalculateTorqueElectric( float angularVelocity, float dt )
	{
		float absAngVel = Math.Abs( angularVelocity );

		// Avoid angular velocity spikes while shifting
		if ( Controller.Transmission.IsShifting )
			ThrottlePosition = 0;

		float maxLossPower = MaxPower * 0.3f;
		float lossPower = maxLossPower * (1f - ThrottlePosition) * RPMPercent;
		float genPower = MaxPower * ThrottlePosition;
		float totalPower = genPower - lossPower;
		totalPower = MathX.Lerp( totalPower * 0.1f, totalPower, RPMPercent * 10f );
		float clampedAngVel = absAngVel < 10f ? 10f : absAngVel;
		return PowerInKWToTorque( clampedAngVel, totalPower );
	}


	/// <summary>
	/// Calculates torque for ICE (Internal Combustion Engine).
	/// </summary>
	public float CalculateTorqueICE( float angularVelocity, float dt )
	{
		// Set the throttle to 0 when shifting, but avoid doing so around idle RPM to prevent stalls.
		if ( Controller.Transmission.IsShifting && angularVelocity > _idleAngularVelocity )
			ThrottlePosition = 0f;

		// Set throttle to 0 when starter active.
		if ( StarterActive )
			ThrottlePosition = 0f;
		// Apply idle throttle correction to keep the engine running
		else
			ApplyICEIdleCorrection();

		// Trigger rev limiter if needed
		if ( angularVelocity >= _revLimiterAngularVelocity && !RevLimiterActive )
			RevLimiter();

		// Calculate torque
		float generatedTorque;

		// Do not generate any torque while starter is active to prevent RPM spike during startup
		// or while stalled to prevent accidental starts.
		if ( StarterActive )
			generatedTorque = 0f;
		else
			generatedTorque = CalculateICEGeneratedTorqueFromPowerCurve();

		float lossTorque = (StarterActive || ThrottlePosition > 0.2f) ? 0f : CalculateICELossTorqueFromPowerCurve();

		// Reduce the loss torque at rev limiter, but allow it to be >0 to prevent vehicle getting
		// stuck at rev limiter.
		if ( RevLimiterActive )
			lossTorque *= 0.25f;
		generatedTorque += _starterTorque + lossTorque;
		return generatedTorque;
	}

	private float CalculateICELossTorqueFromPowerCurve()
	{
		// Avoid issues with large torque spike around 0 angular velocity.
		if ( OutputAngularVelocity < 10f )
			return -OutputAngularVelocity * MaxPower * 0.03f;

		float angVelPercent = OutputAngularVelocity < 10f ? 0.1f : Math.Clamp( OutputAngularVelocity, 0, _revLimiterAngularVelocity ) / _revLimiterAngularVelocity;

		float lossPower = PowerCurve.Evaluate( angVelPercent ) * -MaxPower * Math.Clamp( _userThrottleInput + 0.5f, 0, 1 ) * EngineLossPercent;

		return PowerInKWToTorque( OutputAngularVelocity, lossPower );
	}

	private void ApplyICEIdleCorrection()
	{
		if ( Ignition && OutputAngularVelocity < _idleAngularVelocity * 1.1f )
		{
			// Apply a small correction to account for the error since the throttle is applied only
			// if the idle RPM is below the target RPM.
			float idleCorrection = _idleAngularVelocity * 1.08f - OutputAngularVelocity;
			idleCorrection = idleCorrection < 0f ? 0f : idleCorrection;
			float idleThrottlePosition = Math.Clamp( idleCorrection * 0.01f, 0, 1 );
			ThrottlePosition = Math.Max( _userThrottleInput, idleThrottlePosition );
		}
	}

	private float CalculateICEGeneratedTorqueFromPowerCurve()
	{
		generatedPower = 0;
		float torque = 0;

		if ( !Ignition && !StarterActive )
			return 0;
		if ( RevLimiterActive )
			ThrottlePosition = 0.2f;
		else
		{
			// Add maximum losses to the maximum power when calculating the generated power since the maxPower is net value (after losses).
			generatedPower = PowerCurve.Evaluate( _rpmPercent ) * (MaxPower * (1f + EngineLossPercent)) * ThrottlePosition;
			torque = PowerInKWToTorque( OutputAngularVelocity, generatedPower );
		}
		return torque;
	}


	public void GetPeakTorque( out float peakTorque, out float peakTorqueRpm )
	{
		peakTorque = 0;
		peakTorqueRpm = 0;

		for ( float i = 0.05f; i < 1f; i += 0.05f )
		{
			float rpm = i * RevLimiterRPM;
			float P = PowerCurve.Evaluate( i ) * MaxPower;
			if ( rpm < IdleRPM )
			{
				continue;
			}

			float W = rpm.RPMToAngularVelocity();
			float T = (P * 1000f) / W;

			if ( T > peakTorque )
			{
				peakTorque = T;
				peakTorqueRpm = rpm;
			}
		}
	}

	public void GetPeakPower( out float peakPower, out float peakPowerRpm )
	{
		float maxY = 0f;
		float maxX = 1f;
		for ( float i = 0f; i < 1f; i += 0.05f )
		{
			float y = PowerCurve.Evaluate( i );
			if ( y > maxY )
			{
				maxY = y;
				maxX = i;
			}
		}

		peakPower = maxY * MaxPower;
		peakPowerRpm = maxX * RevLimiterRPM;
	}



	[Property, Group("Parsing")] string Clipboard { get; set; }
	[Button, Group( "Parsing" )]
	public void FromLUT()
	{
		if ( Clipboard == null )
			return;

		using var undo = Scene.Editor?.UndoScope( "From LUT" ).WithComponentChanges( this ).Push();

		var data = new List<(float RPM, float PowerHP)>();

		var lines = Clipboard.Split( ['\n', '\r'], StringSplitOptions.RemoveEmptyEntries );

		foreach ( var line in lines )
		{
			var parts = line.Split( '|' );
			if ( parts.Length == 2 &&
				float.TryParse( parts[0], out float rpm ) &&
				float.TryParse( parts[1], out float powerHP ) )
			{
				data.Add( (rpm, powerHP) );
			}
		}

		RevLimiterRPM = data.Max( x => x.RPM );
		MaxPower = data.Max( x => x.PowerHP ) * 0.746f;

		var frames = data.Select( d =>
			new Curve.Frame(
				d.RPM / RevLimiterRPM,
				(d.PowerHP * 0.746f) / MaxPower
			)
		).ToList();
		PowerCurve = new( frames );
		Clipboard = null;
	}

}