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 ); /// /// Delegate for a function that modifies engine power. /// public delegate float PowerModifier(); public enum EngineType { ICE, Electric, } /// /// If true starter will be ran for [starterRunTime] seconds if engine receives any throttle input. /// [Property] public bool AutoStartOnThrottle { get; set; } = true; /// /// Assign your own delegate to use different type of torque calculation. /// public CalculateTorque CalculateTorqueDelegate; /// /// 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. /// [Property] public EngineType Type { get; set; } = EngineType.ICE; /// /// Power generated by the engine in kW /// public float generatedPower; /// /// RPM at which idler circuit will try to keep RPMs when there is no input. /// [Property] public float IdleRPM { get; set; } = 900; /// /// Maximum engine power in [kW]. /// [Property, Group( "Power" )] public float MaxPower { get; set; } = 120; /// /// Loss power (pumping, friction losses) is calculated as the percentage of maxPower. /// Should be between 0 and 1 (100%). /// [Range( 0, 1 ), Property] public float EngineLossPercent { get; set; } = 0.8f; /// /// 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. /// [Property] public bool FlyingStartEnabled { get; set; } [Property] public bool Ignition { get; set; } /// /// 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. /// [Property, Group( "Power" )] public Curve PowerCurve { get; set; } = new( new List() { 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 ) } ); /// /// Is the engine currently hitting the rev limiter? /// public bool RevLimiterActive; /// /// If engine RPM rises above revLimiterRPM, how long should fuel cutoff last? /// Higher values make hitting rev limiter more rough and choppy. /// [Property] public float RevLimiterCutoffDuration { get; set; } = 0.12f; /// /// Engine RPM at which rev limiter activates. /// [Property] public float RevLimiterRPM { get; set; } = 6700; /// /// Is the starter currently active? /// [Property, ReadOnly, Group( "Info" )] public bool StarterActive = false; /// /// 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. /// [Property] public float StartDuration = 0.5f; /// /// 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. /// [Property, ReadOnly, Group( "Info" )] public float EstimatedPeakPower => _peakPower; private float _peakPower; /// /// RPM at which the peak power is achieved. /// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value. /// [Property, ReadOnly, Group( "Info" )] public float EstimatedPeakPowerRPM => _peakPowerRpm; private float _peakPowerRpm; /// /// Peak torque value as calculated from the power curve. /// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value. /// [Property, ReadOnly, Group( "Info" )] public float EstimatedPeakTorque => _peakTorque; private float _peakTorque; /// /// 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. /// [Property, ReadOnly, Group( "Info" )] public float EstimatedPeakTorqueRPM => _peakTorqueRpm; private float _peakTorqueRpm; /// /// RPM as a percentage of maximum RPM. /// [Property, ReadOnly, Group( "Info" )] public float RPMPercent => _rpmPercent; [Sync] private float _rpmPercent { get; set; } /// /// Engine throttle position. 0 for no throttle and 1 for full throttle. /// [Property, ReadOnly, Group( "Info" )] public float ThrottlePosition { get; private set; } /// /// Is the engine currently running? /// Requires ignition to be enabled. /// [Property, ReadOnly, Group( "Info" )] public bool IsRunning { get; private set; } /// /// Is the engine currently running? /// Requires ignition to be enabled. /// [Property, ReadOnly, Group( "Info" )] public bool IsActive { get; private set; } private float _idleAngularVelocity; /// /// Current load of the engine, based on the power produced. /// 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(); } /// /// Toggles engine state. /// 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; } /// /// Calculates torque for electric engine type. /// 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 ); } /// /// Calculates torque for ICE (Internal Combustion Engine). /// 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 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; } } }