2025-11-21 17:52:25 +07:00

664 lines
18 KiB
C#

using Sandbox;
using Sandbox.Audio;
using System;
using System.Collections.Generic;
using System.Linq;
using VeloX.Utils;
using static Sandbox.VertexLayout;
using static VeloX.EngineStream;
namespace VeloX;
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;
[Sync] private float _rpmPercent { get; set; }
/// <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 Task.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 );
}
public void UpdateEngine( in float dt )
{
StreamEngineUpdate( dt, WorldPosition );
if ( IsProxy )
return;
// Cache values
_userThrottleInput = _userThrottleInput.LerpTo( Controller.SwappedThrottle, 0.05f );
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 = CalculateTorqueICE( 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 Task.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 = angVelPercent * 3f * -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;
}
[Property] public EngineStream Stream { get; set; }
public bool EngineSoundPaused => !IsRunning;
private float _wobbleTime;
private static readonly Mixer EngineMixer = Mixer.FindMixerByName( "Car Engine" );
private readonly Dictionary<Layer, SoundHandle> EngineSounds = [];
protected override void OnDestroy()
{
base.OnDestroy();
foreach ( var item in EngineSounds.Values )
item.Dispose();
}
public void StreamEngineUpdate( float deltaTime, Vector3 position, bool isLocal = false )
{
var globalPitch = 1.0f;
// Gear wobble effect
if ( _wobbleTime > 0 )
{
_wobbleTime -= deltaTime * (0.1f + ThrottlePosition);
globalPitch += MathF.Cos( _wobbleTime * Stream.Parameters.WobbleFrequency ) * _wobbleTime * (1 - _wobbleTime) * Stream.Parameters.WobbleStrength;
}
globalPitch *= Stream.Parameters.Pitch;
// Redline effect
var redlineVolume = 1.0f;
if ( RPMPercent >= 0.95f )
{
redlineVolume = 1 - Stream.Parameters.RedlineStrength +
MathF.Cos( Time.Now * Stream.Parameters.RedlineFrequency ) *
Stream.Parameters.RedlineStrength;
}
// Process layers
foreach ( var (id, layer) in Stream.Layers )
{
EngineSounds.TryGetValue( layer, out var channel );
if ( !channel.IsValid() )
{
channel = Sound.PlayFile( layer.AudioPath );
EngineSounds[layer] = channel;
}
if ( channel.Paused && EngineSoundPaused )
continue;
// Reset controller outputs
float layerVolume = 1.0f;
float layerPitch = 1.0f;
// Apply all controllers
foreach ( var controller in layer.Controllers )
{
var inputValue = controller.InputParameter switch
{
Audio.Controller.InputTypes.Throttle => _userThrottleInput,
Audio.Controller.InputTypes.RpmFraction => RPMPercent,
_ => 0.0f
};
var normalized = Math.Clamp( inputValue, controller.InputRange.Min, controller.InputRange.Max );
var outputValue = controller.InputRange.Remap(
normalized,
controller.OutputRange.Min,
controller.OutputRange.Max
);
// Apply to correct parameter
switch ( controller.OutputParameter )
{
case Audio.Controller.OutputTypes.Volume:
layerVolume *= outputValue;
break;
case Audio.Controller.OutputTypes.Pitch:
layerPitch *= outputValue;
break;
}
}
// Apply redline effect if needed
layerVolume *= layer.UseRedline ? redlineVolume : 1.0f;
layerPitch *= globalPitch;
// Update audio channel
channel.Pitch = layerPitch;
channel.Volume = layerVolume * Stream.Parameters.Volume;
channel.Position = position;
channel.ListenLocal = isLocal;
channel.Paused = EngineSoundPaused;
channel.TargetMixer = EngineMixer;
}
}
}