diff --git a/Assets/effects/metal_impact.prefab b/Assets/effects/metal_impact.prefab
index 02f8ece..860805d 100644
--- a/Assets/effects/metal_impact.prefab
+++ b/Assets/effects/metal_impact.prefab
@@ -18,7 +18,7 @@
"__type": "Sandbox.ParticleEffect",
"__guid": "4e3d505f-af05-4c8f-85ee-c5a264ff23b6",
"__enabled": true,
- "__version": 1,
+ "__version": 3,
"Alpha": {
"Type": "Curve",
"Evaluation": "Life",
@@ -46,8 +46,7 @@
"out": 0,
"mode": "Mirrored"
}
- ],
- "Constants": "1,0,0,0"
+ ]
},
"ApplyAlpha": false,
"ApplyColor": true,
@@ -63,6 +62,11 @@
"CollisionPrefabChance": 1,
"CollisionPrefabRotation": 0,
"CollisionRadius": 1,
+ "ConstantMovement": {
+ "X": 0,
+ "Y": 0,
+ "Z": 0
+ },
"Damping": 0,
"DieOnCollisionChance": 0,
"FollowerPrefab": [],
@@ -124,29 +128,17 @@
"ConstantA": "1,1,1,1",
"ConstantB": "1,0,0,1"
},
+ "InitialVelocity": {
+ "X": 0,
+ "Y": 0,
+ "Z": 0
+ },
"Lifetime": {
"Type": "Range",
- "Evaluation": "Particle",
- "CurveA": [
- {
- "x": 0.5,
- "y": 0.5,
- "in": 0,
- "out": 0,
- "mode": "Mirrored"
- }
- ],
- "CurveB": [
- {
- "x": 0.5,
- "y": 0.5,
- "in": 0,
- "out": 0,
- "mode": "Mirrored"
- }
- ],
+ "Evaluation": "Seed",
"Constants": "0.1,2,0,0"
},
+ "LocalSpace": 0,
"MaxParticles": 500,
"OnComponentDestroy": null,
"OnComponentDisabled": null,
@@ -166,29 +158,7 @@
"Pitch": 0,
"PreWarm": 0,
"PushStrength": 0,
- "Roll": {
- "Type": "Range",
- "Evaluation": "Particle",
- "CurveA": [
- {
- "x": 0.5,
- "y": 0.5,
- "in": 0,
- "out": 0,
- "mode": "Mirrored"
- }
- ],
- "CurveB": [
- {
- "x": 0.5,
- "y": 0.5,
- "in": 0,
- "out": 0,
- "mode": "Mirrored"
- }
- ],
- "Constants": "0,360,0,0"
- },
+ "Roll": 0,
"Scale": {
"Type": "Curve",
"Evaluation": "Life",
@@ -230,36 +200,16 @@
"out": 0,
"mode": "Mirrored"
}
- ],
- "Constants": "1,0,0,0"
+ ]
},
"SequenceId": 0,
"SequenceSpeed": 1,
"SequenceTime": 1,
"SheetSequence": false,
- "Space": "World",
"StartDelay": 0,
"StartVelocity": {
"Type": "Range",
- "Evaluation": "Particle",
- "CurveA": [
- {
- "x": 0.5,
- "y": 0.5,
- "in": 0,
- "out": 0,
- "mode": "Mirrored"
- }
- ],
- "CurveB": [
- {
- "x": 0.5,
- "y": 0.5,
- "in": 0,
- "out": 0,
- "mode": "Mirrored"
- }
- ],
+ "Evaluation": "Seed",
"Constants": "10,70,0,0"
},
"Stretch": 0,
@@ -267,7 +217,11 @@
"Timing": "GameTime",
"Tint": "1,1,1,1",
"UsePrefabFeature": false,
- "Yaw": 0
+ "Yaw": {
+ "Type": "Range",
+ "Evaluation": "Seed",
+ "Constants": "0,360,0,0"
+ }
},
{
"__type": "Sandbox.ParticleSpriteRenderer",
@@ -302,7 +256,8 @@
"Scale": 1,
"Shadows": false,
"SortMode": "Unsorted",
- "Texture": "materials/particles/shapes/spark2.vtex"
+ "Texture": "textures/shapes/spark3.vtex",
+ "TextureFilter": "Bilinear"
},
{
"__type": "Sandbox.ParticleConeEmitter",
diff --git a/Assets/frictions/Michelin.tire b/Assets/frictions/Michelin.tire
new file mode 100644
index 0000000..214b05f
--- /dev/null
+++ b/Assets/frictions/Michelin.tire
@@ -0,0 +1,20 @@
+{
+ "Pacejka": {
+ "Lateral": {
+ "B": 16,
+ "C": 1.5100001,
+ "D": 2,
+ "E": -0.8
+ },
+ "Longitudinal": {
+ "B": 16,
+ "C": 1.3,
+ "D": 1.2,
+ "E": 0.5
+ }
+ },
+ "RollResistanceLin": 0.001,
+ "RollResistanceQuad": 1E-06,
+ "__references": [],
+ "__version": 0
+}
\ No newline at end of file
diff --git a/Assets/frictions/default.tire b/Assets/frictions/default.tire
index c64e7a2..97ba89f 100644
--- a/Assets/frictions/default.tire
+++ b/Assets/frictions/default.tire
@@ -1,20 +1,8 @@
{
- "Pacejka": {
- "Lateral": {
- "B": 12,
- "C": 1.3,
- "D": 1.8,
- "E": -1.8
- },
- "Longitudinal": {
- "B": 10.86,
- "C": 2.15,
- "D": 2,
- "E": 0.992
- }
- },
- "RollResistanceLin": 0.001,
- "RollResistanceQuad": 1E-06,
+ "B": 8.36,
+ "C": 2.15,
+ "D": 0.833,
+ "E": 0.992,
"__references": [],
"__version": 0
}
\ No newline at end of file
diff --git a/Assets/prefabs/particles/tire_smoke.prefab b/Assets/prefabs/particles/tire_smoke.prefab
new file mode 100644
index 0000000..a4000ed
--- /dev/null
+++ b/Assets/prefabs/particles/tire_smoke.prefab
@@ -0,0 +1,295 @@
+{
+ "RootObject": {
+ "__guid": "67db5230-4e08-4970-ab3c-df73f37dd486",
+ "__version": 1,
+ "Flags": 0,
+ "Name": "tire_smoke",
+ "Position": "0,0,0",
+ "Rotation": "0,0,0,1",
+ "Scale": "1,1,1",
+ "Tags": "",
+ "Enabled": true,
+ "NetworkMode": 2,
+ "NetworkInterpolation": true,
+ "NetworkOrphaned": 0,
+ "OwnerTransfer": 1,
+ "Components": [],
+ "Children": [
+ {
+ "__guid": "83d2bd2a-7d1d-466c-a1a7-cb4771a95da5",
+ "__version": 1,
+ "Flags": 0,
+ "Name": "Smoke",
+ "Position": "0,0,0",
+ "Rotation": "0,0,0,1",
+ "Scale": "1,1,1",
+ "Tags": "particles",
+ "Enabled": true,
+ "NetworkMode": 2,
+ "NetworkInterpolation": true,
+ "NetworkOrphaned": 0,
+ "OwnerTransfer": 1,
+ "Components": [
+ {
+ "__type": "Sandbox.ParticleEffect",
+ "__guid": "6db5eadc-45c0-482f-8da3-f29b79b84cfe",
+ "__enabled": true,
+ "__version": 3,
+ "Alpha": {
+ "Type": "Curve",
+ "Evaluation": "Life",
+ "CurveA": [
+ {
+ "x": 0,
+ "y": 0,
+ "in": -4.0000024,
+ "out": 4.0000024,
+ "mode": "Mirrored"
+ },
+ {
+ "x": 0.20812808,
+ "y": 0.475,
+ "in": 0,
+ "out": 0,
+ "mode": "Mirrored"
+ },
+ {
+ "x": 1,
+ "y": 0,
+ "in": 0,
+ "out": 0,
+ "mode": "Mirrored"
+ }
+ ],
+ "CurveB": [
+ {
+ "x": 0.5,
+ "y": 0.5,
+ "in": 0,
+ "out": 0,
+ "mode": "Mirrored"
+ }
+ ]
+ },
+ "ApplyAlpha": true,
+ "ApplyColor": true,
+ "ApplyRotation": true,
+ "ApplyShape": true,
+ "Bounce": 1,
+ "Brightness": 1,
+ "Bumpiness": 0,
+ "Collision": false,
+ "CollisionIgnore": null,
+ "CollisionPrefab": null,
+ "CollisionPrefabAlign": false,
+ "CollisionPrefabChance": 1,
+ "CollisionPrefabRotation": 0,
+ "CollisionRadius": 1,
+ "ConstantMovement": {
+ "X": 0,
+ "Y": 0,
+ "Z": 0
+ },
+ "Damping": 5,
+ "DieOnCollisionChance": 0,
+ "FollowerPrefab": null,
+ "FollowerPrefabChance": 1,
+ "FollowerPrefabKill": true,
+ "Force": true,
+ "ForceDirection": "0,0,220",
+ "ForceScale": 1,
+ "ForceSpace": "World",
+ "Friction": 1,
+ "Gradient": {
+ "Type": "Range",
+ "Evaluation": "Life",
+ "GradientA": {
+ "blend": "Linear",
+ "color": [
+ {
+ "t": 0.5,
+ "c": "1,1,1,1"
+ }
+ ],
+ "alpha": []
+ },
+ "GradientB": {
+ "blend": "Linear",
+ "color": [
+ {
+ "t": 0.5,
+ "c": "1,1,1,1"
+ }
+ ],
+ "alpha": []
+ },
+ "ConstantA": "0.18317,0.18317,0.18317,1",
+ "ConstantB": "1,1,1,1"
+ },
+ "InitialVelocity": {
+ "X": 0,
+ "Y": 0,
+ "Z": 0
+ },
+ "Lifetime": {
+ "Type": "Range",
+ "Evaluation": "Seed",
+ "Constants": "2,3,0,0"
+ },
+ "LocalSpace": 0,
+ "MaxParticles": 25,
+ "OnComponentDestroy": null,
+ "OnComponentDisabled": null,
+ "OnComponentEnabled": null,
+ "OnComponentFixedUpdate": null,
+ "OnComponentStart": null,
+ "OnComponentUpdate": null,
+ "OnParticleCreated": null,
+ "OnParticleDestroyed": null,
+ "OrbitalForce": {
+ "X": 0,
+ "Y": 0,
+ "Z": 0
+ },
+ "OrbitalPull": 0,
+ "PerParticleTimeScale": 1,
+ "Pitch": 0,
+ "PreWarm": 0,
+ "PushStrength": 0,
+ "Roll": 0,
+ "Scale": {
+ "Type": "Range",
+ "Evaluation": "Seed",
+ "Constants": "50,60,0,0"
+ },
+ "SequenceId": 0,
+ "SequenceSpeed": 0.5,
+ "SequenceTime": 1,
+ "SheetSequence": true,
+ "StartDelay": 0.025,
+ "StartVelocity": {
+ "Type": "Range",
+ "Evaluation": "Seed",
+ "Constants": "10,70,0,0"
+ },
+ "Stretch": 0,
+ "TimeScale": 1,
+ "Timing": "GameTime",
+ "Tint": "1,1,1,1",
+ "UsePrefabFeature": false,
+ "Yaw": {
+ "Type": "Range",
+ "Evaluation": "Seed",
+ "Constants": "0,360,0,0"
+ }
+ },
+ {
+ "__type": "Sandbox.ParticleSpriteRenderer",
+ "__guid": "efbb8b5c-15b8-4e3f-ab55-9e7483b1c1f4",
+ "__enabled": true,
+ "__version": 2,
+ "Additive": false,
+ "Alignment": "LookAtCamera",
+ "BlurAmount": 0.5,
+ "BlurOpacity": 0.91,
+ "BlurSpacing": 0.73,
+ "DepthFeather": 0,
+ "FaceVelocity": false,
+ "FogStrength": 1,
+ "LeadingTrail": true,
+ "Lighting": false,
+ "MotionBlur": false,
+ "OnComponentDestroy": null,
+ "OnComponentDisabled": null,
+ "OnComponentEnabled": null,
+ "OnComponentFixedUpdate": null,
+ "OnComponentStart": null,
+ "OnComponentUpdate": null,
+ "Opaque": false,
+ "RenderOptions": {
+ "GameLayer": true,
+ "OverlayLayer": false,
+ "BloomLayer": false,
+ "AfterUILayer": false
+ },
+ "RotationOffset": 0,
+ "Scale": 1,
+ "Shadows": true,
+ "SortMode": "Unsorted",
+ "Sprite": {
+ "$compiler": "embed",
+ "$source": null,
+ "data": {
+ "Animations": [
+ {
+ "Name": "Default",
+ "FrameRate": 15,
+ "Origin": "0.5,0.5",
+ "LoopMode": "Loop",
+ "Frames": [
+ {
+ "Texture": "textures/smoketexturesheet.vtex"
+ }
+ ]
+ }
+ ],
+ "__references": null
+ },
+ "compiled": null
+ },
+ "StartingAnimationName": "Default",
+ "TextureFilter": "Bilinear"
+ },
+ {
+ "__type": "Sandbox.ParticleSphereEmitter",
+ "__guid": "4c00fdfa-a1eb-4ded-a6b2-fdf448823a6a",
+ "__enabled": true,
+ "Burst": 0,
+ "Delay": 0,
+ "DestroyOnEnd": false,
+ "Duration": 5,
+ "Loop": true,
+ "OnComponentDestroy": null,
+ "OnComponentDisabled": null,
+ "OnComponentEnabled": null,
+ "OnComponentFixedUpdate": null,
+ "OnComponentStart": null,
+ "OnComponentUpdate": null,
+ "OnEdge": false,
+ "Radius": 23.6,
+ "Rate": 5,
+ "RateOverDistance": 0,
+ "Velocity": 0
+ }
+ ],
+ "Children": []
+ }
+ ],
+ "__properties": {
+ "NetworkInterpolation": true,
+ "TimeScale": 1,
+ "WantsSystemScene": true,
+ "Metadata": {},
+ "NavMesh": {
+ "Enabled": false,
+ "IncludeStaticBodies": true,
+ "IncludeKeyframedBodies": true,
+ "EditorAutoUpdate": true,
+ "AgentHeight": 64,
+ "AgentRadius": 16,
+ "AgentStepSize": 18,
+ "AgentMaxSlope": 40,
+ "ExcludedBodies": "",
+ "IncludedBodies": ""
+ }
+ },
+ "__variables": []
+ },
+ "ResourceVersion": 2,
+ "ShowInMenu": false,
+ "MenuPath": null,
+ "MenuIcon": null,
+ "DontBreakAsTemplate": false,
+ "__references": [],
+ "__version": 2
+}
\ No newline at end of file
diff --git a/Assets/sounds/tire/skid.wav b/Assets/sounds/tire/skid.wav
new file mode 100644
index 0000000..285861b
Binary files /dev/null and b/Assets/sounds/tire/skid.wav differ
diff --git a/Assets/textures/SmokeTextureSheet.png b/Assets/textures/SmokeTextureSheet.png
new file mode 100644
index 0000000..bc6bac2
Binary files /dev/null and b/Assets/textures/SmokeTextureSheet.png differ
diff --git a/Assets/textures/smoketexturesheet.vtex b/Assets/textures/smoketexturesheet.vtex
new file mode 100644
index 0000000..6196868
--- /dev/null
+++ b/Assets/textures/smoketexturesheet.vtex
@@ -0,0 +1,18 @@
+{
+ "Images": null,
+ "Sequences": [
+ {
+ "Source": "textures/smoketexturesheet.png",
+ "IsLooping": true,
+ "FlipBook": true,
+ "Columns": 6,
+ "Rows": 5,
+ "Frames": 30
+ }
+ ],
+ "InputColorSpace": "Linear",
+ "OutputFormat": "DXT5",
+ "OutputColorSpace": "Linear",
+ "OutputMipAlgorithm": "None",
+ "OutputTypeString": "2D"
+}
\ No newline at end of file
diff --git a/Code/Base/Powertrain/Clutch.cs b/Code/Base/Powertrain/Clutch.cs
index 600a847..5da8529 100644
--- a/Code/Base/Powertrain/Clutch.cs
+++ b/Code/Base/Powertrain/Clutch.cs
@@ -1,49 +1,207 @@
-using Sandbox;
-using System;
+using System;
+using System.Collections.Generic;
+using Sandbox;
-namespace VeloX.Powertrain;
+namespace VeloX;
-public class Clutch : PowertrainComponent
+public partial class Clutch : PowertrainComponent
{
- [Property] public override float Inertia { get; set; } = 0.002f;
- [Property] public float SlipTorque { get; set; } = 1000f;
+ protected override void OnAwake()
+ {
+ base.OnAwake();
+ Name ??= "Clutch";
+ }
- public float Pressing { get; set; } = 1; // todo
+ ///
+ /// RPM at which automatic clutch will try to engage.
+ ///
+
+ [Property] public float EngagementRPM { get; set; } = 1200f;
+
+ public float ThrottleEngagementOffsetRPM = 400f;
+
+ ///
+ /// Clutch engagement in range [0,1] where 1 is fully engaged clutch.
+ /// Affected by Slip Torque field as the clutch can transfer [clutchEngagement * SlipTorque] Nm
+ /// meaning that higher value of SlipTorque will result in more sensitive clutch.
+ ///
+ [Range( 0, 1 ), Property] public float ClutchInput { get; set; }
+
+ ///
+ /// Curve representing pedal travel vs. clutch engagement. Should start at 0,0 and end at 1,1.
+ ///
+ [Property] public Curve EngagementCurve { get; set; } = new( new List() { new( 0, 0 ), new( 1, 1 ) } );
+
+ public enum ClutchControlType
+ {
+ Automatic,
+ Manual
+ }
+
+ [Property] public ClutchControlType СontrolType { get; set; } = ClutchControlType.Automatic;
+
+ ///
+ /// The RPM range in which the clutch will go from disengaged to engaged and vice versa.
+ /// E.g. if set to 400 and engagementRPM is 1000, 1000 will mean clutch is fully disengaged and
+ /// 1400 fully engaged. Setting it too low might cause clutch to hunt/oscillate.
+ ///
+ [Property] public float EngagementRange { get; set; } = 400f;
+
+ ///
+ /// Torque at which the clutch will slip / maximum torque that the clutch can transfer.
+ /// This value also affects clutch engagement as higher slip value will result in clutch
+ /// that grabs higher up / sooner. Too high slip torque value combined with low inertia of
+ /// powertrain components might cause instability in powertrain solver.
+ ///
+ [Property] public float SlipTorque { get; set; } = 500f;
+
+
+ ///
+ /// Amount of torque that will be passed through clutch even when completely disengaged
+ /// to emulate torque converter creep on automatic transmissions.
+ /// Should be higher than rolling resistance of the wheels to get the vehicle rolling.
+ ///
+ [Range( 0, 100f ), Property] public float CreepTorque { get; set; } = 0;
+
+ [Property] public float CreepSpeedLimit { get; set; } = 1f;
+
+
+ ///
+ /// Clutch engagement based on ClutchInput and the clutchEngagementCurve
+ ///
+ [Property, ReadOnly]
+ public float Engagement => _clutchEngagement;
+ private float _clutchEngagement;
+
+ protected override void OnStart()
+ {
+ base.OnStart();
+ SlipTorque = Controller.Engine.EstimatedPeakTorque * 1.5f;
+ }
+
+ public override float QueryAngularVelocity( float angularVelocity, float dt )
+ {
+ InputAngularVelocity = angularVelocity;
+
+ // Return input angular velocity if InputNameHash or OutputNameHash is 0
+ if ( InputNameHash == 0 || OutputNameHash == 0 )
+ {
+ return InputAngularVelocity;
+ }
+
+ // Adjust clutch engagement based on conditions
+ if ( СontrolType == ClutchControlType.Automatic )
+ {
+ Engine engine = Controller.Engine;
+ // Override engagement when shifting to smoothly engage and disengage gears
+ if ( Controller.Transmission.IsShifting )
+ {
+ float shiftProgress = Controller.Transmission.ShiftProgress;
+ ClutchInput = MathF.Abs( MathF.Cos( MathF.PI * shiftProgress ) );
+ }
+ // Clutch engagement calculation for automatic clutch
+ else
+ {
+ // Calculate engagement
+ // Engage the clutch if the input spinning faster than the output, but also if vice versa.
+ float throttleInput = Controller.SwappedThrottle;
+ float finalEngagementRPM = EngagementRPM + ThrottleEngagementOffsetRPM * (throttleInput * throttleInput);
+ float referenceRPM = MathF.Max( InputRPM, OutputRPM );
+
+ ClutchInput = (referenceRPM - finalEngagementRPM) / EngagementRange;
+ ClutchInput = Math.Clamp( ClutchInput, 0f, 1f );
+
+ // Avoid disconnecting clutch at high speed
+ if ( engine.OutputRPM > engine.IdleRPM * 1.1f && Controller.TotalSpeed > 3f )
+ {
+ ClutchInput = 1f;
+ }
+ }
+ if ( Controller.IsClutching > 0 )
+ {
+ ClutchInput = 1 - Controller.IsClutching;
+ }
+
+ }
+ else if ( СontrolType == ClutchControlType.Manual )
+ {
+ // Manual clutch engagement through user input
+ ClutchInput = Controller.IsClutching;
+ }
+
+ OutputAngularVelocity = InputAngularVelocity * _clutchEngagement;
+ float Wout = Output.QueryAngularVelocity( OutputAngularVelocity, dt ) * _clutchEngagement;
+ float Win = angularVelocity * (1f - _clutchEngagement);
+ return Wout + Win;
+ }
public override float QueryInertia()
{
- if ( !HasOutput )
+ if ( OutputNameHash == 0 )
+ {
return Inertia;
+ }
- return Inertia + Output.QueryInertia() * Pressing;
- }
-
- public override float QueryAngularVelocity( float angularVelocity )
- {
- this.angularVelocity = angularVelocity;
-
- if ( !HasOutput )
- return angularVelocity;
-
-
- float outputW = Output.QueryAngularVelocity( angularVelocity ) * Pressing;
- float inputW = angularVelocity * (1 - Pressing);
-
- return outputW + inputW;
+ float I = Inertia + Output.QueryInertia() * _clutchEngagement;
+ return I;
}
- public override float ForwardStep( float torque, float inertia )
+ public override float ForwardStep( float torque, float inertiaSum, float dt )
{
- if ( !HasOutput )
+
+ InputTorque = torque;
+ InputInertia = inertiaSum;
+
+ if ( OutputNameHash == 0 )
return torque;
- Torque = Math.Clamp( torque, -SlipTorque, SlipTorque );
- Torque = torque * (1 - (1 - MathF.Pow( Pressing, 0.3f )));
+ // Get the clutch engagement point from the input value
+ // Do not use the clutchEnagement directly for any calculations!
+ _clutchEngagement = EngagementCurve.Evaluate( ClutchInput );
+ _clutchEngagement = Math.Clamp( _clutchEngagement, 0, 1 );
+ // Calculate output inertia and torque based on the clutch engagement
- float returnTorque = Output.ForwardStep( Torque, inertia * Pressing + Inertia ) * Pressing;
+ // Assume half of the inertia is on the input plate and the other half is on the output clutch plate.
+ float halfClutchInertia = Inertia * 0.5f;
+ OutputInertia = (inertiaSum + halfClutchInertia) * _clutchEngagement + halfClutchInertia;
- return Math.Clamp( returnTorque, -SlipTorque, SlipTorque );
+ // Allow the torque output to be only up to the slip torque valu
+ float outputTorqueClamp = SlipTorque * _clutchEngagement;
+
+ OutputTorque = InputTorque;
+ OutputTorque = Math.Clamp( OutputTorque, 0, outputTorqueClamp );
+ float slipOverflowTorque = -Math.Min( outputTorqueClamp - OutputTorque, 0 );
+
+ // Apply the creep torque commonly caused by torque converter drag in automatic transmissions
+ ApplyCreepTorque( ref OutputTorque, CreepTorque );
+
+ // Send the torque downstream
+ float returnTorque = _output.ForwardStep( OutputTorque, OutputInertia, dt ) * _clutchEngagement;
+
+ // Clamp the return torque to the slip torque of the clutch once again
+ returnTorque = Math.Clamp( returnTorque, -SlipTorque, SlipTorque );
+
+ // Torque returned to the input is a combination of torque returned by the powertrain and the torque that
+ // was possibly never sent downstream
+ return returnTorque + slipOverflowTorque;
}
+
+
+ private void ApplyCreepTorque( ref float torque, float creepTorque )
+ {
+ // Apply creep torque to forward torque
+ if ( creepTorque != 0 && Controller.Engine.IsRunning && Controller.TotalSpeed < CreepSpeedLimit )
+ {
+ bool torqueWithinCreepRange = torque < creepTorque && torque > -creepTorque;
+
+ if ( torqueWithinCreepRange )
+ {
+ torque = creepTorque;
+ }
+ }
+
+ }
+
}
diff --git a/Code/Base/Powertrain/Differential.cs b/Code/Base/Powertrain/Differential.cs
new file mode 100644
index 0000000..d37ed74
--- /dev/null
+++ b/Code/Base/Powertrain/Differential.cs
@@ -0,0 +1,234 @@
+using Sandbox;
+using System;
+
+namespace VeloX;
+
+
+public partial class Differential : PowertrainComponent
+{
+
+
+ /// Input torque
+ /// Angular velocity of the outputA
+ /// Angular velocity of the outputB
+ /// Inertia of the outputA
+ /// Inertia of the outputB
+ /// Time step
+ /// Torque bias between outputA and outputB. 0 = all torque goes to A, 1 = all torque goes to B
+ /// Stiffness of the limited slip or locked differential 0-1
+ /// Stiffness under power
+ /// Stiffness under braking
+ /// Slip torque of the limited slip differential
+ /// Torque output towards outputA
+ /// Torque output towards outputB
+ public delegate void SplitTorque( float T, float Wa, float Wb, float Ia, float Ib, float dt, float biasAB,
+ float stiffness, float powerRamp, float coastRamp, float slipTorque, out float Ta, out float Tb );
+
+ protected override void OnAwake()
+ {
+ base.OnAwake();
+ Name ??= "Differential";
+ AssignDifferentialDelegate();
+ }
+ public enum DifferentialType
+ {
+ Open,
+ Locked,
+ LimitedSlip,
+ }
+
+ ///
+ /// Differential type.
+ ///
+ [Property]
+ public DifferentialType Type
+ {
+ get => _differentialType;
+ set
+ {
+ _differentialType = value;
+ AssignDifferentialDelegate();
+ }
+ }
+ private DifferentialType _differentialType;
+
+ ///
+ /// Torque bias between left (A) and right (B) output in [0,1] range.
+ ///
+ [Property, Range( 0, 1 )] public float BiasAB { get; set; } = 0.5f;
+
+ ///
+ /// Stiffness of locking differential [0,1]. Higher value
+ /// will result in lower difference in rotational velocity between left and right wheel.
+ /// Too high value might introduce slight oscillation due to drivetrain windup and a vehicle that is hard to steer.
+ ///
+ [Property, Range( 0, 1 ), HideIf( nameof( _differentialType ), DifferentialType.Open )] public float Stiffness { get; set; } = 0.5f;
+
+ ///
+ /// Stiffness of the LSD differential under acceleration.
+ ///
+ [Property, Range( 0, 1 ), ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float PowerRamp { get; set; } = 1f;
+
+ ///
+ /// Stiffness of the LSD differential under braking.
+ ///
+ [Property, Range( 0, 1 ), ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float CoastRamp { get; set; } = 0.5f;
+
+
+ ///
+ /// Second output of differential.
+ ///
+ [Property]
+ public PowertrainComponent OutputB
+ {
+ get { return _outputB; }
+ set
+ {
+ if ( value == this )
+ {
+ Log.Warning( $"{Name}: PowertrainComponent Output can not be self." );
+ OutputBNameHash = 0;
+ _output = null;
+ return;
+ }
+ if ( _outputB != null )
+ {
+ _outputB.InputNameHash = 0;
+ _outputB.Input = null;
+ }
+
+ _outputB = value;
+
+ if ( _outputB != null )
+ {
+ _outputB.Input = this;
+ OutputBNameHash = _outputB.ToString().GetHashCode();
+ }
+ else
+ {
+ OutputBNameHash = 0;
+ }
+ }
+ }
+
+ protected PowertrainComponent _outputB;
+ public int OutputBNameHash;
+
+
+ ///
+ /// Slip torque of limited slip differentials.
+ ///
+ [Property, ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float SlipTorque { get; set; } = 400f;
+
+ ///
+ /// Function delegate that will be used to split the torque between output(A) and outputB.
+ ///
+ public SplitTorque SplitTorqueDelegate;
+
+ private void AssignDifferentialDelegate()
+ {
+ SplitTorqueDelegate = _differentialType switch
+ {
+ DifferentialType.Open => OpenDiffTorqueSplit,
+ DifferentialType.Locked => LockingDiffTorqueSplit,
+ DifferentialType.LimitedSlip => LimitedDiffTorqueSplit,
+ _ => OpenDiffTorqueSplit,
+ };
+ }
+
+ public static void OpenDiffTorqueSplit( float T, float Wa, float Wb, float Ia, float Ib, float dt, float biasAB,
+ float stiffness, float powerRamp, float coastRamp, float slipTorque, out float Ta, out float Tb )
+ {
+ Ta = T * (1f - biasAB);
+ Tb = T * biasAB;
+ }
+
+
+ public static void LockingDiffTorqueSplit( float T, float Wa, float Wb, float Ia, float Ib, float dt, float biasAB,
+ float stiffness, float powerRamp, float coastRamp, float slipTorque, out float Ta, out float Tb )
+ {
+ Ta = T * (1f - biasAB);
+ Tb = T * biasAB;
+
+ float syncTorque = (Wa - Wb) * stiffness * (Ia + Ib) * 0.5f / dt;
+
+ Ta -= syncTorque;
+ Tb += syncTorque;
+ }
+
+
+ public static void LimitedDiffTorqueSplit( float T, float Wa, float Wb, float Ia, float Ib, float dt, float biasAB,
+ float stiffness, float powerRamp, float coastRamp, float slipTorque, out float Ta, out float Tb )
+ {
+ if ( Wa < 0 || Wb < 0 )
+ {
+ Ta = T * (1f - biasAB);
+ Tb = T * biasAB;
+ return;
+ }
+
+ // Минимальный момент трения, даже если разницы скоростей нет
+ float preloadTorque = MathF.Abs( T ) * 0.5f;
+ float speedDiff = Wa - Wb;
+ float ramp = T > 0 ? powerRamp : coastRamp;
+
+ // Основной момент трения LSD (зависит от разницы скоростей и preload)
+ float frictionTorque = ramp * (slipTorque * MathF.Abs( speedDiff ) + preloadTorque);
+ frictionTorque = MathF.Min( frictionTorque, MathF.Abs( T ) * 0.5f );
+
+ Ta = T * (1f - biasAB) - MathF.Sign( speedDiff ) * frictionTorque;
+ Tb = T * biasAB + MathF.Sign( speedDiff ) * frictionTorque;
+ }
+ public override float QueryAngularVelocity( float angularVelocity, float dt )
+ {
+ InputAngularVelocity = angularVelocity;
+
+ if ( OutputNameHash == 0 || OutputBNameHash == 0 )
+ return angularVelocity;
+
+ OutputAngularVelocity = InputAngularVelocity;
+ float Wa = _output.QueryAngularVelocity( OutputAngularVelocity, dt );
+ float Wb = _outputB.QueryAngularVelocity( OutputAngularVelocity, dt );
+ return (Wa + Wb) * 0.5f;
+ }
+
+ public override float QueryInertia()
+ {
+ if ( OutputNameHash == 0 || OutputBNameHash == 0 )
+ return Inertia;
+
+ float Ia = _output.QueryInertia();
+ float Ib = _outputB.QueryInertia();
+ float I = Inertia + (Ia + Ib);
+
+ return I;
+ }
+
+
+ public override float ForwardStep( float torque, float inertiaSum, float dt )
+ {
+ InputTorque = torque;
+ InputInertia = inertiaSum;
+
+ if ( OutputNameHash == 0 || OutputBNameHash == 0 )
+ return torque;
+
+ float Wa = _output.QueryAngularVelocity( OutputAngularVelocity, dt );
+ float Wb = _outputB.QueryAngularVelocity( OutputAngularVelocity, dt );
+
+ float Ia = _output.QueryInertia();
+ float Ib = _outputB.QueryInertia();
+ SplitTorqueDelegate.Invoke( torque, Wa, Wb, Ia, Ib, dt, BiasAB, Stiffness, PowerRamp,
+ CoastRamp, SlipTorque, out float Ta, out float Tb );
+
+ float outAInertia = inertiaSum * 0.5f + Ia;
+ float outBInertia = inertiaSum * 0.5f + Ib;
+
+ OutputTorque = Ta + Tb;
+ OutputInertia = outAInertia + outBInertia;
+
+ return _output.ForwardStep( Ta, outAInertia, dt ) + _outputB.ForwardStep( Tb, outBInertia, dt );
+ }
+
+
+}
diff --git a/Code/Base/Powertrain/Differential/BaseDifferential.cs b/Code/Base/Powertrain/Differential/BaseDifferential.cs
deleted file mode 100644
index 296ce82..0000000
--- a/Code/Base/Powertrain/Differential/BaseDifferential.cs
+++ /dev/null
@@ -1,79 +0,0 @@
-using Sandbox;
-using System;
-
-namespace VeloX.Powertrain;
-
-[Category( "VeloX/Powertrain/Differential" )]
-public abstract class BaseDifferential : PowertrainComponent
-{
- [Property] public float FinalDrive { get; set; } = 3.392f;
- [Property] public override float Inertia { get; set; } = 0.01f;
- //[Property] public float CoastRamp { get; set; } = 1f;
- //[Property] public float PowerRamp { get; set; } = 1f;
- //[Property] public float Stiffness { get; set; } = 0.1f;
- //[Property] public float SlipTorque { get; set; } = 0f;
- //[Property] public float SteerLock { get; set; } = 45f;
-
-
- ///
- /// The PowertrainComponent this component will output to.
- ///
- [Property]
- public PowertrainComponent OutputB
- {
- get => _outputb;
- set
- {
- if ( value == this )
- {
- _outputb = null;
- return;
- }
-
- _outputb = value;
- if ( _outputb != null )
- _outputb.Input = this;
- }
-
- }
- private PowertrainComponent _outputb;
-
- public override bool HasOutput => Output.IsValid() && OutputB.IsValid();
-
- public override float QueryAngularVelocity( float angularVelocity )
- {
- this.angularVelocity = angularVelocity;
-
- if ( !HasOutput )
- return angularVelocity;
-
- float aW = Output.QueryAngularVelocity( angularVelocity );
- float bW = OutputB.QueryAngularVelocity( angularVelocity );
-
- return (aW + bW) * FinalDrive * 0.5f;
- }
-
- public abstract void SplitTorque( float aW, float bW, float aI, float bI, out float tqA, out float tqB );
-
- public override float ForwardStep( float torque, float inertia )
- {
- if ( !HasOutput )
- return torque;
-
- float aW = Output.QueryAngularVelocity( angularVelocity );
- float bW = OutputB.QueryAngularVelocity( angularVelocity );
-
- float aI = Output.QueryInertia();
- float bI = OutputB.QueryInertia();
-
- Torque = torque * FinalDrive;
-
- SplitTorque( aW, bW, aI, bI, out float tqA, out float tqB );
-
- tqA = Output.ForwardStep( tqA, inertia * 0.5f * MathF.Pow( FinalDrive, 2 ) + aI );
- tqB = OutputB.ForwardStep( tqB, inertia * 0.5f * MathF.Pow( FinalDrive, 2 ) + bI );
-
- return tqA + tqB;
- }
-
-}
diff --git a/Code/Base/Powertrain/Differential/OpenDifferential.cs b/Code/Base/Powertrain/Differential/OpenDifferential.cs
deleted file mode 100644
index 15a0065..0000000
--- a/Code/Base/Powertrain/Differential/OpenDifferential.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using Sandbox;
-
-namespace VeloX.Powertrain;
-
-public class OpenDifferential : BaseDifferential
-{
- [Property] public float BiasAB { get; set; } = 0.5f;
- public override void SplitTorque( float aW, float bW, float aI, float bI, out float tqA, out float tqB )
- {
- tqA = Torque * (1 - BiasAB);
- tqB = Torque * BiasAB;
- }
-}
diff --git a/Code/Base/Powertrain/Engine.cs b/Code/Base/Powertrain/Engine.cs
index 400f67e..9163578 100644
--- a/Code/Base/Powertrain/Engine.cs
+++ b/Code/Base/Powertrain/Engine.cs
@@ -1,109 +1,663 @@
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.Powertrain;
+namespace VeloX;
-public class Engine : PowertrainComponent
+public class Engine : PowertrainComponent, IScenePhysicsEvents
{
- [Property, Group( "Settings" )] public float IdleRPM { get; set; } = 900f;
- [Property, Group( "Settings" )] public float MaxRPM { get; set; } = 7000f;
- [Property, Group( "Settings" )] public override float Inertia { get; set; } = 0.151f;
- [Property, Group( "Settings" )] public float LimiterDuration { get; set; } = 0.05f;
- [Property, Group( "Settings" )] public Curve TorqueMap { get; set; }
- [Property, Group( "Settings" )] public EngineStream Stream { get; set; }
+ protected override void OnAwake()
+ {
+ base.OnAwake();
+ Name ??= "Engine";
+ UpdatePeakPowerAndTorque();
- [Sync] public float Throttle { get; internal set; }
+ }
- [Property] public bool IsRedlining => !limiterTimer;
- [Property] public float RPMPercent => Math.Clamp( (RPM - IdleRPM) / (MaxRPM - IdleRPM), 0, 1 );
+ [Hide] public new bool Input { get; set; }
- private float masterThrottle;
- private TimeUntil limiterTimer;
- private float finalTorque;
+ 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;
- private EngineStreamPlayer StreamPlayer;
- public float[] friction = [15.438f, 2.387f, 0.7958f];
protected override void OnStart()
{
base.OnStart();
- StreamPlayer = new( Stream );
- }
-
- public float GetFrictionTorque( float throttle, float rpm )
- {
- float s = rpm < 0 ? -1f : 1f;
- float r = s * rpm * 0.001f;
- float f = friction[0] + friction[1] * r + friction[2] * r * r;
- return -s * f * (1 - throttle);
- }
- private float GenerateTorque()
- {
- float throttle = Throttle;
- float rpm = RPM;
- float friction = GetFrictionTorque( throttle, rpm );
-
- float maxInitialTorque = TorqueMap.Evaluate( RPMPercent ) - friction;
- float idleFadeStart = Math.Clamp( MathX.Remap( rpm, IdleRPM - 300, IdleRPM, 1, 0 ), 0, 1 );
- float idleFadeEnd = Math.Clamp( MathX.Remap( rpm, IdleRPM, IdleRPM + 600, 1, 0 ), 0, 1 );
- float additionalEnergySupply = idleFadeEnd * (-friction / maxInitialTorque) + idleFadeStart;
-
-
- if ( rpm > MaxRPM )
+ if ( Type == EngineType.ICE )
{
- throttle = 0;
- limiterTimer = LimiterDuration;
+ CalculateTorqueDelegate = CalculateTorqueICE;
}
- else if ( !limiterTimer )
- throttle = 0;
-
- masterThrottle = Math.Clamp( additionalEnergySupply + throttle, 0, 1 );
-
- float realInitialTorque = maxInitialTorque * masterThrottle;
- Torque = realInitialTorque + friction;
-
- return Torque;
- }
-
- public override float ForwardStep( float _, float __ )
- {
- if ( !HasOutput )
+ else if ( Type == EngineType.Electric )
{
- angularVelocity += GenerateTorque() / Inertia * Time.Delta;
- angularVelocity = Math.Max( angularVelocity, 0 );
- return 0;
+ IdleRPM = 0f;
+ FlyingStartEnabled = true;
+ CalculateTorqueDelegate = CalculateTorqueElectric;
+ StarterActive = false;
+ StartDuration = 0.001f;
+ RevLimiterCutoffDuration = 0f;
}
- float outputInertia = Output.QueryInertia();
- float inertiaSum = Inertia + outputInertia;
- float outputW = Output.QueryAngularVelocity( angularVelocity );
-
- float targetW = Inertia / inertiaSum * angularVelocity + outputInertia / inertiaSum * outputW;
- float generatedTorque = GenerateTorque();
- float reactTorque = (targetW - angularVelocity) * Inertia / Time.Delta;
- float returnedTorque = Output.ForwardStep( generatedTorque - reactTorque, Inertia );
-
- finalTorque = generatedTorque + reactTorque + returnedTorque;
- angularVelocity += finalTorque / inertiaSum * Time.Delta;
- angularVelocity = Math.Max( angularVelocity, 0 );
-
- UpdateStream();
-
- return finalTorque;
}
- private void UpdateStream()
+ public void StartEngine()
{
- if ( StreamPlayer is null )
+ 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;
- StreamPlayer.Throttle = Throttle;
- StreamPlayer.RPMPercent = RPMPercent;
- StreamPlayer.EngineState = EngineState.Running;
- StreamPlayer.IsRedlining = IsRedlining;
+ 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();
+ }
+
+
+ ///
+ /// 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 );
- StreamPlayer.Update( Time.Delta, WorldPosition );
}
+ 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;
+ }
+
+
+ ///
+ /// 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;
+
+ }
+ }
}
diff --git a/Code/Base/Powertrain/Gearbox/BaseGearbox.cs b/Code/Base/Powertrain/Gearbox/BaseGearbox.cs
deleted file mode 100644
index 8a68516..0000000
--- a/Code/Base/Powertrain/Gearbox/BaseGearbox.cs
+++ /dev/null
@@ -1,45 +0,0 @@
-using Sandbox;
-using System;
-
-namespace VeloX.Powertrain;
-
-[Category( "VeloX/Powertrain/Gearbox" )]
-public abstract class BaseGearbox : PowertrainComponent
-{
- [Property] public override float Inertia { get; set; } = 0.01f;
-
- protected float ratio;
-
- public override float QueryInertia()
- {
- if ( !HasOutput || ratio == 0 )
- return Inertia;
-
- return Inertia + Output.QueryInertia() / MathF.Pow( ratio, 2 );
- }
-
- public override float QueryAngularVelocity( float angularVelocity )
- {
- this.angularVelocity = angularVelocity;
- if ( !HasOutput || ratio == 0 )
- return angularVelocity;
-
- return Output.QueryAngularVelocity( angularVelocity ) * ratio;
- }
-
- public override float ForwardStep( float torque, float inertia )
- {
- Torque = torque * ratio;
-
- if ( !HasOutput )
- return torque;
-
- if ( ratio == 0 )
- {
- Output.ForwardStep( 0, Inertia * 0.5f );
- return torque;
- }
-
- return Output.ForwardStep( Torque, (inertia + Inertia) * MathF.Pow( ratio, 2 ) ) / ratio;
- }
-}
diff --git a/Code/Base/Powertrain/Gearbox/ManualGearbox.cs b/Code/Base/Powertrain/Gearbox/ManualGearbox.cs
deleted file mode 100644
index d0c6a65..0000000
--- a/Code/Base/Powertrain/Gearbox/ManualGearbox.cs
+++ /dev/null
@@ -1,52 +0,0 @@
-using Sandbox;
-
-namespace VeloX.Powertrain;
-
-public class ManualGearbox : BaseGearbox
-{
- [Property] public float[] Ratios { get; set; } = [3.626f, 2.200f, 1.541f, 1.213f, 1.000f, 0.767f];
- [Property] public float Reverse { get; set; } = 3.4f;
- [Property, InputAction] public string ForwardAction { get; set; } = "Attack1";
- [Property, InputAction] public string BackwardAction { get; set; } = "Attack2";
-
- private int gear;
-
- protected void SetGear( int gear )
- {
- if ( gear < -1 || gear >= Ratios.Length )
- return;
- this.gear = gear;
-
- RecalcRatio();
- }
-
- private void RecalcRatio()
- {
- if ( gear == -1 )
- ratio = -Reverse;
- else if ( gear == 0 )
- ratio = 0;
- else
- ratio = Ratios[gear - 1];
- }
-
- public void Shift( int dir )
- {
- SetGear( gear + dir );
- }
-
- private void InputResolve()
- {
- if ( Sandbox.Input.Pressed( ForwardAction ) )
- Shift( 1 );
- else if ( Sandbox.Input.Pressed( BackwardAction ) )
- Shift( -1 );
- }
-
- public override float ForwardStep( float torque, float inertia )
- {
- InputResolve();
-
- return base.ForwardStep( torque, inertia );
- }
-}
diff --git a/Code/Base/Powertrain/PowerWheel.cs b/Code/Base/Powertrain/PowerWheel.cs
deleted file mode 100644
index 437cf08..0000000
--- a/Code/Base/Powertrain/PowerWheel.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-using Sandbox;
-using System;
-
-namespace VeloX.Powertrain;
-
-public class PowerWheel : PowertrainComponent
-{
- [Property] public VeloXWheel Wheel { get; set; }
-
- public override float QueryInertia()
- {
- float dtScale = Math.Clamp( Time.Delta, 0.01f, 0.05f ) / 0.005f;
- return Wheel.BaseInertia * dtScale;
- }
-
- public override float QueryAngularVelocity( float angularVelocity )
- {
- return Wheel.AngularVelocity;
- }
-
- public override float ForwardStep( float torque, float inertia )
- {
- Wheel.AutoPhysics = false;
- Wheel.Torque = torque;
- Wheel.Brake = Vehicle.Brake;
- Inertia = Wheel.BaseInertia + inertia;
- Wheel.Inertia = inertia;
- Wheel.DoPhysics( Vehicle );
-
- angularVelocity = Wheel.AngularVelocity;
-
- return Wheel.CounterTorque;
- }
-}
diff --git a/Code/Base/Powertrain/PowertrainComponent.cs b/Code/Base/Powertrain/PowertrainComponent.cs
index 1997852..8377b9c 100644
--- a/Code/Base/Powertrain/PowertrainComponent.cs
+++ b/Code/Base/Powertrain/PowertrainComponent.cs
@@ -1,26 +1,31 @@
-using Sandbox;
-using System;
+using System;
+using Sandbox;
-namespace VeloX.Powertrain;
+namespace VeloX;
public abstract class PowertrainComponent : Component
{
- protected override void OnAwake()
- {
- Vehicle ??= Components.Get( FindMode.EverythingInSelfAndAncestors );
- }
+ [Property] public VeloXBase Controller;
+ ///
+ /// Name of the component. Only unique names should be used on the same vehicle.
+ ///
+ [Property] public string Name { get; set; }
- public const float RAD_TO_RPM = 60f / MathF.Tau;
- public const float RPM_TO_RAD = 1 / (60 / MathF.Tau);
- public const float UNITS_PER_METER = 39.37f;
- public const float UNITS_TO_METERS = 0.01905f;
- public const float KG_TO_N = 9.80665f;
- public const float KG_TO_KN = 0.00980665f;
- [Property] public VeloXBase Vehicle { get; set; }
- [Property] public virtual float Inertia { get; set; } = 0.02f;
+ ///
+ /// Angular inertia of the component. Higher inertia value will result in a powertrain that is slower to spin up, but
+ /// also slower to spin down. Too high values will result in (apparent) sluggish response while too low values will
+ /// result in vehicle being easy to stall and possible powertrain instability / glitches.
+ ///
+ [Property, Range( 0.0002f, 2f )] public float Inertia { get; set; } = 0.05f;
+ [Property, ReadOnly] public float InputTorque;
+ [Property, ReadOnly] public float OutputTorque;
+ public float InputAngularVelocity;
+ public float OutputAngularVelocity;
+ public float InputInertia;
+ public float OutputInertia;
///
/// Input component. Set automatically.
@@ -34,67 +39,143 @@ public abstract class PowertrainComponent : Component
if ( value == null || value == this )
{
_input = null;
+ InputNameHash = 0;
return;
}
_input = value;
+ InputNameHash = _input.GetHashCode();
}
}
- private PowertrainComponent _input;
+ protected PowertrainComponent _input;
public int InputNameHash;
+
+
///
/// The PowertrainComponent this component will output to.
///
[Property]
public PowertrainComponent Output
{
- get => _output;
+ get { return _output; }
set
{
if ( value == this )
{
+ Log.Warning( $"{Name}: PowertrainComponent Output can not be self." );
+ OutputNameHash = 0;
_output = null;
return;
}
_output = value;
if ( _output != null )
+ {
_output.Input = this;
+ OutputNameHash = _output.GetHashCode();
+ }
+ else
+ {
+ OutputNameHash = 0;
+ }
}
}
- private PowertrainComponent _output;
+ protected PowertrainComponent _output;
+ public int OutputNameHash { get; private set; }
- public float RPM => angularVelocity * RAD_TO_RPM;
- protected float angularVelocity;
- protected float Torque;
+ ///
+ /// Input shaft RPM of component.
+ ///
+ [Property, ReadOnly]
+ public float InputRPM => AngularVelocityToRPM( InputAngularVelocity );
- public virtual bool HasOutput => Output.IsValid();
+
+ ///
+ /// Output shaft RPM of component.
+ ///
+ [Property, ReadOnly]
+ public float OutputRPM => AngularVelocityToRPM( OutputAngularVelocity );
+
+
+ public virtual float QueryAngularVelocity( float angularVelocity, float dt )
+ {
+ InputAngularVelocity = angularVelocity;
+
+ if ( OutputNameHash == 0 )
+ {
+ return angularVelocity;
+ }
+
+ OutputAngularVelocity = angularVelocity;
+ return _output.QueryAngularVelocity( OutputAngularVelocity, dt );
+ }
public virtual float QueryInertia()
{
- if ( !HasOutput )
+ if ( OutputNameHash == 0 )
return Inertia;
- return Inertia + Output.QueryInertia();
- }
+ float Ii = Inertia;
+ float Ia = _output.QueryInertia();
- public virtual float QueryAngularVelocity( float angularVelocity )
+ return Ii + Ia;
+ }
+ public virtual float ForwardStep( float torque, float inertiaSum, float dt )
{
- if ( !HasOutput )
- return angularVelocity;
+ InputTorque = torque;
+ InputInertia = inertiaSum;
- return Output.QueryAngularVelocity( angularVelocity );
+ if ( OutputNameHash == 0 )
+ return torque;
+
+ OutputTorque = InputTorque;
+ OutputInertia = inertiaSum + Inertia;
+ return _output.ForwardStep( OutputTorque, OutputInertia, dt );
}
- public virtual float ForwardStep( float torque, float inertia )
+ public static float TorqueToPowerInKW( in float angularVelocity, in float torque )
{
- if ( !HasOutput )
- return Torque;
+ // Power (W) = Torque (Nm) * Angular Velocity (rad/s)
+ float powerInWatts = torque * angularVelocity;
- return Output.ForwardStep( Torque, inertia + Inertia );
+ // Convert power from watts to kilowatts
+ float powerInKW = powerInWatts / 1000f;
+
+ return powerInKW;
}
+
+ public static float PowerInKWToTorque( in float angularVelocity, in float powerInKW )
+ {
+ // Convert power from kilowatts to watts
+ float powerInWatts = powerInKW * 1000f;
+
+ // Torque (Nm) = Power (W) / Angular Velocity (rad/s)
+ float absAngVel = Math.Abs( angularVelocity );
+ float clampedAngularVelocity = (absAngVel > -1f && absAngVel < 1f) ? 1f : angularVelocity;
+ float torque = powerInWatts / clampedAngularVelocity;
+ return torque;
+ }
+
+ public float CalculateOutputPowerInKW()
+ {
+ return GetPowerInKW( OutputTorque, OutputAngularVelocity );
+ }
+ public static float GetPowerInKW( in float torque, in float angularVelocity )
+ {
+ // Power (W) = Torque (Nm) * Angular Velocity (rad/s)
+ float powerInWatts = torque * angularVelocity;
+
+ // Convert power from watts to kilowatts
+ float powerInKW = powerInWatts / 1000f;
+
+ return powerInKW;
+ }
+
+ public static float AngularVelocityToRPM( float angularVelocity ) => angularVelocity * 9.5492965855137f;
+
+ public static float RPMToAngularVelocity( float RPM ) => RPM * 0.10471975511966f;
}
diff --git a/Code/Base/Powertrain/Transmission.cs b/Code/Base/Powertrain/Transmission.cs
new file mode 100644
index 0000000..9f72d25
--- /dev/null
+++ b/Code/Base/Powertrain/Transmission.cs
@@ -0,0 +1,800 @@
+using Sandbox;
+using System.Collections.Generic;
+using System;
+
+namespace VeloX;
+
+public class Transmission : PowertrainComponent
+{
+ protected override void OnAwake()
+ {
+ base.OnAwake();
+ Name ??= "Transmission";
+ LoadGearsFromGearingProfile();
+ }
+ ///
+ /// A class representing a single ground surface type.
+ ///
+ public partial class TransmissionGearingProfile
+ {
+ ///
+ /// List of forward gear ratios starting from 1st forward gear.
+ ///
+ public List ForwardGears { get; set; } = [3.59f, 2.02f, 1.38f, 1f, 0.87f];
+
+ ///
+ /// List of reverse gear ratios starting from 1st reverse gear.
+ ///
+ public List ReverseGears { get; set; } = [-4f,];
+ }
+
+ public const float INPUT_DEADZONE = 0.05f;
+ public float ReferenceShiftRPM => _referenceShiftRPM;
+
+ private float _referenceShiftRPM;
+
+ ///
+ /// If true the gear input has to be held for the transmission to stay in gear, otherwise it goes to neutral.
+ /// Used for hardware H-shifters.
+ ///
+ [Property] public bool HoldToKeepInGear { get; set; }
+
+ ///
+ /// Final gear multiplier. Each gear gets multiplied by this value.
+ /// Equivalent to axle/differential ratio in real life.
+ ///
+ [Property] public float FinalGearRatio { get; set; } = 4.3f;
+ ///
+ /// [Obsolete, will be removed]
+ /// Currently active gearing profile.
+ /// Final gear ratio will be determined from this and final gear ratio.
+ ///
+
+ [Property] public TransmissionGearingProfile GearingProfile { get; set; } = new();
+
+
+ ///
+ /// A list of gears ratios in order of negative, neutral and then positive.
+ /// E.g. -4, 0, 6, 4, 3, 2 => one reverse, 4 forward gears.
+ ///
+ [Property, ReadOnly, Group( "Info" )] public List Gears = new();
+
+ ///
+ /// Number of forward gears.
+ ///
+ public int ForwardGearCount;
+
+ ///
+ /// Number of reverse gears.
+ ///
+ public int ReverseGearCount;
+
+ ///
+ /// How much inclines affect shift point position. Higher value will push the shift up and shift down RPM up depending
+ /// on the current incline to prevent vehicle from upshifting at the wrong time.
+ ///
+ [Property, Range( 0, 4 )] public float InclineEffectCoeff { get; set; }
+
+ ///
+ /// Function that handles gear shifts.
+ /// Use External transmission type and assign this delegate manually to use a custom
+ /// gear shift function.
+ ///
+ public delegate void Shift( VeloXBase vc );
+
+ ///
+ /// Function that changes the gears as required.
+ /// Use transmissionType External and assign this delegate to use your own gear shift code.
+ ///
+ public Shift ShiftDelegate;
+
+ ///
+ /// Event that gets triggered when transmission shifts down.
+ ///
+ public event Action OnGearDownShift;
+
+ ///
+ /// Event that gets triggered when transmission shifts (up or down).
+ ///
+ public event Action OnGearShift;
+
+ ///
+ /// Event that gets triggered when transmission shifts up.
+ ///
+ public event Action OnGearUpShift;
+
+ ///
+ /// Time after shifting in which shifting can not be done again.
+ ///
+ [Property] public float PostShiftBan { get; set; } = 0.5f;
+
+ public enum AutomaticTransmissionDNRShiftType
+ {
+ Auto,
+ RequireShiftInput,
+ RepeatInput,
+ }
+
+ ///
+ /// Behavior when switching from neutral to forward or reverse gear.
+ ///
+ [Property] public AutomaticTransmissionDNRShiftType DNRShiftType { get; set; } = AutomaticTransmissionDNRShiftType.Auto;
+
+ ///
+ /// Speed at which the vehicle can switch between D/N/R gears.
+ ///
+ [Property] public float DnrSpeedThreshold { get; set; } = 0.4f;
+
+ ///
+ /// If set to >0, the clutch will need to be released to the value below the set number
+ /// for gear shifts to occur.
+ ///
+ [Property, Range( 0, 1 )] public float ClutchInputShiftThreshold { get; set; } = 1f;
+
+ ///
+ /// Time it takes transmission to shift between gears.
+ ///
+ [Property] public float ShiftDuration { get; set; } = 0.2f;
+
+ ///
+ /// Intensity of variable shift point. Higher value will result in shift point moving higher up with higher engine
+ /// load.
+ ///
+ [Property, Range( 0, 1 )] public float VariableShiftIntensity { get; set; } = 0.3f;
+
+ ///
+ /// If enabled transmission will adjust both shift up and down points to match current load.
+ ///
+ [Property] public bool VariableShiftPoint { get; set; } = true;
+
+ ///
+ /// Current gear ratio.
+ ///
+ [Property, ReadOnly] public float CurrentGearRatio { get; set; }
+
+ ///
+ /// Is the transmission currently in the post-shift phase in which the shifting is disabled/banned to prevent gear hunting?
+ ///
+ [Property, ReadOnly] public bool IsPostShiftBanActive { get; set; }
+
+ ///
+ /// Is a gear shift currently in progress.
+ ///
+ [Property, ReadOnly] public bool IsShifting { get; set; }
+
+ ///
+ /// Progress of the current gear shift in range of 0 to 1.
+ ///
+ [Property, ReadOnly] public float ShiftProgress { get; set; }
+
+
+ ///
+ /// Current RPM at which transmission will aim to downshift. All the modifiers are taken into account.
+ /// This value changes with driving conditions.
+ ///
+ [Property]
+ public float DownshiftRPM
+ {
+ get => _downshiftRPM;
+ set { _downshiftRPM = Math.Clamp( value, 0, float.MaxValue ); }
+ }
+
+ private float _downshiftRPM = 1400;
+ ///
+ /// RPM at which the transmission will try to downshift, but the value might get changed by shift modifier such
+ /// as incline modifier.
+ /// To get actual downshift RPM use DownshiftRPM.
+ ///
+ [Property]
+ public float TargetDownshiftRPM => _targetDownshiftRPM;
+
+ private float _targetDownshiftRPM;
+ ///
+ /// RPM at which automatic transmission will shift up. If dynamic shift point is enabled this value will change
+ /// depending on load.
+ ///
+ [Property]
+ public float UpshiftRPM
+ {
+ get => _upshiftRPM;
+ set { _upshiftRPM = Math.Clamp( value, 0, float.MaxValue ); }
+ }
+ private float _upshiftRPM = 2800;
+
+ ///
+ /// RPM at which the transmission will try to upshift, but the value might get changed by shift modifier such
+ /// as incline modifier.
+ /// To get actual upshift RPM use UpshiftRPM.
+ ///
+ [Property]
+ public float TargetUpshiftRPM => _targetUpshiftRPM;
+ private float _targetUpshiftRPM;
+
+ public enum TransmissionShiftType
+ {
+ Manual,
+ Automatic,
+ //CVT,
+ }
+
+ ///
+ /// Determines in which way gears can be changed.
+ /// Manual - gears can only be shifted by manual user input.
+ /// Automatic - automatic gear changing. Allows for gear skipping (e.g. 3rd->5th) which can be useful in trucks and
+ /// other high gear count vehicles.
+ /// AutomaticSequential - automatic gear changing but only one gear at the time can be shifted (e.g. 3rd->4th)
+ ///
+ [Property]
+ public TransmissionShiftType TransmissionType
+ {
+ get => transmissionType; set
+ {
+ if ( value == transmissionType )
+ return;
+ transmissionType = value;
+ AssignShiftDelegate();
+ }
+ }
+ ///
+ /// Is the automatic gearbox sequential?
+ /// Has no effect on manual transmission.
+ ///
+ [Property] public bool IsSequential { get; set; } = false;
+
+ [Property] public bool AllowUpshiftGearSkipping { get; set; }
+
+ [Property] public bool AllowDownshiftGearSkipping { get; set; } = true;
+
+ private bool _repeatInputFlag;
+ private float _smoothedThrottleInput;
+
+ ///
+ /// Timer needed to prevent manual transmission from slipping out of gear too soon when hold in gear is enabled,
+ /// which could happen in FixedUpdate() runs twice for one Update() and the shift flag is reset
+ /// resulting in gearbox thinking it has no shift input.
+ ///
+ private float _slipOutOfGearTimer = -999f;
+
+
+ ///
+ /// 0 for neutral, less than 0 for reverse gears and lager than 0 for forward gears.
+ /// Use 'ShiftInto' to set gear.
+ ///
+ [Property, Sync]
+ public int Gear
+ {
+ get => IndexToGear( gearIndex );
+ set => gearIndex = GearToIndex( value );
+ }
+
+
+ ///
+ /// Current gear index in the gears list.
+ /// Different from gear because gear uses -1 = R, 0 = N and D = 1, while this is the apsolute index
+ /// in the range of 0 to gear list size minus one.
+ /// Use Gear to get the actual gear.
+ ///
+ public int gearIndex;
+ private TransmissionShiftType transmissionType = TransmissionShiftType.Automatic;
+
+ private int GearToIndex( int g )
+ {
+ return g + ReverseGearCount;
+ }
+
+ private int IndexToGear( int g )
+ {
+ return g - ReverseGearCount;
+ }
+ ///
+ /// Returns current gear name as a string, e.g. "R", "R2", "N" or "1"
+ ///
+ public string GearName
+ {
+ get
+ {
+ int gear = Gear;
+
+ if ( _gearNameCache.TryGetValue( gear, out string gearName ) )
+ return gearName;
+
+ if ( gear == 0 )
+ gearName = "N";
+ else if ( gear > 0 )
+ gearName = Gear.ToString();
+ else
+ gearName = "R" + (ReverseGearCount > 1 ? -gear : "");
+
+ _gearNameCache[gear] = gearName;
+ return gearName;
+ }
+ }
+
+ private readonly Dictionary _gearNameCache = new();
+
+ public void LoadGearsFromGearingProfile()
+ {
+ if ( GearingProfile == null )
+ return;
+
+ int totalGears = GearingProfile.ReverseGears.Count + 1 + GearingProfile.ForwardGears.Count;
+ if ( Gears == null )
+ Gears = new( totalGears );
+ else
+ {
+ Gears.Clear();
+ Gears.Capacity = totalGears;
+ }
+
+ Gears.AddRange( GearingProfile.ReverseGears );
+ Gears.Add( 0 );
+ Gears.AddRange( GearingProfile.ForwardGears );
+ }
+ protected override void OnStart()
+ {
+ base.OnStart();
+ LoadGearsFromGearingProfile();
+ UpdateGearCounts();
+ Gear = 0;
+
+ AssignShiftDelegate();
+ }
+
+ ///
+ /// Total gear ratio of the transmission for current gear.
+ ///
+ private float CalculateTotalGearRatio()
+ {
+
+ //if ( TransmissionType == TransmissionShiftType.CVT )
+ //{
+ // float minRatio = Gears[gearIndex];
+ // float maxRatio = minRatio * 40f;
+ // float t = Math.Clamp( Controller.Engine.RPMPercent + (1f - Controller.Engine.ThrottlePosition), 0, 1 );
+ // float ratio = MathX.Lerp( maxRatio, minRatio, t ) * FinalGearRatio;
+ // return MathX.Lerp( CurrentGearRatio, ratio, Time.Delta * 5f );
+ //}
+ //else
+ return Gears[gearIndex] * FinalGearRatio;
+ }
+ public override float QueryAngularVelocity( float angularVelocity, float dt )
+ {
+ InputAngularVelocity = angularVelocity;
+
+ if ( CurrentGearRatio == 0 || OutputNameHash == 0 )
+ {
+ OutputAngularVelocity = 0f;
+ return angularVelocity;
+ }
+
+ OutputAngularVelocity = InputAngularVelocity / CurrentGearRatio;
+ return _output.QueryAngularVelocity( OutputAngularVelocity, dt ) * CurrentGearRatio;
+ }
+
+ public override float QueryInertia()
+ {
+ if ( OutputNameHash == 0 || CurrentGearRatio == 0 )
+ return Inertia;
+
+ return Inertia + _output.QueryInertia() / (CurrentGearRatio * CurrentGearRatio);
+ }
+
+
+ ///
+ /// Calculates the would-be RPM if none of the wheels was slipping.
+ ///
+ /// RPM as it would be if the wheels are not slipping or in the air.
+ private float CalculateNoSlipRPM()
+ {
+ float vehicleLocalVelocity = Controller.LocalVelocity.x.InchToMeter();
+
+ // Get the average no-slip wheel RPM
+ // Use the vehicle velocity as the friction velocities for the wheel are 0 when in air and
+ // because the shift RPM is not really required to be extremely precise, so slight offset
+ // between the vehicle position and velocity and the wheel ones is not important.
+ // Still, calculate for each wheel since radius might be different.
+ float angVelSum = 0f;
+ foreach ( VeloXWheel wheelComponent in Controller.MotorWheels )
+ {
+ angVelSum += vehicleLocalVelocity / wheelComponent.Radius;
+ }
+
+ // Apply total gear ratio to get the no-slip condition RPM
+ return AngularVelocityToRPM( angVelSum / Controller.MotorWheels.Count ) * CurrentGearRatio;
+ }
+
+ public override float ForwardStep( float torque, float inertiaSum, float dt )
+ {
+ InputTorque = torque;
+ InputInertia = inertiaSum;
+
+ UpdateGearCounts();
+
+ if ( _output == null )
+ return InputTorque;
+
+ // Update current gear ratio
+ CurrentGearRatio = CalculateTotalGearRatio();
+
+ // Run the shift function
+ _referenceShiftRPM = CalculateNoSlipRPM();
+ ShiftDelegate?.Invoke( Controller );
+
+ // Reset any input related to shifting, now that the shifting has been processed
+ //Controller.Input.ResetShiftFlags();
+
+ // Run the physics step
+ // No output, simply return the torque to the sender
+ if ( OutputNameHash == 0 )
+ return torque;
+
+ // In neutral, do not send any torque but update components downstram
+ if ( CurrentGearRatio < 1e-5f && CurrentGearRatio > -1e-5f )
+ {
+ OutputTorque = 0;
+ OutputInertia = InputInertia;
+ _output.ForwardStep( OutputTorque, OutputInertia, dt );
+ return torque;
+ }
+
+ // Always send torque to keep wheels updated
+ OutputTorque = torque * CurrentGearRatio;
+ OutputInertia = (inertiaSum + Inertia) * (CurrentGearRatio * CurrentGearRatio);
+ return _output.ForwardStep( torque * CurrentGearRatio, OutputInertia, dt ) / CurrentGearRatio;
+ }
+ private void UpdateGearCounts()
+ {
+ ForwardGearCount = 0;
+ ReverseGearCount = 0;
+ int gearCount = Gears.Count;
+ for ( int i = 0; i < gearCount; i++ )
+ {
+ float gear = Gears[i];
+ if ( gear > 0 )
+ ForwardGearCount++;
+ else if ( gear < 0 )
+ ReverseGearCount++;
+ }
+ }
+
+ private void AssignShiftDelegate()
+ {
+ if ( TransmissionType == TransmissionShiftType.Manual )
+ ShiftDelegate = ManualShift;
+ else if ( TransmissionType == TransmissionShiftType.Automatic )
+ ShiftDelegate = AutomaticShift;
+ //else if ( TransmissionType == TransmissionShiftType.CVT )
+ // ShiftDelegate = CVTShift;
+ }
+ private void ManualShift( VeloXBase car )
+ {
+ if ( car.IsShiftingUp )
+ {
+ ShiftInto( Gear + 1 );
+ return;
+ }
+
+ if ( car.IsShiftingDown )
+ {
+ ShiftInto( Gear - 1 );
+ return;
+ }
+
+ if ( HoldToKeepInGear )
+ {
+ _slipOutOfGearTimer += Time.Delta;
+ if ( Gear != 0 && _slipOutOfGearTimer > 0.1f )
+ ShiftInto( 0 );
+ }
+ }
+ ///
+ /// Shifts into given gear. 0 for neutral, less than 0 for reverse and above 0 for forward gears.
+ /// Does nothing if the target gear is equal to current gear.
+ ///
+ public void ShiftInto( int targetGear, bool instant = false )
+ {
+ // Clutch is not pressed above the set threshold, exit and do not shift.
+ if ( Controller.IsClutching > ClutchInputShiftThreshold )
+ return;
+
+ int currentGear = Gear;
+ bool isShiftFromOrToNeutral = targetGear == 0 || currentGear == 0;
+
+ //Debug.Log($"Shift from {currentGear} into {targetGear}");
+
+ // Check if shift can happen at all
+ if ( targetGear == currentGear || targetGear < -100 )
+ return;
+
+ // Convert gear to gear list index
+ int targetIndex = GearToIndex( targetGear );
+
+ // Check for gear list bounds
+ if ( targetIndex < 0 || targetIndex >= Gears.Count )
+ return;
+
+ if ( !IsShifting && (isShiftFromOrToNeutral || !IsPostShiftBanActive) )
+ {
+ ShiftCoroutine( currentGear, targetGear, isShiftFromOrToNeutral || instant );
+
+ // If in neutral reset the repeated input flat required for repeat input reverse
+ if ( targetGear == 0 )
+ _repeatInputFlag = false;
+ }
+ }
+
+ private async void ShiftCoroutine( int currentGear, int targetGear, bool instant )
+ {
+ if ( IsShifting )
+ return;
+
+ float dt = Time.Delta;
+ bool isManual = TransmissionType == TransmissionShiftType.Manual;
+
+ //Debug.Log($"Shift from {currentGear} to {targetGear}, instant: {instant}");
+
+ // Immediately start shift ban to prevent repeated shifts while this one has not finished
+ if ( !isManual )
+ IsPostShiftBanActive = true;
+
+ IsShifting = true;
+ ShiftProgress = 0f;
+
+ // Run the first half of shift timer
+ float shiftTimer = 0;
+ float halfDuration = ShiftDuration * 0.5f;
+ if ( !instant )
+ while ( shiftTimer < halfDuration )
+ {
+ ShiftProgress = shiftTimer / ShiftDuration;
+ shiftTimer += dt;
+ await GameTask.DelayRealtimeSeconds( dt );
+ }
+
+ // Do the shift at the half point of shift duration
+ Gear = targetGear;
+ if ( currentGear < targetGear )
+ OnGearUpShift?.Invoke();
+ else
+ OnGearDownShift?.Invoke();
+
+ OnGearShift?.Invoke();
+
+ // Run the second half of the shift timer
+ if ( !instant )
+ while ( shiftTimer < ShiftDuration )
+ {
+ ShiftProgress = shiftTimer / ShiftDuration;
+ shiftTimer += dt;
+ await GameTask.DelayRealtimeSeconds( dt );
+ }
+
+
+ // Shift has finished
+ ShiftProgress = 1f;
+ IsShifting = false;
+
+ // Run post shift ban only if not manual as blocking user input feels unresponsive and post shift ban
+ // exists to prevent auto transmission from hunting.
+ if ( !isManual )
+ {
+ // Post shift ban timer
+ float postShiftBanTimer = 0;
+ while ( postShiftBanTimer < PostShiftBan )
+ {
+ postShiftBanTimer += dt;
+ await GameTask.DelayRealtimeSeconds( dt );
+ }
+
+ // Post shift ban has finished
+ IsPostShiftBanActive = false;
+ }
+ }
+ private void CVTShift( VeloXBase car ) => AutomaticShift( car );
+
+ ///
+ /// Handles automatic and automatic sequential shifting.
+ ///
+ private void AutomaticShift( VeloXBase car )
+ {
+ float vehicleSpeed = car.ForwardSpeed;
+
+ float throttleInput = car.SwappedThrottle;
+ float brakeInput = car.SwappedBrakes;
+ int currentGear = Gear;
+ // Assign base shift points
+ _targetDownshiftRPM = _downshiftRPM;
+ _targetUpshiftRPM = _upshiftRPM;
+
+ // Calculate shift points for variable shift RPM
+ if ( VariableShiftPoint )
+ {
+ // Smooth throttle input so that the variable shift point does not shift suddenly and cause gear hunting
+ _smoothedThrottleInput = MathX.Lerp( _smoothedThrottleInput, throttleInput, Time.Delta * 2f );
+ float revLimiterRPM = car.Engine.RevLimiterRPM;
+
+ _targetUpshiftRPM = _upshiftRPM + Math.Clamp( _smoothedThrottleInput * VariableShiftIntensity, 0f, 1f ) * _upshiftRPM;
+ _targetUpshiftRPM = Math.Clamp( _targetUpshiftRPM, _upshiftRPM, revLimiterRPM * 0.97f );
+
+ _targetDownshiftRPM = _downshiftRPM + Math.Clamp( _smoothedThrottleInput * VariableShiftIntensity, 0f, 1f ) * _downshiftRPM;
+ _targetDownshiftRPM = Math.Clamp( _targetDownshiftRPM, car.Engine.IdleRPM * 1.1f, _targetUpshiftRPM * 0.7f );
+
+ // Add incline modifier
+ float inclineModifier = Math.Clamp( car.WorldRotation.Forward.Dot( Vector3.Up ) * InclineEffectCoeff, 0f, 1f );
+
+ _targetUpshiftRPM += revLimiterRPM * inclineModifier;
+ _targetDownshiftRPM += revLimiterRPM * inclineModifier;
+ }
+
+
+ // In neutral
+ if ( currentGear == 0 )
+ {
+ if ( DNRShiftType == AutomaticTransmissionDNRShiftType.Auto )
+ {
+ if ( throttleInput > INPUT_DEADZONE )
+ ShiftInto( 1 );
+ else if ( brakeInput > INPUT_DEADZONE )
+ ShiftInto( -1 );
+ }
+ else if ( DNRShiftType == AutomaticTransmissionDNRShiftType.RequireShiftInput )
+ {
+ if ( car.IsShiftingUp )
+ ShiftInto( 1 );
+ else if ( car.IsShiftingDown )
+ ShiftInto( -1 );
+ }
+ else if ( DNRShiftType == AutomaticTransmissionDNRShiftType.RepeatInput )
+ {
+ if ( _repeatInputFlag == false && throttleInput < INPUT_DEADZONE && brakeInput < INPUT_DEADZONE )
+ _repeatInputFlag = true;
+
+ if ( _repeatInputFlag )
+ {
+ if ( throttleInput > INPUT_DEADZONE )
+ ShiftInto( 1 );
+ else if ( brakeInput > INPUT_DEADZONE )
+ ShiftInto( -1 );
+ }
+ }
+ }
+ // In reverse
+ else if ( currentGear < 0 )
+ {
+ // Shift into neutral
+ if ( DNRShiftType == AutomaticTransmissionDNRShiftType.RequireShiftInput )
+ {
+ if ( car.IsShiftingUp )
+ ShiftInto( 0 );
+ }
+ else
+ {
+ if ( vehicleSpeed < DnrSpeedThreshold && (brakeInput > INPUT_DEADZONE || throttleInput < INPUT_DEADZONE) )
+ ShiftInto( 0 );
+ }
+
+ // Reverse upshift
+ float absGearMinusOne = currentGear - 1;
+ absGearMinusOne = absGearMinusOne < 0 ? -absGearMinusOne : absGearMinusOne;
+ if ( _referenceShiftRPM > TargetUpshiftRPM && absGearMinusOne < ReverseGearCount )
+ ShiftInto( currentGear - 1 );
+ // Reverse downshift
+ else if ( _referenceShiftRPM < TargetDownshiftRPM && currentGear < -1 )
+ ShiftInto( currentGear + 1 );
+ }
+ // In forward
+ else
+ {
+ if ( vehicleSpeed > 0.4f )
+ {
+ // Upshift
+ if ( currentGear < ForwardGearCount && _referenceShiftRPM > TargetUpshiftRPM )
+ {
+ if ( !IsSequential && AllowUpshiftGearSkipping )
+ {
+ int g = currentGear;
+
+ while ( g < ForwardGearCount )
+ {
+ g++;
+
+ float wouldBeEngineRPM = ReverseTransmitRPM( _referenceShiftRPM / CurrentGearRatio, g );
+ float shiftDurationPadding = Math.Clamp( ShiftDuration, 0, 1 ) * (_targetUpshiftRPM - _targetDownshiftRPM) * 0.25f;
+
+ if ( wouldBeEngineRPM < _targetDownshiftRPM + shiftDurationPadding )
+ {
+ g--;
+ break;
+ }
+ }
+ if ( g != currentGear )
+ {
+ ShiftInto( g );
+ }
+ }
+ else
+ {
+ ShiftInto( currentGear + 1 );
+ }
+ }
+ // Downshift
+ else if ( _referenceShiftRPM < TargetDownshiftRPM )
+ {
+ // Non-sequential
+ if ( !IsSequential && AllowDownshiftGearSkipping )
+ {
+ if ( currentGear != 1 )
+ {
+ int g = currentGear;
+ while ( g > 1 )
+ {
+ g--;
+ float wouldBeEngineRPM = ReverseTransmitRPM( _referenceShiftRPM / CurrentGearRatio, g );
+ if ( wouldBeEngineRPM > _targetUpshiftRPM )
+ {
+ g++;
+ break;
+ }
+ }
+
+ if ( g != currentGear )
+ {
+ ShiftInto( g );
+ }
+ }
+ else if ( vehicleSpeed < DnrSpeedThreshold && throttleInput < INPUT_DEADZONE
+ && DNRShiftType !=
+ AutomaticTransmissionDNRShiftType
+ .RequireShiftInput )
+ {
+ ShiftInto( 0 );
+ }
+ }
+ // Sequential
+ else
+ {
+ if ( currentGear != 1 )
+ {
+ ShiftInto( currentGear - 1 );
+ }
+ else if ( vehicleSpeed < DnrSpeedThreshold && throttleInput < INPUT_DEADZONE &&
+ brakeInput < INPUT_DEADZONE
+ && DNRShiftType !=
+ AutomaticTransmissionDNRShiftType.RequireShiftInput )
+ {
+ ShiftInto( 0 );
+ }
+ }
+ }
+ }
+ // Shift into neutral
+ else
+ {
+ if ( DNRShiftType != AutomaticTransmissionDNRShiftType.RequireShiftInput )
+ {
+ if ( throttleInput < INPUT_DEADZONE )
+ {
+ ShiftInto( 0 );
+ }
+ }
+ else
+ {
+ if ( car.IsShiftingDown )
+ {
+ ShiftInto( 0 );
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Converts axle RPM to engine RPM for given gear in Gears list.
+ ///
+ public float ReverseTransmitRPM( float inputRPM, int g )
+ {
+ float outRpm = inputRPM * Gears[GearToIndex( g )] * FinalGearRatio;
+ return Math.Abs( outRpm );
+ }
+
+}
diff --git a/Code/Base/Powertrain/WheelPowertrain.cs b/Code/Base/Powertrain/WheelPowertrain.cs
new file mode 100644
index 0000000..7ab7325
--- /dev/null
+++ b/Code/Base/Powertrain/WheelPowertrain.cs
@@ -0,0 +1,59 @@
+using Sandbox;
+using System;
+
+namespace VeloX;
+
+public partial class WheelPowertrain : PowertrainComponent
+{
+ protected override void OnAwake()
+ {
+ base.OnAwake();
+ Name ??= Wheel.ToString();
+ }
+ [Property] public VeloXWheel Wheel { get; set; }
+
+ protected override void OnStart()
+ {
+ _initialRollingResistance = Wheel.RollingResistanceTorque;
+ _initialWheelInertia = Wheel.BaseInertia;
+ }
+ private float _initialRollingResistance;
+ private float _initialWheelInertia;
+
+ public override float QueryAngularVelocity( float angularVelocity, float dt )
+ {
+ InputAngularVelocity = OutputAngularVelocity = Wheel.AngularVelocity;
+
+ return OutputAngularVelocity;
+ }
+
+ public override float QueryInertia()
+ {
+ // Calculate the base inertia of the wheel and scale it by the inverse of the dt.
+ float dtScale = Math.Clamp( Time.Delta, 0.01f, 0.05f ) / 0.005f;
+ float radius = Wheel.Radius;
+ return 0.5f * Wheel.Mass * radius * radius * dtScale;
+ }
+
+ public void ApplyRollingResistanceMultiplier( float multiplier )
+ {
+
+ Wheel.RollingResistanceTorque = _initialRollingResistance * multiplier;
+ }
+ public override float ForwardStep( float torque, float inertiaSum, float dt )
+ {
+ InputTorque = torque;
+ InputInertia = inertiaSum;
+
+ OutputTorque = InputTorque;
+ OutputInertia = Wheel.BaseInertia + inertiaSum;
+
+ Wheel.Torque = OutputTorque;
+ Wheel.Inertia = OutputInertia;
+
+ Wheel.AutoSimulate = false;
+ Wheel.StepPhys( Controller, dt );
+
+ return Math.Abs( Wheel.CounterTorque );
+ }
+}
diff --git a/Code/Base/VeloXBase.Engine.cs b/Code/Base/VeloXBase.Engine.cs
new file mode 100644
index 0000000..899e323
--- /dev/null
+++ b/Code/Base/VeloXBase.Engine.cs
@@ -0,0 +1,128 @@
+using Sandbox;
+using System;
+using System.Collections.Generic;
+namespace VeloX;
+
+public abstract partial class VeloXBase
+{
+
+ [Property, Feature( "Powertrain" )] public Engine Engine { get; set; }
+ [Property, Feature( "Powertrain" )] public Clutch Clutch { get; set; }
+ [Property, Feature( "Powertrain" )] public Transmission Transmission { get; set; }
+ [Property, Feature( "Powertrain" )] public Differential Differential { get; set; }
+
+ [Property, Feature( "Powertrain" )] public List MotorWheels { get; set; }
+
+ private GameObject powertrainGameObject;
+
+ [Button, Feature( "Powertrain" )]
+ internal void CreatePowertrain()
+ {
+ using var undoScope = Scene.Editor?.UndoScope( "Create Powertrain" ).WithComponentCreations().WithGameObjectCreations().Push();
+ if ( !powertrainGameObject.IsValid() )
+ {
+ powertrainGameObject = null;
+ }
+ powertrainGameObject ??= new GameObject( true, "Powertrain" );
+
+ if ( !Engine.IsValid() )
+ Engine = new GameObject( powertrainGameObject, true, "Engine" ).GetOrAddComponent();
+
+ Engine.Controller = this;
+ Engine.Inertia = 0.25f;
+ Engine.Ignition = false;
+
+ if ( !Clutch.IsValid() )
+ Clutch = new GameObject( Engine.GameObject, true, "Clutch" ).GetOrAddComponent();
+
+ Clutch.Controller = this;
+ Clutch.Inertia = 0.02f;
+
+ Engine.Output = Clutch;
+
+ if ( !Transmission.IsValid() )
+ Transmission = new GameObject( Clutch.GameObject, true, "Transmission" ).GetOrAddComponent();
+
+ Transmission.Controller = this;
+ Transmission.Inertia = 0.01f;
+
+ Clutch.Output = Transmission;
+
+ Differential = new TreeBuilder( Transmission, MotorWheels ).Root.Diff;
+
+ Transmission.Output = Differential;
+
+ //PowertrainWheels = Differential.Components.GetAll( FindMode.InDescendants ).ToList();
+
+ }
+}
+
+
+
+internal class TreeNode
+{
+ internal TreeNode Left { get; set; }
+ internal TreeNode Right { get; set; }
+ public WheelPowertrain Item { get; set; }
+ public Differential Diff { get; set; }
+ public bool IsLeaf => Left == null && Right == null;
+}
+
+internal class TreeBuilder
+{
+ internal TreeNode Root { get; private set; }
+
+ internal TreeBuilder( PowertrainComponent parent, List items )
+ {
+ if ( items == null || items.Count == 0 )
+ throw new ArgumentException( "Items list cannot be null or empty." );
+
+ Root = BuildTree( parent, items, 0, items.Count - 1 );
+ }
+
+ private static TreeNode BuildTree( PowertrainComponent parent, List items, int start, int end )
+ {
+ if ( start > end )
+ return null;
+
+ if ( start == end )
+ {
+
+ var leaf = new TreeNode() { Item = new GameObject( parent.GameObject, true, $"Wheel {items[start].GameObject.Name}" ).GetOrAddComponent() };
+ leaf.Item.Controller = parent.Controller;
+ leaf.Item.Wheel = items[start];
+ leaf.Item.Inertia = 0.01f;
+ var parentd = parent as Differential;
+ if ( (start + 1) % 2 == 0 )
+ {
+ GameTask.RunInThreadAsync( () =>
+ {
+ parentd.OutputB = leaf.Item;
+ } );
+ }
+ else
+ {
+ GameTask.RunInThreadAsync( () =>
+ {
+ parent.Output = leaf.Item;
+ } );
+ }
+
+ return leaf;
+ }
+
+ int mid = (start + end) / 2;
+ var diff = new GameObject( parent.GameObject, true, "Differential" ).GetOrAddComponent();
+ diff.Controller = parent.Controller;
+ diff.Inertia = 0.1f;
+ var node = new TreeNode
+ {
+ Left = BuildTree( diff, items, start, mid ),
+ Right = BuildTree( diff, items, mid + 1, end ),
+ Diff = diff
+ };
+ diff.Output = node.Left.Diff;
+ diff.OutputB = node.Right.Diff;
+ return node;
+ }
+}
diff --git a/Code/Base/VeloXBase.Input.cs b/Code/Base/VeloXBase.Input.cs
index faa966b..ac2507e 100644
--- a/Code/Base/VeloXBase.Input.cs
+++ b/Code/Base/VeloXBase.Input.cs
@@ -5,19 +5,149 @@ namespace VeloX;
public abstract partial class VeloXBase
{
[Property, Feature( "Input" )] internal InputResolver Input { get; set; } = new();
+ [Property, Feature( "Input" )] public Connection Driver { get => Input.Driver; set => Input.Driver = value; }
- private Guid _guid;
- [Sync( SyncFlags.FromHost )]
- public Guid ConnectionID
+ private bool IsDriverActive => Driver is not null;
+
+ public Vector2 MouseDelta => IsDriverActive ? Input.MouseDelta : default;
+ public Vector2 MouseWheel => IsDriverActive ? Input.MouseWheel : default;
+ public Angles AnalogLook => IsDriverActive ? Input.AnalogLook : default;
+ public Vector3 AnalogMove => IsDriverActive ? Input.AnalogMove : default;
+
+ private float throttle;
+ private float brakes;
+ private float steerAngle;
+ private float handbrake;
+
+ public bool CanInputSwapping { get; set; } = true;
+ public bool IsInputSwapped => CanInputSwapping && Transmission.Gear < 0;
+
+ public bool IsShiftingDown { get; private set; }
+
+
+ public bool IsShiftingUp { get; private set; }
+
+ public float IsClutching { get; private set; }
+
+ public float SwappedThrottle => IsInputSwapped ? Brakes : Throttle;
+ public float SwappedBrakes => IsInputSwapped ? Throttle : Brakes;
+
+
+ [Sync]
+ public float VerticalInput
{
- get => _guid;
+ get => throttle - brakes;
set
{
- _guid = value;
- Input.Driver = Connection.Find( _guid );
+ float clampedValue = Math.Clamp( value, -1, 1 );
+
+ if ( value > 0 )
+ {
+ throttle = clampedValue;
+ brakes = 0;
+ }
+ else
+ {
+ throttle = 0;
+ brakes = -clampedValue;
+ }
}
}
- [Property, Feature( "Input" )] public bool IsDriver => ConnectionID == Connection.Local.Id;
+
+ ///
+ /// Throttle axis.
+ /// For combined throttle/brake input use 'VerticalInput' instead.
+ ///
+ [Sync( SyncFlags.Interpolate ), Range( 0, 1 ), Property]
+ public float Throttle
+ {
+ get => throttle;
+ set => throttle = Math.Clamp( value, 0, 1 );
+ }
+
+ ///
+ /// Brake axis.
+ /// For combined throttle/brake input use 'VerticalInput' instead.
+ ///
+ [Sync]
+ public float Brakes
+ {
+ get => brakes;
+ set => brakes = Math.Clamp( value, 0, 1 );
+ }
+
+ [Sync]
+ public float SteeringAngle
+ {
+ get => steerAngle;
+ set => steerAngle = Math.Clamp( value, -1, 1 );
+ }
+ [Sync]
+ public float Handbrake
+ {
+ get => handbrake;
+ set => handbrake = Math.Clamp( value, 0, 1 );
+ }
+
+
+ public void ResetInput()
+ {
+ VerticalInput = 0;
+ Handbrake = 0;
+
+ SteeringAngle = 0;
+
+ IsClutching = 0;
+
+ IsShiftingUp = false;
+ IsShiftingDown = false;
+ }
+ private void UpdateInput()
+ {
+ VerticalInput = TotalSpeed < 10 ? Input.AnalogMove.x * 0.5f : Input.AnalogMove.x;
+ Handbrake = Input.Down( "Jump" ) ? 1 : 0;
+
+ SteeringAngle = Input.AnalogMove.y;
+
+ IsClutching = (Input.Down( "Run" ) || Input.Down( "Jump" )) ? 1 : 0;
+
+ IsShiftingUp = Input.Pressed( "Attack1" );
+ IsShiftingDown = Input.Pressed( "Attack2" );
+
+ if ( TotalSpeed < 150 && Driver is null )
+ Handbrake = 1;
+
+ }
+
+
+ public bool Down( string action )
+ {
+ return IsDriverActive && Input.Down( action );
+ }
+
+ public bool Pressed( string action )
+ {
+ return IsDriverActive && Input.Pressed( action );
+ }
+
+ public bool Released( string action )
+ {
+ return IsDriverActive && Input.Released( action );
+ }
+
+ public void TriggerHaptics( float leftMotor, float rightMotor, float leftTrigger = 0f, float rightTrigger = 0f, int duration = 500 )
+ {
+ if ( IsDriverActive )
+ {
+ Input.TriggerHaptics( leftMotor, rightMotor, leftTrigger, rightTrigger, duration );
+ }
+ }
+
+ public void StopAllHaptics()
+ {
+ if ( IsDriverActive )
+ Input.StopAllHaptics();
+ }
}
diff --git a/Code/Base/VeloXBase.Phys.cs b/Code/Base/VeloXBase.Phys.cs
index 79d90cf..d3b44bb 100644
--- a/Code/Base/VeloXBase.Phys.cs
+++ b/Code/Base/VeloXBase.Phys.cs
@@ -1,41 +1,57 @@
using Sandbox;
-using System;
namespace VeloX;
public abstract partial class VeloXBase
{
+ private Vector3 linForce;
private Vector3 angForce;
private void PhysicsSimulate()
{
+ if ( Body.Sleeping && Input.AnalogMove.x == 0 )
+ return;
+
var drag = AngularDrag;
var mass = Body.Mass;
var angVel = Body.AngularVelocity;
- angForce.x = angVel.x * drag.x * mass;
- angForce.y = angVel.y * drag.y * mass;
- angForce.z = angVel.z * drag.z * mass;
+ linForce.x = 0;
+ linForce.y = 0;
+ linForce.z = 0;
+ angForce = angForce.WithX( angVel.x * drag.x * mass * 1000 );
+ angForce = angForce.WithY( angVel.y * drag.y * mass * 1000 );
+ angForce = angForce.WithZ( angVel.z * drag.z * mass * 1000 );
if ( Wheels.Count > 0 )
- foreach ( var v in Wheels )
- if ( v.AutoPhysics ) v.DoPhysics( this );
-
- var totalSpeed = TotalSpeed + Math.Abs( Body.AngularVelocity.z );
-
- var factor = 1 - Math.Clamp( totalSpeed / 30, 0, 1 );
-
- if ( factor > 0.1f )
{
- var vel = Body.Velocity;
+ Vector3 vehVel = Body.Velocity;
+ Vector3 vehAngVel = Body.AngularVelocity;
- var rt = WorldRotation.Right;
+ var dt = Time.Delta;
+ CombinedLoad = 0;
+ foreach ( var v in Wheels )
+ CombinedLoad += v.Fz;
+ foreach ( var v in Wheels )
+ {
+ v.Brake = SwappedBrakes;
+ if ( !v.IsFront )
+ v.Brake += Handbrake;
- var force = rt.Dot( vel ) / Time.Delta * mass * factor * rt;
- Body.ApplyForce( -force );
+ v.Update( this, in dt );
+ v.DoPhysics( in dt );
+ }
+
+
+
+ Body.Velocity = vehVel;
+ Body.AngularVelocity = vehAngVel;
}
+ Body.ApplyForce( linForce );
Body.ApplyTorque( angForce );
+
+
}
}
diff --git a/Code/Base/VeloXBase.Wheel.cs b/Code/Base/VeloXBase.Wheel.cs
index 79dbfdc..493fe0f 100644
--- a/Code/Base/VeloXBase.Wheel.cs
+++ b/Code/Base/VeloXBase.Wheel.cs
@@ -1,13 +1,16 @@
using Sandbox;
using System.Collections.Generic;
+using System.Linq;
namespace VeloX;
public abstract partial class VeloXBase
{
+ public float CombinedLoad { get; protected set; }
[Property] public List Wheels { get; set; }
[Property] public TagSet WheelIgnoredTags { get; set; }
+ public bool IsOnGround => Wheels.Any( x => x.IsOnGround );
public List FindWheels() => [.. Components.GetAll()];
@@ -16,5 +19,4 @@ public abstract partial class VeloXBase
{
Wheels = FindWheels();
}
-
}
diff --git a/Code/Base/VeloXBase.cs b/Code/Base/VeloXBase.cs
index 6215466..8a87bef 100644
--- a/Code/Base/VeloXBase.cs
+++ b/Code/Base/VeloXBase.cs
@@ -7,8 +7,7 @@ public abstract partial class VeloXBase : Component
{
[Sync] public WaterState WaterState { get; set; }
[Sync] public bool IsEngineOnFire { get; set; }
- [Sync, Range( 0, 1 ), Property] public float Brake { get; set; }
- [Sync( SyncFlags.Interpolate ), Range( 0, 1 ), Property] public float Throttle { get; set; }
+ [Property, Sync] public EngineState EngineState { get; set; }
[Property] public Vector3 AngularDrag { get; set; } = new( -0.1f, -0.1f, -3 );
[Property] public float Mass { get; set; } = 900;
@@ -23,7 +22,7 @@ public abstract partial class VeloXBase : Component
protected override void OnFixedUpdate()
{
- if ( !IsDriver )
+ if ( IsProxy )
return;
LocalVelocity = WorldTransform.PointToLocal( WorldPosition + Body.Velocity );
@@ -31,7 +30,9 @@ public abstract partial class VeloXBase : Component
TotalSpeed = LocalVelocity.Length;
Body.PhysicsBody.Mass = Mass;
+ UpdateInput();
PhysicsSimulate();
+
}
}
diff --git a/Code/Base/Wheel/Pacejka.cs b/Code/Base/Wheel/Pacejka.cs
deleted file mode 100644
index 65c8b1d..0000000
--- a/Code/Base/Wheel/Pacejka.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using Sandbox;
-using Sandbox.Services;
-using System;
-using System.Threading;
-
-namespace VeloX;
-public class Pacejka
-{
- public class PacejkaPreset
- {
- [KeyProperty] public float B { get; set; } = 10.86f;
- [KeyProperty] public float C { get; set; } = 2.15f;
- [KeyProperty] public float D { get; set; } = 0.933f;
- [KeyProperty] public float E { get; set; } = 0.992f;
-
- public float Evaluate( float slip ) => D * MathF.Sin( C * MathF.Atan( B * slip - E * (B * slip - MathF.Atan( B * slip )) ) );
-
- public float GetPeakSlip()
- {
- float peakSlip = -1;
- float yMax = 0;
-
- for ( float i = 0; i < 1f; i += 0.01f )
- {
- float y = Evaluate( i );
- if ( y > yMax )
- {
- yMax = y;
- peakSlip = i;
- }
- }
-
- return peakSlip;
- }
-
- }
-
- public PacejkaPreset Lateral { get; set; } = new();
- public PacejkaPreset Longitudinal { get; set; } = new();
-
- public float PacejkaFx( float slip ) => Longitudinal.Evaluate( slip );
- public float PacejkaFy( float slip ) => Lateral.Evaluate( slip );
-}
diff --git a/Code/Base/Wheel/Suspension/BasicSuspension.cs b/Code/Base/Wheel/Suspension/BasicSuspension.cs
deleted file mode 100644
index bed19d6..0000000
--- a/Code/Base/Wheel/Suspension/BasicSuspension.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace VeloX;
-
-public class BasicSuspension
-{
- private readonly Hinge Hinge;
-
- public BasicSuspension( Vector3 wheel, Vector3 hingeBody, Vector3 hingeWheel )
- {
- Vector3 hingePoint = wheel - (hingeWheel - hingeBody);
- Hinge = new Hinge( hingePoint, hingeWheel - hingeBody );
- }
-
- public virtual void GetWheelTransform( float travel, out Rotation rotation, out Vector3 position )
- {
- rotation = Rotation.Identity;
- position = Hinge.Rotate( travel );
- }
-}
diff --git a/Code/Base/Wheel/Suspension/Hinge.cs b/Code/Base/Wheel/Suspension/Hinge.cs
deleted file mode 100644
index db8d1d9..0000000
--- a/Code/Base/Wheel/Suspension/Hinge.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-using System;
-
-namespace VeloX;
-
-internal readonly struct Hinge( Vector3 hinge_anchor, Vector3 hinge_arm )
-{
- [Description( "the point that the wheels are rotated around as the suspension compresses" )]
- public readonly Vector3 Anchor = hinge_anchor;
-
- [Description( "anchor to wheel vector" )]
- public readonly Vector3 Arm = hinge_arm;
-
- [Description( "arm length squared" )]
- public readonly float LengthSquared = hinge_arm.Dot( hinge_arm );
-
- [Description( "1 / arm length in hinge axis normal plane" )]
- public readonly float NormXY = 1 / MathF.Sqrt( hinge_arm.x * hinge_arm.x + hinge_arm.y * hinge_arm.y );
-
- public readonly Vector3 Rotate( float travel )
- {
- float z = Arm.z + travel;
- float lengthSq = MathF.Max( LengthSquared - z * z, 0.0f );
- float nxy = NormXY * MathF.Sqrt( lengthSq );
- return Anchor + new Vector3( Arm.x * nxy, Arm.y * nxy, z );
- }
-}
diff --git a/Code/Base/Wheel/Suspension/MacPhersonSuspension.cs b/Code/Base/Wheel/Suspension/MacPhersonSuspension.cs
deleted file mode 100644
index 6cd1044..0000000
--- a/Code/Base/Wheel/Suspension/MacPhersonSuspension.cs
+++ /dev/null
@@ -1,32 +0,0 @@
-using Sandbox;
-using System;
-
-namespace VeloX;
-public class MacPhersonSuspension
-{
- public readonly Vector3 WheelOffset;
- public readonly Vector3 UprightTop;
- public readonly Vector3 UprightAxis;
- private readonly Hinge Hinge;
-
- public MacPhersonSuspension( Vector3 wheel, Vector3 strutBody, Vector3 strutWheel, Vector3 hingeBody )
- {
- WheelOffset = wheel - strutWheel;
- UprightTop = strutBody;
- UprightAxis = (strutBody - strutWheel).Normal;
- Hinge = new( hingeBody, strutWheel - hingeBody );
- }
-
- public void GetWheelTransform( float travel, out Rotation rotation, out Vector3 position )
- {
- Vector3 hingeEnd = Hinge.Rotate( travel );
- Vector3 uprightAxisNew = (UprightTop - hingeEnd).Normal;
-
- rotation = Rotation.FromAxis(
- Vector3.Cross( UprightAxis, uprightAxisNew ),
- MathF.Acos( Vector3.Dot( UprightAxis, uprightAxisNew ) ).RadianToDegree()
- );
-
- position = hingeEnd + WheelOffset.Transform( rotation );
- }
-}
diff --git a/Code/Base/Wheel/TirePreset.cs b/Code/Base/Wheel/TirePreset.cs
index 03b9328..11966b5 100644
--- a/Code/Base/Wheel/TirePreset.cs
+++ b/Code/Base/Wheel/TirePreset.cs
@@ -4,57 +4,45 @@ using System;
namespace VeloX;
-[GameResource( "Wheel Friction", "tire", "Wheel Friction", Category = "VeloX", Icon = "radio_button_checked" )]
+[AssetType( Name = "Wheel Friction", Extension = "tire", Category = "VeloX" )]
public class TirePreset : GameResource
{
+ public static TirePreset Default { get; } = ResourceLibrary.Get( "frictions/default.tire" );
- [Property] public Pacejka Pacejka { get; set; }
+ private float peakSlip = -1;
- public float RollResistanceLin { get; set; } = 1E-3f;
- public float RollResistanceQuad { get; set; } = 1E-6f;
+ [Property] public float B { get; set; } = 10.86f;
+ [Property] public float C { get; set; } = 2.15f;
+ [Property] public float D { get; set; } = 0.933f;
+ [Property] public float E { get; set; } = 0.992f;
- public float GetRollingResistance( float velocity, float resistance_factor )
- { // surface influence on rolling resistance
- float resistance = resistance_factor * RollResistanceLin;
-
- // heat due to tire deformation increases rolling resistance
- // approximate by quadratic function
- resistance += velocity * velocity * RollResistanceQuad;
-
- return resistance;
- }
-
- public static void ComputeSlip( float lon_velocity, float lat_velocity, float rot_velocity, float wheel_radius, out float slip_ratio, out float slip_angle )
+ public float GetPeakSlip()
{
- var abs_lon = Math.Max( MathF.Abs( lon_velocity ), 1e-3f );
-
- slip_ratio = lon_velocity - rot_velocity * wheel_radius;
-
- if ( abs_lon >= 0.005f )
- slip_ratio /= abs_lon;
- else
- slip_ratio *= abs_lon;
-
- if ( abs_lon >= 0.5f )
- slip_angle = MathF.Atan2( -lat_velocity, abs_lon ).RadianToDegree() / 50f;
- else
- slip_angle = -lat_velocity * (0.01f / Time.Delta);
-
- slip_ratio = Math.Clamp( slip_ratio, -1, 1 );
- slip_angle = Math.Clamp( slip_angle, -1, 1 );
+ if ( peakSlip == -1 )
+ peakSlip = CalcPeakSlip();
+ return peakSlip;
}
- /// approximate asin(x) = x + x^3/6 for +-18 deg range
- public static float ComputeCamberAngle( float sin_camber )
+
+ public float Evaluate( float t ) => D * MathF.Sin( C * MathF.Atan( B * t - E * (B * t - MathF.Atan( B * t )) ) );
+
+ private float CalcPeakSlip()
{
- float sc = Math.Clamp( sin_camber, -0.3f, 0.3f );
- return ((1 / 6.0f) * (sc * sc) + 1) * sc;
+ float peakSlip = -1;
+ float yMax = 0;
+
+ for ( float i = 0; i < 1f; i += 0.01f )
+ {
+ float y = Evaluate( i );
+ if ( y > yMax )
+ {
+ yMax = y;
+ peakSlip = i;
+ }
+ }
+
+ return peakSlip;
}
- public static float ComputeCamberVelocity( float sa, float vx )
- {
- float tansa = (1 / 3.0f * (sa * sa) + 1) * sa;
- return tansa * vx;
- }
}
diff --git a/Code/Base/Wheel/VeloXWheel.Friction.cs b/Code/Base/Wheel/VeloXWheel.Friction.cs
new file mode 100644
index 0000000..21db5e1
--- /dev/null
+++ b/Code/Base/Wheel/VeloXWheel.Friction.cs
@@ -0,0 +1,299 @@
+using Sandbox;
+using System;
+
+namespace VeloX;
+
+
+public partial class VeloXWheel
+{
+ ///
+ /// Constant torque acting similar to brake torque.
+ /// Imitates rolling resistance.
+ ///
+ [Property, Range( 0, 500 ), Sync] public float RollingResistanceTorque { get; set; } = 30f;
+
+ ///
+ /// The percentage this wheel is contributing to the total vehicle load bearing.
+ ///
+ public float LoadContribution { get; set; } = 0.25f;
+
+ ///
+ /// Maximum load the tire is rated for in [N].
+ /// Used to calculate friction.Default value is adequate for most cars but
+ /// larger and heavier vehicles such as semi trucks will use higher values.
+ /// A good rule of the thumb is that this value should be 2x the Load
+ /// while vehicle is stationary.
+ ///
+ [Property, Sync] public float LoadRating { get; set; } = 5400;
+
+
+ ///
+ /// The amount of torque returned by the wheel.
+ /// Under no-slip conditions this will be equal to the torque that was input.
+ /// When there is wheel spin, the value will be less than the input torque.
+ ///
+ public float CounterTorque { get; private set; }
+
+ [Property, Sync] public bool AutoSetFriction { get; set; } = true;
+ [Property, Sync] public bool UseGroundVelocity { get; set; } = true;
+
+ [Property, Range( 0, 2 )] public float BrakeMult { get; set; } = 1f;
+ [Property] public Friction ForwardFriction = new();
+ [Property] public Friction SidewayFriction = new();
+ public Vector3 FrictionForce;
+
+
+ private Vector3 hitContactVelocity;
+ private Vector3 hitForwardDirection;
+ private Vector3 hitSidewaysDirection;
+
+ public Vector3 ContactRight => hitSidewaysDirection;
+ public Vector3 ContactForward => hitForwardDirection;
+
+ [Sync]
+ public float LongitudinalSlip
+ {
+ get => ForwardFriction.Slip;
+ private set => ForwardFriction.Slip = value;
+ }
+
+ public float LongitudinalSpeed => ForwardFriction.Speed;
+ public bool IsSkiddingLongitudinally => NormalizedLongitudinalSlip > 0.35f;
+ public float NormalizedLongitudinalSlip => Math.Clamp( Math.Abs( LongitudinalSlip ), 0, 1 );
+
+
+ [Sync]
+ public float LateralSlip
+ {
+ get => SidewayFriction.Slip;
+ private set => SidewayFriction.Slip = value;
+ }
+
+ public float LateralSpeed => SidewayFriction.Speed;
+ public bool IsSkiddingLaterally => NormalizedLateralSlip > 0.35f;
+ public float NormalizedLateralSlip => Math.Clamp( Math.Abs( LateralSlip ), 0, 1 );
+ public bool IsSkidding => IsSkiddingLaterally || IsSkiddingLongitudinally;
+ public float NormalizedSlip => (NormalizedLateralSlip + NormalizedLongitudinalSlip) / 2f;
+
+ private void UpdateHitVariables()
+ {
+ if ( IsOnGround )
+ {
+ hitContactVelocity = Vehicle.Body.GetVelocityAtPoint( ContactPosition + Vehicle.Body.MassCenter );
+
+ hitForwardDirection = ContactNormal.Cross( TransformRotationSteer.Right ).Normal;
+ hitSidewaysDirection = Rotation.FromAxis( ContactNormal, 90f ) * hitForwardDirection;
+
+ ForwardFriction.Speed = hitContactVelocity.Dot( hitForwardDirection ).InchToMeter();
+ SidewayFriction.Speed = hitContactVelocity.Dot( hitSidewaysDirection ).InchToMeter();
+ }
+ else
+ {
+ ForwardFriction.Speed = 0f;
+ SidewayFriction.Speed = 0f;
+ }
+ }
+
+
+ private Vector3 lowSpeedReferencePosition;
+ private bool lowSpeedReferenceIsSet;
+ private Vector3 currentPosition;
+ private Vector3 referenceError;
+ private Vector3 correctiveForce;
+ private void UpdateFriction( float dt )
+ {
+
+
+ var motorTorque = DriveTorque;
+ var brakeTorque = BrakeTorque * BrakeMult;
+
+ float allWheelLoadSum = Vehicle.CombinedLoad;
+
+ LoadContribution = allWheelLoadSum == 0 ? 1f : Fz / allWheelLoadSum;
+
+ float mRadius = Radius;
+
+ float invDt = 1f / dt;
+ float invRadius = 1f / mRadius;
+ float inertia = Inertia;
+ float invInertia = 1f / Inertia;
+
+ float loadClamped = Math.Clamp( Fz, 0, LoadRating );
+
+ float forwardLoadFactor = loadClamped * 1.35f;
+ float sideLoadFactor = loadClamped * 1.9f;
+
+ float loadPercent = Math.Clamp( Fz / LoadRating, 0f, 1f );
+ float slipLoadModifier = 1f - loadPercent * 0.4f;
+ //DebugOverlay.Text( WorldPosition, SidewayFriction.Speed.ToString(), overlay: true );
+
+
+ float mass = Vehicle.Body.Mass;
+ float absForwardSpeed = Math.Abs( ForwardFriction.Speed );
+ float forwardForceClamp = mass * LoadContribution * absForwardSpeed * invDt;
+ float absSideSpeed = Math.Abs( SidewayFriction.Speed );
+ float sideForceClamp = mass * LoadContribution * absSideSpeed * invDt;
+
+ float forwardSpeedClamp = 1.5f * (dt / 0.005f);
+ forwardSpeedClamp = Math.Clamp( forwardSpeedClamp, 1.5f, 10f );
+ float clampedAbsForwardSpeed = Math.Max( absForwardSpeed, forwardSpeedClamp );
+
+ // Calculate effect of camber on friction
+ float camberFrictionCoeff = Math.Max( 0, Vehicle.WorldRotation.Up.Dot( ContactNormal ) );
+
+ float peakForwardFrictionForce = forwardLoadFactor;
+ float absCombinedBrakeTorque = Math.Max( 0, brakeTorque + RollingResistanceTorque );
+
+ float signedCombinedBrakeTorque = absCombinedBrakeTorque * -Math.Sign( ForwardFriction.Speed );
+ float signedCombinedBrakeForce = signedCombinedBrakeTorque * invRadius;
+ float motorForce = motorTorque * invRadius;
+ float forwardInputForce = motorForce + signedCombinedBrakeForce;
+ float absMotorTorque = Math.Abs( motorTorque );
+ float absBrakeTorque = Math.Abs( brakeTorque );
+
+ float maxForwardForce = Tire.Evaluate( Math.Abs( ForwardFriction.Slip ) ) * Math.Min( peakForwardFrictionForce, forwardForceClamp );
+
+ maxForwardForce = absMotorTorque < absBrakeTorque ? maxForwardForce : peakForwardFrictionForce;
+ ForwardFriction.Force = forwardInputForce > maxForwardForce ? maxForwardForce
+ : forwardInputForce < -maxForwardForce ? -maxForwardForce : forwardInputForce;
+
+ bool wheelIsBlocked = false;
+ if ( IsOnGround )
+ {
+ float combinedWheelForce = motorForce + absCombinedBrakeTorque * invRadius * -Math.Sign( AngularVelocity );
+ float wheelForceClampOverflow = 0;
+ if ( (combinedWheelForce >= 0 && AngularVelocity < 0) || (combinedWheelForce < 0 && AngularVelocity > 0) )
+ {
+ float absWheelForceClamp = Math.Abs( AngularVelocity ) * inertia * invRadius * invDt;
+ float absCombinedWheelForce = combinedWheelForce < 0 ? -combinedWheelForce : combinedWheelForce;
+ float wheelForceDiff = absCombinedWheelForce - absWheelForceClamp;
+ wheelForceClampOverflow = Math.Max( 0, wheelForceDiff ) * Math.Sign( combinedWheelForce );
+ combinedWheelForce = Math.Clamp( combinedWheelForce, -absWheelForceClamp, absWheelForceClamp );
+ }
+ AngularVelocity += combinedWheelForce * mRadius * invInertia * dt;
+
+ // Surface (corrective) force
+ float noSlipAngularVelocity = ForwardFriction.Speed * invRadius;
+ float angularVelocityError = AngularVelocity - noSlipAngularVelocity;
+
+ float angularVelocityCorrectionForce = Math.Clamp( -angularVelocityError * inertia * invRadius * invDt, -maxForwardForce, maxForwardForce );
+ if ( absMotorTorque < absBrakeTorque && Math.Abs( wheelForceClampOverflow ) > Math.Abs( angularVelocityCorrectionForce ) )
+ {
+ wheelIsBlocked = true;
+ AngularVelocity += ForwardFriction.Speed > 0 ? 1e-10f : -1e-10f;
+ }
+ else
+ {
+ AngularVelocity += angularVelocityCorrectionForce * mRadius * invInertia * dt;
+ }
+ }
+ else
+ {
+ float maxBrakeTorque = AngularVelocity * inertia * invDt + motorTorque;
+ maxBrakeTorque = maxBrakeTorque < 0 ? -maxBrakeTorque : maxBrakeTorque;
+ float brakeTorqueSign = AngularVelocity < 0f ? -1f : 1f;
+ float clampedBrakeTorque = Math.Clamp( absCombinedBrakeTorque, -maxBrakeTorque, maxBrakeTorque );
+ AngularVelocity += (motorTorque - brakeTorqueSign * clampedBrakeTorque) * invInertia * dt;
+ }
+
+ float absAngularVelocity = AngularVelocity < 0 ? -AngularVelocity : AngularVelocity;
+ float maxCounterTorque = inertia * absAngularVelocity;
+ CounterTorque = Math.Clamp( (signedCombinedBrakeForce - ForwardFriction.Force) * mRadius, -maxCounterTorque, maxCounterTorque );
+
+
+ ForwardFriction.Slip = (ForwardFriction.Speed - AngularVelocity * mRadius) / clampedAbsForwardSpeed;
+ ForwardFriction.Slip *= slipLoadModifier;
+
+ SidewayFriction.Slip = MathF.Atan2( SidewayFriction.Speed, clampedAbsForwardSpeed ).RadianToDegree() * 0.01111f;
+ SidewayFriction.Slip *= slipLoadModifier;
+
+ float sideSlipSign = SidewayFriction.Slip > 0 ? 1 : -1;
+ float absSideSlip = Math.Abs( SidewayFriction.Slip );
+ float peakSideFrictionForce = sideLoadFactor;
+
+ float sideForce = -sideSlipSign * Tire.Evaluate( absSideSlip ) * sideLoadFactor;
+ SidewayFriction.Force = Math.Clamp( sideForce, -sideForceClamp, sideForceClamp );
+ SidewayFriction.Force *= camberFrictionCoeff;
+
+ if ( IsOnGround && absForwardSpeed < 0.12f && absSideSpeed < 0.12f )
+ {
+ float verticalOffset = RestLength + mRadius;
+ var transformPosition = WorldPosition;
+
+ var transformUp = TransformRotationSteer.Up;
+
+ currentPosition.x = transformPosition.x - transformUp.x * verticalOffset;
+ currentPosition.y = transformPosition.y - transformUp.y * verticalOffset;
+ currentPosition.z = transformPosition.z - transformUp.z * verticalOffset;
+
+ if ( !lowSpeedReferenceIsSet )
+ {
+ lowSpeedReferenceIsSet = true;
+ lowSpeedReferencePosition = currentPosition;
+ }
+ else
+ {
+ referenceError.x = lowSpeedReferencePosition.x - currentPosition.x;
+ referenceError.y = lowSpeedReferencePosition.y - currentPosition.y;
+ referenceError.z = lowSpeedReferencePosition.z - currentPosition.z;
+
+ correctiveForce.x = invDt * LoadContribution * mass * referenceError.x;
+ correctiveForce.y = invDt * LoadContribution * mass * referenceError.y;
+ correctiveForce.z = invDt * LoadContribution * mass * referenceError.z;
+
+ if ( wheelIsBlocked && absAngularVelocity < 0.5f )
+ {
+ ForwardFriction.Force += correctiveForce.Dot( hitForwardDirection );
+ }
+ SidewayFriction.Force += correctiveForce.Dot( hitSidewaysDirection );
+
+ }
+
+ }
+ else
+ {
+ lowSpeedReferenceIsSet = false;
+ }
+ ForwardFriction.Force = Math.Clamp( ForwardFriction.Force, -peakForwardFrictionForce, peakForwardFrictionForce );
+
+ SidewayFriction.Force = Math.Clamp( SidewayFriction.Force, -peakSideFrictionForce, peakSideFrictionForce );
+
+ if ( absForwardSpeed > 2f || absAngularVelocity > 4f )
+ {
+
+ float forwardSlipPercent = ForwardFriction.Slip / Tire.GetPeakSlip();
+ float sideSlipPercent = SidewayFriction.Slip / Tire.GetPeakSlip();
+ float slipCircleLimit = MathF.Sqrt( forwardSlipPercent * forwardSlipPercent + sideSlipPercent * sideSlipPercent );
+ if ( slipCircleLimit > 1f )
+ {
+ float beta = MathF.Atan2( sideSlipPercent, forwardSlipPercent * 0.9f );
+ float sinBeta = MathF.Sin( beta );
+ float cosBeta = MathF.Cos( beta );
+
+ float absForwardForce = ForwardFriction.Force < 0 ? -ForwardFriction.Force : ForwardFriction.Force;
+
+ float absSideForce = SidewayFriction.Force < 0 ? -SidewayFriction.Force : SidewayFriction.Force;
+ float f = absForwardForce * cosBeta * cosBeta + absSideForce * sinBeta * sinBeta;
+
+ ForwardFriction.Force = 0.5f * ForwardFriction.Force - f * cosBeta;
+ SidewayFriction.Force = 0.5f * SidewayFriction.Force - f * sinBeta;
+ }
+ }
+
+ if ( IsOnGround )
+ {
+ FrictionForce.x = (hitSidewaysDirection.x * SidewayFriction.Force + hitForwardDirection.x * ForwardFriction.Force).MeterToInch();
+ FrictionForce.y = (hitSidewaysDirection.y * SidewayFriction.Force + hitForwardDirection.y * ForwardFriction.Force).MeterToInch();
+ FrictionForce.z = (hitSidewaysDirection.z * SidewayFriction.Force + hitForwardDirection.z * ForwardFriction.Force).MeterToInch();
+ //DebugOverlay.Normal( WorldPosition, hitSidewaysDirection * 10, overlay: true );
+ //DebugOverlay.Normal( WorldPosition, hitForwardDirection * 10, overlay: true );
+ //DebugOverlay.Normal( WorldPosition, FrictionForce / 100, overlay: true );
+ //DebugOverlay.Normal( ContactPosition, ContactNormal * 10, overlay: true );
+ //DebugOverlay.Sphere( new( ContactPosition, 4 ), overlay: true );
+ //Vehicle.Body.ApplyForceAt( ContactPosition, FrictionForce );
+
+ }
+ else
+ FrictionForce = Vector3.Zero;
+ }
+}
diff --git a/Code/Base/Wheel/VeloXWheel.Gizmo.cs b/Code/Base/Wheel/VeloXWheel.Gizmo.cs
index 79b8425..d73d8a8 100644
--- a/Code/Base/Wheel/VeloXWheel.Gizmo.cs
+++ b/Code/Base/Wheel/VeloXWheel.Gizmo.cs
@@ -18,7 +18,7 @@ public partial class VeloXWheel : Component
//
{
var suspensionStart = Vector3.Zero;
- var suspensionEnd = Vector3.Zero + Vector3.Down * SuspensionLength;
+ var suspensionEnd = Vector3.Zero + Vector3.Down * RestLength.MeterToInch();
Gizmo.Draw.Color = Color.Cyan;
Gizmo.Draw.LineThickness = 0.25f;
@@ -28,7 +28,7 @@ public partial class VeloXWheel : Component
Gizmo.Draw.Line( suspensionStart + Vector3.Forward, suspensionStart + Vector3.Backward );
Gizmo.Draw.Line( suspensionEnd + Vector3.Forward, suspensionEnd + Vector3.Backward );
}
- var widthOffset = Vector3.Right * Width * 0.5f;
+ var widthOffset = Vector3.Right * Width.MeterToInch() * 0.5f;
//
// Wheel radius
//
@@ -36,7 +36,7 @@ public partial class VeloXWheel : Component
Gizmo.Draw.LineThickness = 0.5f;
Gizmo.Draw.Color = Color.White;
- Gizmo.Draw.LineCylinder( widthOffset, -widthOffset, Radius, Radius, 16 );
+ Gizmo.Draw.LineCylinder( widthOffset, -widthOffset, Radius.MeterToInch(), Radius.MeterToInch(), 16 );
}
//
@@ -51,11 +51,11 @@ public partial class VeloXWheel : Component
for ( float i = 0; i < 16; i++ )
{
- var pos = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( i / 16 * 360, 0, 0 ) ) * Radius;
+ var pos = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( i / 16 * 360, 0, 0 ) ) * Radius.MeterToInch();
Gizmo.Draw.Line( new Line( pos - widthOffset, pos + widthOffset ) );
- var pos2 = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( (i + 1) / 16 * 360, 0, 0 ) ) * Radius;
+ var pos2 = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( (i + 1) / 16 * 360, 0, 0 ) ) * Radius.MeterToInch();
Gizmo.Draw.Line( pos - widthOffset, pos2 + widthOffset );
}
}
@@ -64,7 +64,7 @@ public partial class VeloXWheel : Component
//// Forward direction
////
//{
- // var arrowStart = Vector3.Forward * Radius;
+ // var arrowStart = Vector3.Forward * Radius.MeterToInch();
// var arrowEnd = arrowStart + Vector3.Forward * 8f;
// Gizmo.Draw.Color = Color.Red;
diff --git a/Code/Base/Wheel/VeloXWheel.Skid.cs b/Code/Base/Wheel/VeloXWheel.Skid.cs
new file mode 100644
index 0000000..2f8ca27
--- /dev/null
+++ b/Code/Base/Wheel/VeloXWheel.Skid.cs
@@ -0,0 +1,89 @@
+using Sandbox;
+using System.Collections.Generic;
+
+namespace VeloX;
+
+
+public partial class VeloXWheel
+{
+
+ [ConCmd( "clear_skids" )]
+ public static void ClearSkids()
+ {
+ while ( SkidMarks.Count > 1 )
+ SkidMarks.Dequeue()?.DestroyGameObject();
+ }
+
+ [ConVar( "skidmark_max_skid" )]
+ public static float MaxSkid { get; set; } = 50.0f;
+
+ [ConVar( "skidmark_min_slide" )]
+ public static float MinSlide { get; set; } = 0.1f;
+
+
+ private static readonly Queue SkidMarks = [];
+
+ private LineRenderer _skidMark;
+
+ private void ResetSkid()
+ {
+ _skidMark = null;
+ }
+
+ private void CreateSkid()
+ {
+ GameObject go = new()
+ {
+ WorldPosition = ContactPosition + Vehicle.WorldRotation.Down * (Radius.MeterToInch() - 0.01f),
+ WorldRotation = Rotation.LookAt( hitSidewaysDirection )
+ };
+ _skidMark = go.AddComponent();
+ _skidMark.Face = SceneLineObject.FaceMode.Normal;
+ _skidMark.Points = [go];
+ _skidMark.Color = Color.Black.WithAlpha( 0.5f );
+ _skidMark.Opaque = false;
+ _skidMark.CastShadows = false;
+ _skidMark.Width = Width.MeterToInch() / 2;
+ _skidMark.AutoCalculateNormals = false;
+ _skidMark.SplineInterpolation = 4;
+ go.Flags = go.Flags.WithFlag( GameObjectFlags.Hidden, true );
+ SkidMarks.Enqueue( _skidMark );
+ }
+
+ protected void UpdateSkid()
+ {
+ if ( IsProxy )
+ return;
+ while ( SkidMarks.Count > MaxSkid )
+ {
+ SkidMarks.Dequeue()?.DestroyGameObject();
+ }
+
+ if ( !IsOnGround )
+ {
+ ResetSkid();
+ return;
+ }
+
+ var slideAmount = NormalizedSlip;
+
+ if ( slideAmount < MinSlide * 2 )
+ {
+ ResetSkid();
+ return;
+ }
+
+ if ( !_skidMark.IsValid() )
+ {
+ CreateSkid();
+ }
+ GameObject go = new()
+ {
+ Parent = _skidMark.GameObject,
+ WorldPosition = ContactPosition + Vehicle.WorldRotation.Down * Radius.MeterToInch(),
+ WorldRotation = Rotation.LookAt( ContactNormal.RotateAround( Vector3.Zero, Rotation.FromRoll( 90 ) ) )
+ };
+ go.Flags = go.Flags.WithFlag( GameObjectFlags.Hidden, true );
+ _skidMark.Points.Add( go );
+ }
+}
diff --git a/Code/Base/Wheel/VeloXWheel.Smoke.cs b/Code/Base/Wheel/VeloXWheel.Smoke.cs
new file mode 100644
index 0000000..3c214b6
--- /dev/null
+++ b/Code/Base/Wheel/VeloXWheel.Smoke.cs
@@ -0,0 +1,109 @@
+using Sandbox;
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+namespace VeloX;
+
+
+public partial class VeloXWheel
+{
+ private static readonly GameObject SmokePrefab = GameObject.GetPrefab( "prefabs/particles/tire_smoke.prefab" );
+
+ private GameObject SmokeObject { get; set; }
+ public const float MIN_DRIFT_ANGLE = 10f;
+ public const float MIN_DRIFT_SPEED = 30f;
+ public const float MAX_DRIFT_ANGLE = 110f;
+ private float smokeMul;
+ private float timeMul;
+ protected override void OnStart()
+ {
+ base.OnStart();
+ if ( IsProxy )
+ return;
+
+ if ( !SmokeObject.IsValid() )
+ SmokeObject = GameObject.GetPrefab( "prefabs/particles/tire_smoke.prefab" ).Clone( new CloneConfig() { Parent = GameObject, StartEnabled = true } );
+ }
+ public float GetSlip()
+ {
+ if ( !IsOnGround )
+ return 0;
+ var val = Math.Abs( LateralSlip ) + Math.Abs( LongitudinalSlip );
+ timeMul = timeMul.LerpTo( val, 0.1f );
+
+ if ( timeMul > 2 )
+ return val;
+
+ return 0;
+ }
+ protected override void OnUpdate()
+ {
+ base.OnUpdate();
+ if ( IsProxy )
+ return;
+ UpdateSkid();
+ SmokeObject.WorldPosition = ContactPosition + Vehicle.WorldRotation.Down * Radius.MeterToInch();
+ smokeMul = Math.Max( 0, GetSlip() - 3 );
+
+ bool density = false;
+
+ if ( smokeMul > 0 )
+ density = true;
+ var emitter = SmokeObject.Components.Get( FindMode.EverythingInSelfAndDescendants );
+ emitter.Enabled = density;
+ emitter.Radius = 0f;
+ emitter.Velocity = 0;
+ emitter.Duration = 5;
+ emitter.Burst = 5;
+ float sizeMul = 0.7f + smokeMul * 0.3f;
+ emitter.Rate = smokeMul * 100f;
+ emitter.RateOverDistance = smokeMul * 10f;
+
+ var effect = SmokeObject.Components.Get( FindMode.EverythingInSelfAndDescendants );
+
+ effect.MaxParticles = 500;
+ effect.Damping = 0.9f;
+ effect.ApplyRotation = true;
+ effect.ApplyShape = true;
+
+ effect.Roll = new()
+ {
+ Type = ParticleFloat.ValueType.Range,
+ Evaluation = ParticleFloat.EvaluationType.Seed,
+ ConstantA = 0,
+ ConstantB = 360,
+ };
+ effect.Scale = new()
+ {
+ Type = ParticleFloat.ValueType.Curve,
+ Evaluation = ParticleFloat.EvaluationType.Life,
+ CurveA = new( new List() { new( 0, 10f ), new( 0.8f, 50f ), new( 1f, 160f * sizeMul ) } ),
+ };
+ effect.StartDelay = 0.025f + (1 - smokeMul) * 0.03f;
+
+ effect.ApplyColor = true;
+ effect.Gradient = new()
+ {
+ Type = ParticleGradient.ValueType.Range,
+ Evaluation = ParticleGradient.EvaluationType.Particle,
+ ConstantA = Color.White,
+ ConstantB = Color.Transparent,
+ };
+ effect.StartVelocity = new()
+ {
+ Type = ParticleFloat.ValueType.Range,
+ Evaluation = ParticleFloat.EvaluationType.Seed,
+ ConstantA = 10,
+ ConstantB = 70,
+ };
+ effect.Force = true;
+ effect.InitialVelocity = hitForwardDirection * LongitudinalSlip * 10f;
+ effect.ForceDirection = 0;
+ effect.SheetSequence = true;
+ effect.SequenceSpeed = 0.5f;
+ effect.SequenceTime = 1f;
+
+
+ }
+}
diff --git a/Code/Base/Wheel/VeloXWheel.cs b/Code/Base/Wheel/VeloXWheel.cs
index 4908d34..6624584 100644
--- a/Code/Base/Wheel/VeloXWheel.cs
+++ b/Code/Base/Wheel/VeloXWheel.cs
@@ -1,16 +1,5 @@
using Sandbox;
-using Sandbox.Rendering;
-using Sandbox.Services;
-using Sandbox.UI;
using System;
-using System.Collections.Specialized;
-using System.Diagnostics.Metrics;
-using System.Numerics;
-using System.Text.RegularExpressions;
-using System.Threading;
-using static Sandbox.CameraComponent;
-using static Sandbox.Package;
-using static Sandbox.SkinnedModelRenderer;
namespace VeloX;
@@ -18,72 +7,67 @@ namespace VeloX;
[Title( "VeloX - Wheel" )]
public partial class VeloXWheel : Component
{
+ [Property, Group( "Suspension" )] public float Radius { get; set; } = 0.35f;
+ [Property, Group( "Suspension" )] public float Width { get; set; } = 0.1f;
+ [Property] public float Mass { get; set; } = 5;
+ [Property, Group( "Suspension" )] float RestLength { get; set; } = 0.22f;
+ [Property, Group( "Suspension" )] public float SpringStiffness { get; set; } = 20000.0f;
+ [Property, Group( "Suspension" )] float ReboundStiffness { get; set; } = 2200;
+ [Property, Group( "Suspension" )] float CompressionStiffness { get; set; } = 2400;
+ [Property, Group( "Suspension" )] float BumpStopStiffness { get; set; } = 5000;
- [Property] public float Radius { get; set; } = 15;
- [Property] public float Mass { get; set; } = 20;
- [Property] public float RollingResistance { get; set; } = 20;
- [Property] public float SlipCircleShape { get; set; } = 1.05f;
- [Property] public TirePreset TirePreset { get; set; }
- [Property] public float Width { get; set; } = 6;
- public float SideSlip { get; private set; }
- public float ForwardSlip { get; private set; }
+ [Property, Group( "Traction" )] public TirePreset Tire { get; set; } = ResourceLibrary.Get( "frictions/default.tire" );
+ [Property, Group( "Traction" )] public float SurfaceGrip { get; set; } = 1f;
+ [Property, Group( "Traction" )] public float SurfaceResistance { get; set; } = 0.05f;
+ public bool AutoSimulate = true;
+
+
+ public float BaseInertia => Mass * (Radius * Radius); // kg·m²
+ [Property] public float Inertia { get; set; } = 1.5f; // kg·m²
+ public float SideSlip => SlipAngle;
+ public float ForwardSlip => DynamicSlipRatio;
[Sync] public float Torque { get; set; }
[Sync, Range( 0, 1 )] public float Brake { get; set; }
- [Property] float BrakePowerMax { get; set; } = 3000;
[Property] public bool IsFront { get; protected set; }
[Property] public float SteerMultiplier { get; set; }
- [Property] public float CasterAngle { get; set; } = 7; // todo
- [Property] public float CamberAngle { get; set; } = -3;
- [Property] public float ToeAngle { get; set; } = 0.5f;
- [Property] public float Ackermann { get; set; } = 0;
- [Property, Group( "Suspension" )] float SuspensionLength { get; set; } = 10;
- [Property, Group( "Suspension" )] float SpringStrength { get; set; } = 800;
- [Property, Group( "Suspension" )] float SpringDamper { get; set; } = 3000;
-
- [Property] public bool AutoPhysics { get; set; } = true;
-
- public float Spin { get; private set; }
-
- public float RPM { get => angularVelocity * 30f / MathF.PI; set => angularVelocity = value / (30f / MathF.PI); }
- public float AngularVelocity { get => angularVelocity; set => angularVelocity = value; }
-
- internal float DistributionFactor { get; set; }
+ public float RPM { get => AngularVelocity * 60f / MathF.Tau; set => AngularVelocity = value / (60 / MathF.Tau); }
+ [Sync] internal float DistributionFactor { get; set; }
private Vector3 StartPos { get; set; }
- private static Rotation CylinderOffset => Rotation.FromRoll( 90 );
+ private static Rotation CylinderOffset = Rotation.FromRoll( 90 );
- public SceneTraceResult Trace { get; private set; }
- public bool IsOnGround => Trace.Hit;
+ [Sync] public bool IsOnGround { get; private set; }
- private float lastSpringOffset;
- private float angularVelocity;
- private float load;
- private float lastFraction;
- private Vector3 contactPos;
- private Vector3 forward;
- private Vector3 right;
- private Vector3 up;
+ [Property] public float DriveTorque => Torque;
+ [Property] public float BrakeTorque => Brake * 5000f;
- private float forwardFriction;
- private float sideFriction;
- private Vector3 force;
- public float CounterTorque { get; private set; }
+ public float Compression { get; protected set; } // meters
+ public float LastLength { get; protected set; } // meters
+ public float Fz { get; protected set; } // N
+ public float AngularVelocity { get; protected set; } // rad/s
+ public float Fx { get; protected set; } // N
+ public float Fy { get; protected set; } // N
+ public float RollAngle { get; protected set; } // degrees
- internal float BaseInertia => 0.5f * Mass * MathF.Pow( Radius.InchToMeter(), 2 );
- public float Inertia
- {
- get => BaseInertia + inertia;
- set => inertia = value;
- }
+ private VeloXBase Vehicle;
+ [Sync] public Vector3 ContactNormal { get; protected set; }
+ [Sync] public Vector3 ContactPosition { get; protected set; }
+ public float SlipRatio { get; protected set; }
+ public float SlipAngle { get; protected set; }
+ public float DynamicSlipRatio { get; protected set; }
+ public float DynamicSlipAngle { get; protected set; }
+ Rotation TransformRotationSteer => Vehicle.WorldTransform.RotationToWorld( Vehicle.SteerAngle * SteerMultiplier );
protected override void OnAwake()
{
+ Vehicle = Components.Get( FindMode.EverythingInSelfAndAncestors );
base.OnAwake();
if ( StartPos.IsNearZeroLength )
StartPos = LocalPosition;
+ Inertia = BaseInertia;
}
internal void Update( VeloXBase vehicle, in float dt )
@@ -93,224 +77,198 @@ public partial class VeloXWheel : Component
private void UpdateVisuals( VeloXBase vehicle, in float dt )
{
- Spin -= angularVelocity.RadianToDegree() * dt;
- WorldRotation = vehicle.WorldTransform.RotationToWorld( GetSteer( vehicle.SteerAngle.yaw ) ) * Rotation.FromAxis( Vector3.Right, Spin );
+ WorldRotation = vehicle.WorldTransform.RotationToWorld( vehicle.SteerAngle * SteerMultiplier ).RotateAroundAxis( Vector3.Right, -RollAngle );
+ LocalPosition = StartPos + Vector3.Down * LastLength.MeterToInch();
}
- private Rotation GetSteer( float steer )
+ private struct WheelTraceData
{
-
- float angle = (-steer * SteerMultiplier).DegreeToRadian();
-
- float t = MathF.Tan( (MathF.PI / 2) - angle ) - Ackermann;
- float steering_angle = MathF.CopySign( float.Pi / 2, t ) - MathF.Atan( t );
- var steering_axis = Vector3.Up * MathF.Cos( -CasterAngle.DegreeToRadian() ) +
- Vector3.Right * MathF.Sin( -CasterAngle.DegreeToRadian() );
-
- return Rotation.FromAxis( Vector3.Forward, -CamberAngle ) * Rotation.FromAxis( steering_axis, steering_angle.RadianToDegree() );
+ internal Vector3 ContactNormal;
+ internal Vector3 ContactPosition;
+ internal float Compression;
+ internal float Force;
+ }
+ public void UpdateForce()
+ {
+ Vehicle.Body.ApplyForceAt( ContactPosition, FrictionForce + ContactNormal * Fz.MeterToInch() );
}
-
- private static float GetLongitudinalLoadCoefficient( float load ) => 11000 * (1 - MathF.Exp( -0.00014f * load ));
- private static float GetLateralLoadCoefficient( float load ) => 18000 * (1 - MathF.Exp( -0.0001f * load ));
- private float inertia;
-
-
- private (float, float, float, float) StepLongitudinal( float Tm, float Tb, float Vx, float W, float Lc, float R, float I )
+ internal void StepPhys( VeloXBase vehicle, in float dt )
{
- float wInit = W;
- float vxAbs = Math.Abs( Vx );
- float Sx;
- if ( Lc < 0.01f )
+
+ var _rigidbody = vehicle.Body;
+ const int numSamples = 3;
+ float halfWidth = Width.MeterToInch() * 0.5f;
+
+ var ang = vehicle.WorldTransform.RotationToWorld( vehicle.SteerAngle * SteerMultiplier );
+
+ Vector3 right = Vector3.VectorPlaneProject( ang.Right, Vector3.Up ).Normal;
+
+
+ int hitCount = 0;
+ WheelTraceData wheelTraceData = new();
+ for ( int i = 0; i < numSamples; i++ )
{
- Sx = 0;
+ float t = (float)i / (numSamples - 1);
+ float offset = MathX.Lerp( -halfWidth, halfWidth, t );
+ Vector3 start = vehicle.WorldTransform.PointToWorld( StartPos + Vector3.Right * offset );
+ Vector3 end = start + vehicle.WorldRotation.Down * RestLength.MeterToInch();
+ if ( TraceWheel( vehicle, ref wheelTraceData, start, end, Width.MeterToInch() / numSamples, dt ) )
+ hitCount++;
}
- else if ( vxAbs >= 0.01f )
+
+
+ if ( hitCount > 0 )
{
- Sx = (W * R - Vx) / vxAbs;
+
+ IsOnGround = true;
+
+ //// Average all contacts
+ Fz = Math.Max( wheelTraceData.Force / hitCount, 0 );
+ Compression = wheelTraceData.Compression / hitCount;
+ ContactNormal = (wheelTraceData.ContactNormal / hitCount).Normal;
+ ContactPosition = wheelTraceData.ContactPosition / hitCount;
+ //DoSuspensionSounds( vehicle, (RestLength - Compression) * 0.8f);
+ LastLength = RestLength - Compression;
+
+ //DebugOverlay.Normal( ContactPosition, ContactNormal * Fz / 1000 );
+ // Apply suspension force
+ //_rigidbody.ApplyForceAt( ContactPosition, ContactNormal * Fz.MeterToInch() );
+
+ UpdateHitVariables();
+ // Friction
+
+ Vector3 forward = ContactNormal.Cross( right ).Normal;
+ right = Rotation.FromAxis( ContactNormal, 90f ) * forward;
+
+ var velAtContact = _rigidbody.GetVelocityAtPoint( ContactPosition + vehicle.Body.MassCenter ) * 0.0254f;
+ float vx = Vector3.Dot( velAtContact, forward );
+ float vy = Vector3.Dot( velAtContact, right );
+ float wheelLinearVel = AngularVelocity * Radius;
+
+ float creepVel = 0.5f;
+ SlipRatio = (wheelLinearVel - vx) / (MathF.Abs( vx ) + creepVel);
+ SlipAngle = MathX.RadianToDegree( MathF.Atan2( vy, MathF.Abs( vx ) + creepVel ) );
+
+ float latCoeff = 1.0f - MathF.Exp( -MathF.Max( MathF.Abs( vx ), 1f ) * dt / 0.01f );
+ float longCoeff = 1.0f - MathF.Exp( -MathF.Max( MathF.Abs( vx ), 1f ) * dt / 0.01f );
+
+ DynamicSlipAngle += (SlipAngle - DynamicSlipAngle) * latCoeff;
+ DynamicSlipRatio += (SlipRatio - DynamicSlipRatio) * longCoeff;
+
+
+ UpdateFriction( dt );
}
else
{
- Sx = (W * R - Vx) * 0.6f;
+ IsOnGround = false;
+ // Wheel is off the ground
+ Compression = 0f;
+ Fz = 0f;
+ Fx = 0f;
+ Fy = 0f;
+ ContactNormal = Vector3.Up;
+ ContactPosition = WorldPosition;
}
- Sx = Math.Clamp( Sx, -1, 1 );
-
- W += Tm / I * Time.Delta;
-
- Tb *= W > 0 ? -1 : 1;
-
- float tbCap = Math.Abs( W ) * I / Time.Delta;
- float tbr = Math.Abs( Tb ) - Math.Abs( tbCap );
- tbr = Math.Max( tbr, 0 );
-
- Tb = Math.Clamp( Tb, -tbCap, tbCap );
-
- W += Tb / I * Time.Delta;
-
- float maxTorque = TirePreset.Pacejka.PacejkaFx( Math.Abs( Sx ) ) * Lc * R;
-
- float errorTorque = (W - Vx / R) * I / Time.Delta;
- float surfaceTorque = Math.Clamp( errorTorque, -maxTorque, maxTorque );
-
- W -= surfaceTorque / I * Time.Delta;
- float Fx = surfaceTorque / R;
-
- tbr *= (W > 0 ? -1 : 1);
- float TbCap2 = Math.Abs( W ) * I / Time.Delta;
- float Tb2 = Math.Clamp( tbr, -TbCap2, TbCap2 );
- W += Tb2 / I * Time.Delta;
-
- float deltaOmegaTorque = (W - wInit) * I / Time.Delta;
- float Tcnt = -surfaceTorque + Tb + Tb2 - deltaOmegaTorque;
-
- return (W, Sx, Fx, Tcnt);
}
- private void StepLateral( float Vx, float Vy, float Lc, out float Sy, out float Fy )
+ const string playerTag = "player";
+
+ private bool TraceWheel( VeloXBase vehicle, ref WheelTraceData wheelTraceData, Vector3 start, Vector3 end, float width, in float dt )
{
- float VxAbs = Math.Abs( Vx );
- if ( Lc < 0.01f )
- {
- Sy = 0;
- }
- else if ( VxAbs > 0.1f )
+ var trace = Scene.Trace
+ .FromTo( start, end )
+ .Cylinder( width, Radius.MeterToInch() )
+ .Rotated( vehicle.WorldRotation * CylinderOffset )
+ .UseHitPosition( false )
+ .IgnoreGameObjectHierarchy( Vehicle.GameObject )
+ .WithoutTags( playerTag )
+ .Run();
+
+ //DebugOverlay.Trace( trace, overlay: true );
+ if ( trace.Hit )
{
- Sy = MathX.RadianToDegree( MathF.Atan( Vy / VxAbs ) ) / 50;
- }
- else
- {
+ Vector3 contactPos = trace.EndPosition;
+ Vector3 contactNormal = trace.Normal;
+ float currentLength = trace.Distance.InchToMeter();
+ float compression = (RestLength - currentLength).Clamp( -RestLength, RestLength );
- Sy = Vy * (0.003f / Time.Delta);
+ // Nonlinear spring
+ float springForce = SpringStiffness * compression * (1f + 2f * compression / RestLength);
+
+ // Damping
+ float damperVelocity = (LastLength - currentLength) / dt;
+ float damperForce = damperVelocity > 0 ? damperVelocity * ReboundStiffness : damperVelocity * CompressionStiffness;
+
+ float FzPoint = springForce + damperForce;
+
+ // Bump stop
+ float minCompression = -0.05f;
+ if ( compression <= minCompression ) FzPoint += (minCompression - compression) * BumpStopStiffness;
+
+ wheelTraceData.ContactNormal += contactNormal;
+ wheelTraceData.ContactPosition += contactPos;
+ wheelTraceData.Compression += compression;
+ wheelTraceData.Force += Math.Max( 0, FzPoint );
+
+ return true;
}
- Sy = Math.Clamp( Sy, -1, 1 );
- float slipSign = Sy < 0 ? -1 : 1;
- Fy = -slipSign * TirePreset.Pacejka.PacejkaFy( Math.Abs( Sy ) ) * Lc;
+ return false;
}
- private void SlipCircle( float Sx, float Sy, float Fx, ref float Fy )
+
+ public void DoPhysics( in float dt )
{
- float SxAbs = Math.Abs( Sx );
- if ( SxAbs > 0.01f )
- {
- float SxClamped = Math.Clamp( Sx, -1, 1 );
- float SyClamped = Math.Clamp( Sy, -1, 1 );
- Vector2 combinedSlip = new( SxClamped * 1.05f, SyClamped );
- Vector2 slipDir = combinedSlip.Normal;
-
- float F = MathF.Sqrt( Fx * Fx + Fy * Fy );
- float absSlipDirY = Math.Abs( slipDir.y );
-
- Fy = F * absSlipDirY * (Fy < 0 ? -1 : 1);
- }
- }
-
- public void DoPhysics( VeloXBase vehicle )
- {
- var pos = vehicle.WorldTransform.PointToWorld( StartPos );
-
- var ang = vehicle.WorldTransform.RotationToWorld( GetSteer( vehicle.SteerAngle.yaw ) );
-
- var maxLen = SuspensionLength;
-
- var endPos = pos + ang.Down * maxLen;
- Trace = Scene.Trace
- .IgnoreGameObjectHierarchy( vehicle.GameObject )
- .Cylinder( Width, Radius, pos, endPos )
- .Rotated( vehicle.WorldTransform.Rotation * CylinderOffset )
- .UseRenderMeshes( false )
- .UseHitPosition( true )
- .WithoutTags( vehicle.WheelIgnoredTags )
- .Run();
-
- forward = Vector3.VectorPlaneProject( ang.Forward, Trace.Normal );
- right = Vector3.VectorPlaneProject( ang.Right, Trace.Normal );
-
- var fraction = Trace.Fraction;
-
- contactPos = pos - maxLen * fraction * ang.Up;
-
- LocalPosition = vehicle.WorldTransform.PointToLocal( contactPos );
-
- DoSuspensionSounds( vehicle, fraction - lastFraction );
- lastFraction = fraction;
-
- if ( !IsOnGround )
+ if ( IsProxy )
return;
-
- var vel = vehicle.Body.GetVelocityAtPoint( contactPos );
-
- var offset = maxLen - (fraction * maxLen);
- var springForce = offset * SpringStrength;
- var damperForce = (lastSpringOffset - offset) * SpringDamper;
-
- lastSpringOffset = offset;
-
-
- var velU = Trace.Normal.Dot( vel );
-
- if ( velU < 0 && offset + Math.Abs( velU * Time.Delta ) > SuspensionLength )
- {
- vehicle.Body.CalculateVelocityOffset( -velU / Time.Delta * Trace.Normal, pos, out var linearImp, out var angularImp );
-
- vehicle.Body.Velocity += linearImp;
- vehicle.Body.AngularVelocity += angularImp;
- vehicle.Body.CalculateVelocityOffset( Trace.HitPosition - (contactPos + Trace.Normal * velU * Time.Delta), pos, out var lin, out _ );
-
- vehicle.WorldPosition += lin / Time.Delta;
- damperForce = 0;
-
- }
-
- force = (springForce - damperForce) * Trace.Normal;
-
- load = Math.Max( springForce - damperForce, 0 );
- float R = Radius.InchToMeter();
-
- float forwardSpeed = vel.Dot( forward ).InchToMeter();
- float sideSpeed = vel.Dot( right ).InchToMeter();
-
- float longitudinalLoadCoefficient = GetLongitudinalLoadCoefficient( load );
- float lateralLoadCoefficient = GetLateralLoadCoefficient( load );
-
- float F_roll = TirePreset.GetRollingResistance( angularVelocity * R, 1.0f ) * 10000;
-
- ( float W, float Sx, float Fx, float counterTq) = StepLongitudinal(
- Torque,
- Brake * BrakePowerMax + F_roll,
- forwardSpeed,
- angularVelocity,
- longitudinalLoadCoefficient,
- R,
- Inertia
- );
-
- StepLateral( forwardSpeed, sideSpeed, lateralLoadCoefficient, out float Sy, out float Fy );
-
- SlipCircle( Sx, Sy, Fx, ref Fy );
-
- CounterTorque = counterTq;
- angularVelocity = W;
-
- force += forward * Fx;
- force += right * Fy * Math.Clamp( vehicle.TotalSpeed * 0.005f, 0, 1 );
- ForwardSlip = Sx;
- SideSlip = Sy * Math.Clamp( vehicle.TotalSpeed * 0.005f, 0, 1 );
-
- vehicle.Body.ApplyForceAt( pos, force / Time.Delta );
-
+ StepRotation( Vehicle, dt );
+ if ( AutoSimulate )
+ StepPhys( Vehicle, dt );
}
-#if DEBUG
- protected override void OnUpdate()
+ private void StepRotation( VeloXBase vehicle, in float dt )
{
- DebugOverlay.Normal( contactPos, forward * forwardFriction, Color.Red, overlay: true );
- DebugOverlay.Normal( contactPos, right * sideFriction, Color.Green, overlay: true );
- DebugOverlay.Normal( contactPos, up * force / 1000f, Color.Blue, overlay: true );
- }
-#endif
+ float inertia = MathF.Max( 1f, Inertia );
+ float roadTorque = Fx * Radius;
+ float externalTorque = DriveTorque - roadTorque;
+ float rollingResistanceTorque = Fz * Radius * SurfaceResistance;
+
+ const float HubCoulombNm = 20f;
+ const float HubViscous = 0.1f;
+
+ float coulombTorque = BrakeTorque + rollingResistanceTorque + HubCoulombNm;
+
+ float omega = AngularVelocity;
+
+ if ( MathF.Abs( omega ) < 1e-6f && MathF.Abs( externalTorque ) <= coulombTorque )
+ {
+ AngularVelocity = 0f;
+ }
+ else
+ {
+
+ // viscous decay
+ if ( HubViscous > 0f ) omega *= MathF.Exp( -(HubViscous / inertia) * dt );
+
+ // Coulomb drag
+ if ( coulombTorque > 0f && omega != 0f )
+ {
+ float dir = MathF.Sign( omega );
+ float deltaOmega = (coulombTorque / inertia) * dt;
+ if ( deltaOmega >= MathF.Abs( omega ) ) omega = 0f;
+ else omega -= dir * deltaOmega;
+ }
+
+ if ( MathF.Abs( omega ) < 0.01f ) omega = 0f;
+ }
+ AngularVelocity = omega; // wider sanity range
+
+ RollAngle += MathX.RadianToDegree( AngularVelocity ) * dt;
+ RollAngle = (RollAngle % 360f + 360f) % 360f;
+ }
}
diff --git a/Code/Base/Wheel/WheelManager.cs b/Code/Base/Wheel/WheelManager.cs
new file mode 100644
index 0000000..a8d5d6f
--- /dev/null
+++ b/Code/Base/Wheel/WheelManager.cs
@@ -0,0 +1,58 @@
+using Sandbox;
+using System;
+using System.Diagnostics;
+using System.Linq;
+namespace VeloX;
+
+internal sealed class WheelManager : GameObjectSystem
+{
+
+
+ public WheelManager( Scene scene ) : base( scene )
+ {
+ Listen( Stage.StartFixedUpdate, -99, UpdateWheels, "UpdateWheels" );
+ Listen( Stage.StartFixedUpdate, -100, UpdateEngine, "UpdateEngine" );
+ }
+
+ private void UpdateWheels()
+ {
+ if ( !Game.IsPlaying )
+ return;
+ //Stopwatch sw = Stopwatch.StartNew();
+
+ var wheels = Scene.GetAll();
+ if ( !wheels.Any() ) return;
+
+ var timeDelta = Time.Delta;
+ Sandbox.Utility.Parallel.ForEach( wheels, item =>
+ {
+ if ( !item.IsProxy )
+ item.DoPhysics( timeDelta );
+ } );
+ foreach ( var wheel in wheels )
+ wheel.UpdateForce();
+
+ //sw.Stop();
+
+ //DebugOverlaySystem.Current.ScreenText( new Vector2( 120, 30 ), $"Wheel Sim: {sw.Elapsed.TotalMilliseconds,6:F2} ms", 24 );
+ }
+ private void UpdateEngine()
+ {
+ if ( !Game.IsPlaying )
+ return;
+ //Stopwatch sw = Stopwatch.StartNew();
+
+ var engines = Scene.GetAll();
+ if ( !engines.Any() ) return;
+
+ var timeDelta = Time.Delta;
+ Sandbox.Utility.Parallel.ForEach( engines, item =>
+ {
+ if ( !item.IsProxy )
+ item.UpdateEngine( timeDelta );
+ } );
+ //sw.Stop();
+
+ //DebugOverlaySystem.Current.ScreenText( new Vector2( 120, 54 ), $"Engine Sim: {sw.Elapsed.TotalMilliseconds,6:F2} ms", 24 );
+ }
+}
diff --git a/Code/Car/VeloXCar.Aerodinamics.cs b/Code/Car/VeloXCar.Aerodinamics.cs
new file mode 100644
index 0000000..3b50183
--- /dev/null
+++ b/Code/Car/VeloXCar.Aerodinamics.cs
@@ -0,0 +1,55 @@
+using Sandbox;
+using System;
+using System.Collections.Generic;
+
+namespace VeloX;
+
+public partial class VeloXCar
+{
+
+ private class DownforcePoint
+ {
+ [KeyProperty] public float MaxForce { get; set; }
+ [KeyProperty] public Vector3 Position { get; set; }
+ }
+ public const float RHO = 10.225f;
+ [Property, Feature( "Aerodinamics" )] public Vector3 Dimensions = new( 2f, 4.5f, 1.5f );
+ [Property, Feature( "Aerodinamics" )] public float FrontalCd { get; set; } = 0.35f;
+ [Property, Feature( "Aerodinamics" )] public float SideCd { get; set; } = 1.05f;
+ [Property, Feature( "Aerodinamics" )] public float MaxDownforceSpeed { get; set; } = 80f;
+ [Property, Feature( "Aerodinamics" )] private List DownforcePoints { get; set; } = [];
+
+ private float _forwardSpeed;
+ private float _frontalArea;
+
+ private float _sideArea;
+ private float _sideSpeed;
+ private float lateralDragForce;
+ private float longitudinalDragForce;
+
+ private void SimulateAerodinamics( float dt )
+ {
+ if ( TotalSpeed < 1f )
+ {
+ longitudinalDragForce = 0;
+ lateralDragForce = 0;
+ return;
+ }
+
+ _frontalArea = Dimensions.x * Dimensions.z * 0.85f;
+ _sideArea = Dimensions.y * Dimensions.z * 0.8f;
+ _forwardSpeed = LocalVelocity.x.InchToMeter();
+ _sideSpeed = LocalVelocity.y.InchToMeter();
+ longitudinalDragForce = 0.5f * RHO * _frontalArea * FrontalCd * (_forwardSpeed * _forwardSpeed) * (_forwardSpeed > 0 ? -1f : 1f);
+ lateralDragForce = 0.5f * RHO * _sideArea * SideCd * (_sideSpeed * _sideSpeed) * (_sideSpeed > 0 ? -1f : 1f);
+ var force = new Vector3( longitudinalDragForce.MeterToInch(), lateralDragForce.MeterToInch(), 0 ).RotateAround( Vector3.Zero, WorldRotation );
+ Body.ApplyForce( force );
+ //DebugOverlay.Normal( WorldPosition, force );
+
+ float speedPercent = TotalSpeed / MaxDownforceSpeed;
+ float forceCoeff = 1f - (1f - MathF.Pow( speedPercent, 2f ));
+
+ foreach ( DownforcePoint dp in DownforcePoints )
+ Body.ApplyForceAt( Transform.World.PointToWorld( dp.Position ), forceCoeff.MeterToInch() * dp.MaxForce.MeterToInch() * -WorldRotation.Up );
+ }
+}
diff --git a/Code/Car/VeloXCar.Drift.cs b/Code/Car/VeloXCar.Drift.cs
new file mode 100644
index 0000000..16b3c27
--- /dev/null
+++ b/Code/Car/VeloXCar.Drift.cs
@@ -0,0 +1,70 @@
+
+
+using Sandbox;
+using System;
+
+namespace VeloX;
+
+
+public partial class VeloXCar
+{
+ public const float MIN_DRIFT_ANGLE = 10f;
+ public const float MIN_DRIFT_SPEED = 10f;
+ public const float MAX_DRIFT_ANGLE = 110f;
+
+ public static readonly SoundFile SkidSound = SoundFile.Load( "sounds/tire/skid.wav" );
+
+ private SoundHandle _skidHandle;
+ private float targetPitch;
+ private float targetVolume;
+ public float GetDriftAngle()
+ {
+ if ( !IsOnGround )
+ return 0;
+
+ var velocity = Body.Velocity;
+ var forward = WorldRotation.Forward;
+
+ // Early exit if speed is too low
+ if ( TotalSpeed < MIN_DRIFT_SPEED )
+ return 0f;
+
+ // Normalize the dot product calculation
+ float dotProduct = velocity.Normal.Dot( forward );
+ // Handle potential floating point precision issues
+ float cosAngle = dotProduct;
+ cosAngle = MathX.Clamp( cosAngle, -1f, 1f );
+
+ // Calculate angle in degrees
+ float angle = MathF.Abs( MathX.RadianToDegree( MathF.Acos( cosAngle ) ) );
+
+
+ // Check if angle is within drift range
+ if ( angle >= MIN_DRIFT_ANGLE && angle <= MAX_DRIFT_ANGLE )
+ return angle;
+
+ return 0f;
+ }
+
+ protected virtual void UpdateDrift( float dt )
+ {
+ float driftAngle = GetDriftAngle();
+
+ float mul = (driftAngle - MIN_DRIFT_ANGLE) / (90 - MIN_DRIFT_ANGLE);
+
+ if ( !_skidHandle.IsValid() )
+ _skidHandle = Sound.PlayFile( SkidSound );
+
+ if ( !_skidHandle.IsValid() )
+ return;
+
+ targetVolume = mul;
+ targetPitch = 0.75f + 0.25f * mul;
+
+ _skidHandle.Pitch += (targetPitch - _skidHandle.Pitch) * dt * 5f;
+ _skidHandle.Volume += (targetVolume - _skidHandle.Volume) * dt * 10f;
+ _skidHandle.Position = WorldPosition;
+
+
+ }
+}
diff --git a/Code/Car/VeloXCar.Engine.cs b/Code/Car/VeloXCar.Engine.cs
deleted file mode 100644
index b6748ae..0000000
--- a/Code/Car/VeloXCar.Engine.cs
+++ /dev/null
@@ -1,365 +0,0 @@
-using Sandbox;
-using System;
-using System.Collections.Generic;
-using VeloX.Powertrain;
-
-namespace VeloX;
-
-public partial class VeloXCar
-{
-
- [Property, Feature( "Engine" )] Engine Engine { get; set; }
-
- private void EngineThink( float dt )
- {
- Engine.Throttle = Input.Down( "Forward" ) ? 1 : 0;
- Engine.ForwardStep( 0, 0 );
- }
-
-
- //[Property, Feature( "Engine" ), Sync] public EngineState EngineState { get; set; }
- //[Property, Feature( "Engine" )] public EngineStream Stream { get; set; }
- //public EngineStreamPlayer StreamPlayer { get; set; }
-
- //[Property, Feature( "Engine" )] public float MinRPM { get; set; } = 800;
- //[Property, Feature( "Engine" )] public float MaxRPM { get; set; } = 7000;
- //[Property, Feature( "Engine" ), Range( -1, 1 )]
- //public float PowerDistribution
- //{
- // get => powerDistribution; set
- // {
- // powerDistribution = value;
- // UpdatePowerDistribution();
- // }
- //}
- //[Property, Feature( "Engine" )] public float FlyWheelMass { get; set; } = 80f;
- //[Property, Feature( "Engine" )] public float FlyWheelRadius { get; set; } = 0.5f;
- //[Property, Feature( "Engine" )] public float FlywheelFriction { get; set; } = -6000;
- //[Property, Feature( "Engine" )] public float FlywheelTorque { get; set; } = 20000;
- //[Property, Feature( "Engine" )] public float EngineBrakeTorque { get; set; } = 2000;
- //[Property, Feature( "Engine" )]
- //public Dictionary Gears { get; set; } = new()
- //{
- // [-1] = 2.5f,
- // [0] = 0f,
- // [1] = 2.8f,
- // [2] = 1.7f,
- // [3] = 1.2f,
- // [4] = 0.9f,
- // [5] = 0.75f,
- // [6] = 0.7f
- //};
- //[Property, Feature( "Engine" )] public float DifferentialRatio { get; set; } = 1f;
- //[Property, Feature( "Engine" ), Range( 0, 1 )] public float TransmissionEfficiency { get; set; } = 0.8f;
- //[Property, Feature( "Engine" )] private float MinRPMTorque { get; set; } = 5000f;
- //[Property, Feature( "Engine" )] private float MaxRPMTorque { get; set; } = 8000f;
- //[Sync] public int Gear { get; set; } = 0;
- //[Sync] public float Clutch { get; set; } = 1;
- //[Sync( SyncFlags.Interpolate )] public float EngineRPM { get; set; }
- //public float RPMPercent => (EngineRPM - MinRPM) / MaxRPM;
-
- //private const float TAU = MathF.Tau;
-
- //private int MinGear { get; set; }
- //private int MaxGear { get; set; }
-
- //[Sync] public bool IsRedlining { get; private set; }
-
- //private float flywheelVelocity;
- //private TimeUntil switchCD = 0;
- //private float groundedCount;
- //private float burnout;
-
- //private float frontBrake;
- //private float rearBrake;
-
- //private float availableFrontTorque;
- //private float availableRearTorque;
-
- //private float avgSideSlip;
- //private float avgPoweredRPM;
- //private float avgForwardSlip;
-
- //private float inputThrottle, inputBrake;
- //private bool inputHandbrake;
- //private float transmissionRPM;
- //private float powerDistribution;
-
- //public float FlywheelRPM
- //{
- // get => flywheelVelocity * 60 / TAU;
- // set
- // {
- // flywheelVelocity = value * TAU / 60; EngineRPM = value;
- // }
- //}
- //private void UpdateGearList()
- //{
- // int minGear = 0;
- // int maxGear = 0;
-
- // foreach ( var (gear, ratio) in Gears )
- // {
-
- // if ( gear < minGear )
- // minGear = gear;
-
- // if ( gear > maxGear )
- // maxGear = gear;
-
- // if ( minGear != 0 || maxGear != 0 )
- // {
- // SwitchGear( 0, false );
- // }
- // }
-
- // MinGear = minGear;
- // MaxGear = maxGear;
-
- //}
-
-
- //public void SwitchGear( int index, bool cooldown = true )
- //{
- // if ( Gear == index ) return;
- // index = Math.Clamp( index, MinGear, MaxGear );
-
- // if ( index == 0 || !cooldown )
- // switchCD = 0;
- // else
- // switchCD = 0.3f;
- // Clutch = 1;
- // Gear = index;
- //}
-
- //public float TransmissionToEngineRPM( int gear ) => avgPoweredRPM * Gears[gear] * DifferentialRatio * 60 / TAU;
- //public float GetTransmissionMaxRPM( int gear ) => FlywheelRPM / Gears[gear] / DifferentialRatio;
-
- //private void UpdatePowerDistribution()
- //{
- // if ( Wheels is null ) return;
- // int frontCount = 0, rearCount = 0;
-
- // foreach ( var wheel in Wheels )
- // {
- // if ( wheel.IsFront )
- // frontCount++;
- // else
- // rearCount++;
-
- // }
-
- // float frontDistribution = 0.5f + PowerDistribution * 0.5f;
-
- // float rearDistribution = 1 - frontDistribution;
-
- // frontDistribution /= frontCount;
- // rearDistribution /= rearCount;
-
- // foreach ( var wheel in Wheels )
- // if ( wheel.IsFront )
- // wheel.DistributionFactor = frontDistribution;
- // else
- // wheel.DistributionFactor = rearDistribution;
- //}
-
- //private void EngineAccelerate( float torque, float dt )
- //{
- // var inertia = 0.5f * FlyWheelMass * FlyWheelRadius * FlyWheelRadius;
- // var angularAcceleration = torque / inertia;
-
- // flywheelVelocity += angularAcceleration * dt;
- //}
-
- //private float GetTransmissionTorque( int gear, float minTorque, float maxTorque )
- //{
- // var torque = FlywheelRPM.Remap( MinRPM, MaxRPM, minTorque, maxTorque, true );
- // torque *= (1 - Clutch);
- // torque = torque * Gears[gear] * DifferentialRatio * TransmissionEfficiency;
-
- // return gear == -1 ? -torque : torque;
- //}
-
- //private void AutoGearSwitch()
- //{
- // if ( ForwardSpeed < 100 && Input.Down( "Backward" ) )
- // {
- // SwitchGear( -1, false );
- // return;
- // }
-
- // var currentGear = Gear;
-
- // if ( currentGear < 0 && ForwardSpeed < -100 )
- // return;
-
- // if ( Math.Abs( avgForwardSlip ) > 10 )
- // return;
-
- // var gear = Math.Clamp( currentGear, 1, MaxGear );
-
- // float minRPM = MinRPM, maxRPM = MaxRPM;
-
-
- // maxRPM *= 0.98f;
-
- // float gearRPM;
-
-
- // for ( int i = 1; i <= MaxGear; i++ )
- // {
- // gearRPM = TransmissionToEngineRPM( i );
-
- // if ( (i == 1 && gearRPM < minRPM) || (gearRPM > minRPM && gearRPM < maxRPM) )
- // {
- // gear = i;
- // break;
- // }
- // }
-
- // var threshold = minRPM + (maxRPM - minRPM) * (0.5 - Throttle * 0.3);
- // if ( gear < currentGear && gear > currentGear - 2 && EngineRPM > threshold )
- // return;
-
- // SwitchGear( gear );
- //}
-
- //private float EngineClutch( float dt )
- //{
- // if ( !switchCD )
- // {
- // inputThrottle = 0;
-
- // return 0;
- // }
-
- // if ( inputHandbrake )
- // return 1;
-
- // var absForwardSpeed = Math.Abs( ForwardSpeed );
-
- // if ( groundedCount < 1 && absForwardSpeed > 30 )
- // return 1;
-
- // if ( ForwardSpeed < -50 && inputBrake > 0 && Gear < 0 )
- // return 1;
-
- // if ( absForwardSpeed > 200 )
- // return 0;
-
- // return inputThrottle > 0.1f ? 0 : 1;
- //}
-
- //private void EngineThink( float dt )
- //{
- // inputThrottle = Input.Down( "Forward" ) ? 1 : 0;
- // inputBrake = Input.Down( "Backward" ) ? 1 : 0;
- // inputHandbrake = Input.Down( "Jump" );
- // if ( burnout > 0 )
- // {
- // SwitchGear( 1, false );
- // if ( inputThrottle < 0.1f || inputBrake < 0.1f )
- // burnout = 0;
- // }
- // else
- // AutoGearSwitch();
-
- // if ( Gear < 0 )
- // (inputBrake, inputThrottle) = (inputThrottle, inputBrake);
-
- // var rpm = FlywheelRPM;
-
- // var clutch = EngineClutch( dt );
-
- // if ( inputThrottle > 0.1 && inputBrake > 0.1 && Math.Abs( ForwardSpeed ) < 50 )
- // {
- // burnout = MathX.Approach( burnout, 1, dt * 2 );
- // Clutch = 0;
-
- // }
- // else if ( inputHandbrake )
- // {
- // frontBrake = 0f;
- // rearBrake = 0.5f;
- // Clutch = 1;
- // clutch = 1;
- // }
- // else
- // {
- // if ( (Gear == -1 || Gear == 1) && inputThrottle < 0.05f && inputBrake < 0.1f && groundedCount > 1 && rpm < MinRPM * 1.2f )
- // inputBrake = 0.2f;
-
- // frontBrake = inputBrake * 0.5f;
- // rearBrake = inputBrake * 0.5f;
- // }
-
- // clutch = MathX.Approach( Clutch, clutch, dt * ((Gear < 2 && inputThrottle > 0.1f) ? 6 : 2) );
-
- // Clutch = clutch;
-
- // var isRedlining = false;
- // transmissionRPM = 0;
-
- // if ( Gear != 0 )
- // {
- // transmissionRPM = TransmissionToEngineRPM( Gear );
- // transmissionRPM = Gear < 0 ? -transmissionRPM : transmissionRPM;
- // rpm = (rpm * clutch) + (MathF.Max( 0, transmissionRPM ) * (1 - clutch));
- // }
- // var throttle = Throttle;
-
- // var gearTorque = GetTransmissionTorque( Gear, MinRPMTorque, MaxRPMTorque );
-
- // var availableTorque = gearTorque * throttle;
-
- // if ( transmissionRPM < 0 )
- // {
- // availableTorque += gearTorque * 2;
- // }
- // else
- // {
- // var engineBrakeTorque = GetTransmissionTorque( Gear, EngineBrakeTorque, EngineBrakeTorque );
-
- // availableTorque -= engineBrakeTorque * (1 - throttle) * 0.5f;
- // }
-
- // var maxRPM = MaxRPM;
-
- // if ( rpm < MinRPM )
- // {
- // rpm = MinRPM;
- // }
- // else if ( rpm > maxRPM )
- // {
-
- // if ( rpm > maxRPM * 1.2f )
- // availableTorque = 0;
-
- // rpm = maxRPM;
-
- // if ( Gear != MaxGear || groundedCount < Wheels.Count )
- // isRedlining = true;
- // }
-
- // FlywheelRPM = Math.Clamp( rpm, 0, maxRPM );
-
- // if ( burnout > 0 )
- // availableTorque += availableTorque * burnout * 0.1f;
-
- // var front = 0.5f + PowerDistribution * 0.5f;
- // var rear = 1 - front;
-
- // availableFrontTorque = availableTorque * front;
- // availableRearTorque = availableTorque * rear;
-
- // throttle = MathX.Approach( throttle, inputThrottle, dt * 4 );
-
-
- // EngineAccelerate( FlywheelFriction + FlywheelTorque * throttle, dt );
-
- // Throttle = throttle;
-
- // IsRedlining = (isRedlining && inputThrottle > 0);
- //}
-
-}
diff --git a/Code/Car/VeloXCar.Steering.cs b/Code/Car/VeloXCar.Steering.cs
index 55399bb..5671228 100644
--- a/Code/Car/VeloXCar.Steering.cs
+++ b/Code/Car/VeloXCar.Steering.cs
@@ -1,53 +1,51 @@
using Sandbox;
using System;
-using System.Threading;
+using VeloX.Utils;
namespace VeloX;
public partial class VeloXCar
{
public static float ExpDecay( float a, float b, float decay, float dt ) => b + (a - b) * MathF.Exp( -decay * dt );
- [Property, Feature( "Steer" )] public float SteerConeMaxSpeed { get; set; } = 1800;
- [Property, Feature( "Steer" )] public float SteerConeMaxAngle { get; set; } = 0.25f;
- [Property, Feature( "Steer" )] public float SteerConeChangeRate { get; set; } = 8;
- [Property, Feature( "Steer" )] public float CounterSteer { get; set; } = 0.1f;
- [Property, Feature( "Steer" )] public float MaxSteerAngle { get; set; } = 35f;
+ [Property, Feature( "Steer" )] public float MaxSteerAngle { get; set; } = 40f;
+
+ [ConVar( "steer_return_speed" )]
+ [Property] public static float SteerReturnSpeed { get; set; } = 6f;
+
+ [ConVar( "steer_speed" )]
+ public static float SteerInputResponse { get; set; } = 3f;
+
+
+ [ConVar( "assist_mult" )]
+ public static float MaxSteerAngleMultiplier { get; set; } = 1f;
+ public int CarDirection { get { return ForwardSpeed < 1 ? 0 : (VelocityAngle < 90 && VelocityAngle > -90 ? 1 : -1); } }
+ public float VelocityAngle { get; private set; }
[Sync] public float Steering { get; private set; }
+ private float currentSteerAngle;
private float inputSteer;
-
- public static float SignedAngle( Vector3 from, Vector3 to, Vector3 axis )
- {
- float unsignedAngle = Vector3.GetAngle( from, to );
-
- float cross_x = from.y * to.z - from.z * to.y;
- float cross_y = from.z * to.x - from.x * to.z;
- float cross_z = from.x * to.y - from.y * to.x;
- float sign = MathF.Sign( axis.x * cross_x + axis.y * cross_y + axis.z * cross_z );
- return unsignedAngle * sign;
- }
- public float VelocityAngle { get; private set; }
- public int CarDirection { get { return ForwardSpeed.InchToMeter() < 5 ? 0 : (VelocityAngle < 90 && VelocityAngle > -90 ? 1 : -1); } }
private void UpdateSteering( float dt )
{
- var inputSteer = Input.AnalogMove.y;
+ inputSteer = Input.AnalogMove.y;
- VelocityAngle = 0;// -SignedAngle( Body.Velocity, WorldRotation.Forward, WorldRotation.Up );
+ float targetSteerAngle = inputSteer * MaxSteerAngle;
- //var steerConeFactor = Math.Clamp( TotalSpeed / SteerConeMaxSpeed, 0, 1 );
- //var steerCone = 1 - steerConeFactor * (1 - SteerConeMaxAngle);
+ if ( !Input.Down( "Jump" ) )
+ targetSteerAngle *= Math.Clamp( 1 - Math.Clamp( TotalSpeed / 3000, 0.01f, 0.9f ), -1, 1 );
- inputSteer = ExpDecay( this.inputSteer, inputSteer, SteerConeChangeRate, dt );
- this.inputSteer = inputSteer;
+ VelocityAngle = -Body.Velocity.SignedAngle( WorldRotation.Forward, WorldRotation.Up );
- float target = -inputSteer * MaxSteerAngle;
- if ( CarDirection > 0 )
- target -= VelocityAngle * CounterSteer;
+ float targetAngle = 0;
- inputSteer = Math.Clamp( inputSteer, -1, 1 );
- Steering = inputSteer;
- SteerAngle = new( 0, target, 0 );
+ if ( TotalSpeed > 150 && CarDirection > 0 && IsOnGround )
+ targetAngle = VelocityAngle * MaxSteerAngleMultiplier;
+
+ float lerpSpeed = Math.Abs( inputSteer ) < 0.1f ? SteerReturnSpeed : SteerInputResponse;
+
+ currentSteerAngle = ExpDecay( currentSteerAngle, targetSteerAngle, lerpSpeed, Time.Delta );
+ Steering = currentSteerAngle + targetAngle;
+ SteerAngle = new( 0, Math.Clamp( Steering, -MaxSteerAngle, MaxSteerAngle ), 0 );
}
}
diff --git a/Code/Car/VeloXCar.Wheel.cs b/Code/Car/VeloXCar.Wheel.cs
deleted file mode 100644
index dbd9ad1..0000000
--- a/Code/Car/VeloXCar.Wheel.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace VeloX;
-
-public partial class VeloXCar
-{
- private void WheelThink( in float dt )
- {
- foreach ( var w in Wheels )
- w.Update( this, dt );
- }
-}
diff --git a/Code/Car/VeloXCar.cs b/Code/Car/VeloXCar.cs
index e4d7fdf..d2f7dfc 100644
--- a/Code/Car/VeloXCar.cs
+++ b/Code/Car/VeloXCar.cs
@@ -7,21 +7,33 @@ namespace VeloX;
[Title( "VeloX - Car" )]
public partial class VeloXCar : VeloXBase
{
+
protected override void OnFixedUpdate()
{
- if ( !IsDriver )
+ if ( IsProxy )
return;
base.OnFixedUpdate();
- Brake = Math.Clamp( (Input.Down( "Jump" ) ? 1 : 0), 0, 1 );
-
var dt = Time.Delta;
-
- EngineThink( dt );
- WheelThink( dt );
+ //EngineThink( dt );
+ SimulateAerodinamics( dt );
+ //WheelThink( dt );
UpdateSteering( dt );
-
+ UpdateUnflip( dt );
+ UpdateDrift( dt );
}
+ private void UpdateUnflip( float dt )
+ {
+ if ( Math.Abs( inputSteer ) < 0.1f )
+ return;
+
+ if ( Math.Abs( WorldRotation.Angles().roll ) < 70 )
+ return;
+
+ var angVel = Body.AngularVelocity;
+ var force = inputSteer * Mass * Math.Clamp( 1 - angVel.x / 50, 0, 1 ) * 0.05f;
+ Body.AngularVelocity -= Body.WorldRotation.Forward * force * dt;
+ }
}
diff --git a/Code/Utils/EngineStream/EngineStream.cs b/Code/Utils/EngineStream/EngineStream.cs
index f5905e3..8be3cfd 100644
--- a/Code/Utils/EngineStream/EngineStream.cs
+++ b/Code/Utils/EngineStream/EngineStream.cs
@@ -4,7 +4,8 @@ using VeloX.Audio;
namespace VeloX;
-[GameResource( "Engine Stream", "engstr", "Engine Sound", Category = "VeloX", Icon = "time_to_leave" )]
+[AssetType( Name = "Engine Stream", Extension = "engstr", Category = "VeloX" )]
+[Icon( "time_to_leave" )]
public sealed class EngineStream : GameResource
{
public sealed class Layer
diff --git a/Code/Utils/EngineStream/EngineStreamPlayer.cs b/Code/Utils/EngineStream/EngineStreamPlayer.cs
index 3e9a09c..c18f602 100644
--- a/Code/Utils/EngineStream/EngineStreamPlayer.cs
+++ b/Code/Utils/EngineStream/EngineStreamPlayer.cs
@@ -26,6 +26,7 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable
public void Update( float deltaTime, Vector3 position, bool isLocal = false )
{
+
var globalPitch = 1.0f;
// Gear wobble effect
@@ -50,14 +51,14 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable
foreach ( var (id, layer) in Stream.Layers )
{
EngineSounds.TryGetValue( layer, out var channel );
-
- if ( !channel.IsValid() )
+
+ if ( !channel.IsValid() && layer.AudioPath.IsValid() )
{
channel = Sound.PlayFile( layer.AudioPath );
EngineSounds[layer] = channel;
}
- if ( channel.Paused && (EngineSoundPaused || layer.IsMuted) )
+ if ( !channel.IsValid() || channel.Paused && (EngineSoundPaused || layer.IsMuted) )
continue;
// Reset controller outputs
diff --git a/Code/Utils/MathM.cs b/Code/Utils/MathM.cs
new file mode 100644
index 0000000..df7a4b5
--- /dev/null
+++ b/Code/Utils/MathM.cs
@@ -0,0 +1,29 @@
+using System;
+
+namespace VeloX.Utils;
+
+public static class MathM
+{
+
+ ///
+ /// Converts angular velocity (rad/s) to rotations per minute.
+ ///
+ public static float AngularVelocityToRPM( this float angularVelocity ) => angularVelocity * 9.5492965855137f;
+
+ ///
+ /// Converts rotations per minute to angular velocity (rad/s).
+ ///
+ public static float RPMToAngularVelocity( this float RPM ) => RPM * 0.10471975511966f;
+
+ public static float SignedAngle( this Vector3 from, Vector3 to, Vector3 axis )
+ {
+ float unsignedAngle = Vector3.GetAngle( from, to );
+
+ float cross_x = from.y * to.z - from.z * to.y;
+ float cross_y = from.z * to.x - from.x * to.z;
+ float cross_z = from.x * to.y - from.y * to.x;
+ float sign = MathF.Sign( axis.x * cross_x + axis.y * cross_y + axis.z * cross_z );
+ return unsignedAngle * sign;
+ }
+
+}
diff --git a/Editor/ESEditor/EngineStreamEditor.cs b/Editor/ESEditor/EngineStreamEditor.cs
index 8511ec7..193dca7 100644
--- a/Editor/ESEditor/EngineStreamEditor.cs
+++ b/Editor/ESEditor/EngineStreamEditor.cs
@@ -81,6 +81,11 @@ internal sealed class EngineStreamEditor : Widget, IAssetInspector
picker.OnAssetPicked += asset => CreateStreamEditor( asset[0].LoadResource() );
picker.Show();
};
+ var saveButton = a.Add( new Button( "Save File" ), 0 );
+ saveButton.Clicked = () =>
+ {
+ ActiveStream.StateHasChanged();
+ };
}
Layout RightLayout;
SimulatedEngineWidget SimulatedEngineWidget;
diff --git a/Editor/ESEditor/SimulatedEngineWidget.cs b/Editor/ESEditor/SimulatedEngineWidget.cs
index 003a5b3..17d77a0 100644
--- a/Editor/ESEditor/SimulatedEngineWidget.cs
+++ b/Editor/ESEditor/SimulatedEngineWidget.cs
@@ -76,7 +76,7 @@ internal sealed class SimulatedEngineWidget : Widget
[EditorEvent.Frame]
public void OnFrame()
{
- if ( Player.Stream is null )
+ if ( Player is null || !Player.Stream.IsValid() )
return;
Player.EngineState = IsPlaying ? EngineState.Running : EngineState.Off;
@@ -85,7 +85,7 @@ internal sealed class SimulatedEngineWidget : Widget
IsRedlining = RPM.Value == 1 && !IsRedlining;
Player.IsRedlining = IsRedlining;
- Player.Update( Time.Delta, Vector3.Zero, true );
+ Player?.Update( Time.Delta, Vector3.Zero, true );
}
}
diff --git a/Editor/Wheel/PacejkaWidget.cs b/Editor/Wheel/PacejkaWidget.cs
deleted file mode 100644
index 8efcaa8..0000000
--- a/Editor/Wheel/PacejkaWidget.cs
+++ /dev/null
@@ -1,48 +0,0 @@
-using Editor;
-using Sandbox;
-
-namespace VeloX;
-
-[CustomEditor( typeof( Pacejka ) )]
-public class PacejkaWidget : ControlObjectWidget
-{
- public override bool SupportsMultiEdit => false;
- public override bool IncludeLabel => false;
-
- [CustomEditor( typeof( Pacejka.PacejkaPreset ) )]
- private class LateralForceWidget : ControlObjectWidget
- {
- public LateralForceWidget( SerializedProperty property ) : base( property, true )
- {
- Layout = Layout.Column();
- Layout.Margin = 8f;
- Layout.Spacing = 8;
- foreach ( var item in TypeLibrary.GetType().Properties )
- {
- var row = Layout.AddRow();
- row.Spacing = 8;
- var propetry = SerializedObject.GetProperty( item.Name );
- row.Add( new Label( propetry.Name ) );
- row.Add( Create( propetry ) );
- }
- }
- }
- private Pacejka Pacejka;
- public PacejkaWidget( SerializedProperty property ) : base( property, true )
- {
-
- var obj = SerializedObject;
- Pacejka = obj.ParentProperty.GetValue();
-
- Layout = Layout.Column();
- Layout.Margin = 8f;
- Layout.Add( new Label.Body( $" {ToolTip}" ) { Color = Color.White } );
- var tabs = Layout.Add( new TabWidget( null ) );
- tabs.AddPage( nameof( Pacejka.Lateral ), null,
- Layout.Add( Create( obj.GetProperty( nameof( Pacejka.Lateral ) ) ) )
- );
- tabs.AddPage( nameof( Pacejka.Longitudinal ), null,
- Layout.Add( Create( obj.GetProperty( nameof( Pacejka.Longitudinal ) ) ) )
- );
- }
-}
diff --git a/Editor/Wheel/TirePresetPreview.cs b/Editor/Wheel/TirePresetPreview.cs
index 7575ddf..8b170d1 100644
--- a/Editor/Wheel/TirePresetPreview.cs
+++ b/Editor/Wheel/TirePresetPreview.cs
@@ -11,17 +11,9 @@ namespace VeloX;
[AssetPreview( "tire" )]
class TirePresetPreview : AssetPreview
{
- private Texture texture;
public override bool IsAnimatedPreview => false;
[Range( 0.01f, 1 )] private float Zoom { get; set; } = 1;
- private TirePreset Tire;
- public AssetPreviewWidget Widget { get; private set; }
- public override Widget CreateWidget( Widget parent )
- {
- Widget = parent as AssetPreviewWidget;
- return null;
- }
public override Widget CreateToolbar()
{
var info = new IconButton( "settings" );
@@ -58,55 +50,53 @@ class TirePresetPreview : AssetPreview
using ( Scene.Push() )
{
- PrimaryObject = new()
+ PrimaryObject = new( true )
{
WorldTransform = Transform.Zero
};
- var plane = PrimaryObject.AddComponent();
- plane.Model = Model.Plane;
- plane.LocalScale = new Vector3( 1, 1, 1 );
- plane.MaterialOverride = Material.Load( "materials/dev/reflectivity_30.vmat" );
- plane.Tint = new Color( 0.02f, 0.04f, 0.03f );
+ var spriteRenderer = PrimaryObject.AddComponent();
+
+ var bitmap = new Bitmap( 512, 512 );
+ var tire = Asset.LoadResource();
+ Draw( bitmap, tire );
+
+ spriteRenderer.Sprite = new() { Animations = [new() { Frames = [new() { Texture = bitmap.ToTexture() }] },] }; // Set the texture on the renderer
+ spriteRenderer.Size = 512;
- var bounds = PrimaryObject.GetBounds();
- SceneCenter = bounds.Center;
- SceneSize = bounds.Size;
}
return;
}
public override void UpdateScene( float cycle, float timeStep )
{
- if ( !Widget.IsValid() )
- return;
+ base.UpdateScene( cycle, timeStep );
- Camera.WorldPosition = Vector3.Up * 300;
Camera.Orthographic = true;
- Camera.WorldRotation = new Angles( 90, 0, 0 );
+ Camera.OrthographicHeight = 512;
+ Camera.WorldPosition = Vector3.Backward * 512;
+ Camera.WorldRotation = Rotation.LookAt( Vector3.Forward );
+
var bitmap = new Bitmap( 512, 512 );
+ var tire = Asset.LoadResource();
+ Draw( bitmap, tire );
- Draw( bitmap );
+ PrimaryObject.Components.Get().Sprite = new() { Animations = [new() { Frames = [new() { Texture = bitmap.ToTexture() }] },] }; // Set the texture on the renderer
+ PrimaryObject.Components.Get().Size = 512;
- texture.Clear( Color.Black );
- //texture.Update( bitmap );
- DebugOverlaySystem.Current.Texture( texture, new Rect( 0, Widget.Size ) );
- FrameScene();
}
private readonly List pointCache = [];
public TirePresetPreview( Asset asset ) : base( asset )
{
- texture = Texture.CreateRenderTarget().WithDynamicUsage().WithScreenFormat().WithSize( 512, 512 ).Create();
- Tire = Asset.LoadResource();
}
- private void DrawPacejka( Bitmap bitmap )
+ private void DrawPacejka( Bitmap bitmap, TirePreset tire )
{
- var tire = Tire.Pacejka;
+
var width = bitmap.Width;
var height = bitmap.Height;
@@ -118,32 +108,18 @@ class TirePresetPreview : AssetPreview
for ( float x = 0; x <= 1; x += 0.01f )
{
- float val = tire.PacejkaFy( x ) * Zoom;
+ float val = tire.Evaluate( x ) * Zoom;
pointCache.Add( new( width * x, height - height * val ) );
}
bitmap.DrawLines( pointCache.ToArray() );
}
-
- { // draw longitudinal line
- pointCache.Clear();
-
- bitmap.SetPen( Color.Green, 1 );
-
- for ( float x = 0; x <= 1; x += 0.01f )
- {
- float val = tire.PacejkaFx( x ) * Zoom;
- pointCache.Add( new( width * x, height - height * val ) );
- }
- bitmap.DrawLines( pointCache.ToArray() );
- }
-
pointCache.Clear();
}
- private void Draw( Bitmap bitmap )
+ private void Draw( Bitmap bitmap, TirePreset tire )
{
bitmap.Clear( Color.Black );
bitmap.SetAntialias( true );
- DrawPacejka( bitmap );
+ DrawPacejka( bitmap, tire );
}
}
diff --git a/Editor/Wheel/TirePresetWidget.cs b/Editor/Wheel/TirePresetWidget.cs
new file mode 100644
index 0000000..c7de0d3
--- /dev/null
+++ b/Editor/Wheel/TirePresetWidget.cs
@@ -0,0 +1,27 @@
+using Editor;
+using Sandbox;
+
+namespace VeloX;
+
+[CustomEditor( typeof( TirePreset ) )]
+public class TirePresetWidget : ControlObjectWidget
+{
+ public override bool SupportsMultiEdit => false;
+ public override bool IncludeLabel => false;
+
+ public TirePresetWidget( SerializedProperty property ) : base( property, true )
+ {
+
+ var obj = SerializedObject;
+ var tirePreset = obj.ParentProperty.GetValue();
+
+ Layout = Layout.Column();
+ Layout.Margin = 8f;
+ Layout.Add( new Label.Body( $" {ToolTip}" ) { Color = Color.White } );
+ Layout.Add( Create( obj.GetProperty( nameof( TirePreset.B ) ) ) );
+ Layout.Add( Create( obj.GetProperty( nameof( TirePreset.C ) ) ) );
+ Layout.Add( Create( obj.GetProperty( nameof( TirePreset.D ) ) ) );
+ Layout.Add( Create( obj.GetProperty( nameof( TirePreset.E ) ) ) );
+
+ }
+}