Compare commits

...

17 Commits

Author SHA1 Message Date
e22e463f11 dedicated 2025-12-03 15:17:36 +07:00
66fcc6a2bb cumulative update 2025-12-01 00:02:14 +07:00
e12b75be45 update 2025-11-25 19:16:40 +07:00
68626640c2 new maps and other improvements 2025-11-23 22:51:41 +07:00
b02f14ee47 maps 2025-11-21 22:11:34 +07:00
b978b821fa peak long drag 2025-11-21 20:21:36 +07:00
562750107a fix aboba 2025-11-21 17:52:48 +07:00
6cb8f716b3 new cars and maps 2025-11-21 17:52:25 +07:00
558a1eda07 rebalance 2025-11-20 20:01:57 +07:00
a58888c314 rewrite ui 2025-11-20 00:17:51 +07:00
9d92c4ca93 add races 2025-11-19 17:38:09 +07:00
bbc479be33 update 2025-11-18 21:53:51 +07:00
97fcb29bc0 decals 2025-11-12 21:57:11 +07:00
ab8cc70785 assets and etc. 2025-11-08 17:05:04 +07:00
ae5cd2c8b6 prerelease 2025-11-06 12:13:30 +07:00
Valera
0905876b99 update 2025-07-18 16:05:48 +07:00
Valera
55a178e8c5 cleanup code 2025-06-16 14:46:43 +07:00
58 changed files with 4117 additions and 1996 deletions

View File

@@ -18,7 +18,7 @@
"__type": "Sandbox.ParticleEffect", "__type": "Sandbox.ParticleEffect",
"__guid": "4e3d505f-af05-4c8f-85ee-c5a264ff23b6", "__guid": "4e3d505f-af05-4c8f-85ee-c5a264ff23b6",
"__enabled": true, "__enabled": true,
"__version": 1, "__version": 3,
"Alpha": { "Alpha": {
"Type": "Curve", "Type": "Curve",
"Evaluation": "Life", "Evaluation": "Life",
@@ -46,8 +46,7 @@
"out": 0, "out": 0,
"mode": "Mirrored" "mode": "Mirrored"
} }
], ]
"Constants": "1,0,0,0"
}, },
"ApplyAlpha": false, "ApplyAlpha": false,
"ApplyColor": true, "ApplyColor": true,
@@ -63,6 +62,11 @@
"CollisionPrefabChance": 1, "CollisionPrefabChance": 1,
"CollisionPrefabRotation": 0, "CollisionPrefabRotation": 0,
"CollisionRadius": 1, "CollisionRadius": 1,
"ConstantMovement": {
"X": 0,
"Y": 0,
"Z": 0
},
"Damping": 0, "Damping": 0,
"DieOnCollisionChance": 0, "DieOnCollisionChance": 0,
"FollowerPrefab": [], "FollowerPrefab": [],
@@ -124,29 +128,17 @@
"ConstantA": "1,1,1,1", "ConstantA": "1,1,1,1",
"ConstantB": "1,0,0,1" "ConstantB": "1,0,0,1"
}, },
"InitialVelocity": {
"X": 0,
"Y": 0,
"Z": 0
},
"Lifetime": { "Lifetime": {
"Type": "Range", "Type": "Range",
"Evaluation": "Particle", "Evaluation": "Seed",
"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.1,2,0,0" "Constants": "0.1,2,0,0"
}, },
"LocalSpace": 0,
"MaxParticles": 500, "MaxParticles": 500,
"OnComponentDestroy": null, "OnComponentDestroy": null,
"OnComponentDisabled": null, "OnComponentDisabled": null,
@@ -166,29 +158,7 @@
"Pitch": 0, "Pitch": 0,
"PreWarm": 0, "PreWarm": 0,
"PushStrength": 0, "PushStrength": 0,
"Roll": { "Roll": 0,
"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"
},
"Scale": { "Scale": {
"Type": "Curve", "Type": "Curve",
"Evaluation": "Life", "Evaluation": "Life",
@@ -230,36 +200,16 @@
"out": 0, "out": 0,
"mode": "Mirrored" "mode": "Mirrored"
} }
], ]
"Constants": "1,0,0,0"
}, },
"SequenceId": 0, "SequenceId": 0,
"SequenceSpeed": 1, "SequenceSpeed": 1,
"SequenceTime": 1, "SequenceTime": 1,
"SheetSequence": false, "SheetSequence": false,
"Space": "World",
"StartDelay": 0, "StartDelay": 0,
"StartVelocity": { "StartVelocity": {
"Type": "Range", "Type": "Range",
"Evaluation": "Particle", "Evaluation": "Seed",
"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": "10,70,0,0" "Constants": "10,70,0,0"
}, },
"Stretch": 0, "Stretch": 0,
@@ -267,7 +217,11 @@
"Timing": "GameTime", "Timing": "GameTime",
"Tint": "1,1,1,1", "Tint": "1,1,1,1",
"UsePrefabFeature": false, "UsePrefabFeature": false,
"Yaw": 0 "Yaw": {
"Type": "Range",
"Evaluation": "Seed",
"Constants": "0,360,0,0"
}
}, },
{ {
"__type": "Sandbox.ParticleSpriteRenderer", "__type": "Sandbox.ParticleSpriteRenderer",
@@ -302,7 +256,8 @@
"Scale": 1, "Scale": 1,
"Shadows": false, "Shadows": false,
"SortMode": "Unsorted", "SortMode": "Unsorted",
"Texture": "materials/particles/shapes/spark2.vtex" "Texture": "textures/shapes/spark3.vtex",
"TextureFilter": "Bilinear"
}, },
{ {
"__type": "Sandbox.ParticleConeEmitter", "__type": "Sandbox.ParticleConeEmitter",

View File

@@ -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
}

View File

@@ -1,58 +1,8 @@
{ {
"Pacejka": { "B": 9,
"Lateral": { "C": 2.15,
"a0": 1.4, "D": 0.933,
"a1": -0, "E": 0.971,
"a2": 1688,
"a3": 2400,
"a4": 6.026,
"a5": 0,
"a6": -0.359,
"a7": 1,
"a8": 0,
"a9": 0,
"a10": 0,
"a111": 0,
"a112": 0,
"a12": 0,
"a13": 0
},
"Longitudinal": {
"b0": 1.65,
"b1": 0,
"b2": 1690,
"b3": 0,
"b4": 229,
"b5": 0,
"b6": 0,
"b7": 0,
"b8": -10,
"b9": 0,
"b10": 0
},
"Aligning": {
"c0": 2,
"c1": -3.8,
"c2": -3.14,
"c3": -1.16,
"c4": -7.2,
"c5": 0,
"c6": 0,
"c7": 0.044,
"c8": -0.58,
"c9": 0.18,
"c10": 0,
"c11": 0,
"c12": 0,
"c13": 0,
"c14": 0.14,
"c15": -1.029,
"c16": 0,
"c17": 0
}
},
"RollResistanceLin": 0.001,
"RollResistanceQuad": 1E-06,
"__references": [], "__references": [],
"__version": 0 "__version": 0
} }

View File

@@ -0,0 +1,299 @@
{
"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,
"NetworkTransmit": true,
"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,
"NetworkTransmit": true,
"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": true,
"MotionBlur": false,
"OnComponentDestroy": null,
"OnComponentDisabled": null,
"OnComponentEnabled": null,
"OnComponentFixedUpdate": null,
"OnComponentStart": null,
"OnComponentUpdate": null,
"Opaque": false,
"PlaybackSpeed": 1,
"RenderOptions": {
"GameLayer": true,
"OverlayLayer": false,
"BloomLayer": false,
"AfterUILayer": false
},
"RotationOffset": 0,
"Scale": 1,
"Shadows": true,
"SortMode": "ByDistance",
"Sprite": {
"$compiler": "embed",
"$source": null,
"data": {
"Animations": [
{
"Name": "Default",
"FrameRate": 15,
"Origin": "0.5,0.5",
"LoopMode": "Loop",
"Frames": [
{
"Texture": "textures/smoketexturesheet.vtex",
"BroadcastMessages": []
}
]
}
],
"__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
}

BIN
Assets/sounds/tire/skid.wav Normal file

Binary file not shown.

View File

@@ -0,0 +1,8 @@
{
"loop": false,
"start": 0,
"end": 0,
"rate": 44100,
"compress": false,
"bitrate": 256
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 672 KiB

View File

@@ -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"
}

View File

@@ -1,49 +1,213 @@
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; protected override void OnAwake()
[Property] public float SlipTorque { get; set; } = 1000f; {
base.OnAwake();
Name ??= "Clutch";
}
public float Pressing { get; set; } = 1; // todo /// <summary>
/// RPM at which automatic clutch will try to engage.
/// </summary>
[Property] public float EngagementRPM { get; set; } = 1200f;
public float ThrottleEngagementOffsetRPM = 400f;
/// <summary>
/// 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.
/// </summary>
[Range( 0, 1 ), Property] public float ClutchInput { get; set; }
/// <summary>
/// Curve representing pedal travel vs. clutch engagement. Should start at 0,0 and end at 1,1.
/// </summary>
[Property] public Curve EngagementCurve { get; set; } = new( new List<Curve.Frame>() { new( 0, 0 ), new( 1, 1 ) } );
public enum ClutchControlType
{
Automatic,
Manual
}
[Property] public ClutchControlType СontrolType { get; set; } = ClutchControlType.Automatic;
/// <summary>
/// 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.
/// </summary>
[Property] public float EngagementRange { get; set; } = 400f;
/// <summary>
/// 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.
/// </summary>
[Property] public float SlipTorque { get; set; } = 500f;
/// <summary>
/// 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.
/// </summary>
[Range( 0, 100f ), Property] public float CreepTorque { get; set; } = 0;
[Property] public float CreepSpeedLimit { get; set; } = 1f;
/// <summary>
/// Clutch engagement based on ClutchInput and the clutchEngagementCurve
/// </summary>
[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 = 500 + 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.SwappedBrakes > 0 && Controller.SwappedThrottle == 0 )
{
ClutchInput = 0;
}
}
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() public override float QueryInertia()
{ {
if ( !HasOutput ) if ( OutputNameHash == 0 )
{
return Inertia; return Inertia;
return Inertia + Output.QueryInertia() * Pressing;
} }
public override float QueryAngularVelocity( float angularVelocity ) float I = Inertia + Output.QueryInertia() * _clutchEngagement;
{ return I;
this.angularVelocity = angularVelocity;
if ( !HasOutput )
return angularVelocity;
float outputW = Output.QueryAngularVelocity( angularVelocity ) * Pressing;
float inputW = angularVelocity * (1 - Pressing);
return outputW + inputW;
} }
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; 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 = Controller.Engine.EstimatedPeakTorque * 1.5f * _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;
} }
} }
}
}

View File

@@ -0,0 +1,234 @@
using Sandbox;
using System;
namespace VeloX;
public partial class Differential : PowertrainComponent
{
/// <param name="T">Input torque</param>
/// <param name="Wa">Angular velocity of the outputA</param>
/// <param name="Wb">Angular velocity of the outputB</param>
/// <param name="Ia">Inertia of the outputA</param>
/// <param name="Ib">Inertia of the outputB</param>
/// <param name="dt">Time step</param>
/// <param name="biasAB">Torque bias between outputA and outputB. 0 = all torque goes to A, 1 = all torque goes to B</param>
/// <param name="stiffness">Stiffness of the limited slip or locked differential 0-1</param>
/// <param name="powerRamp">Stiffness under power</param>
/// <param name="coastRamp">Stiffness under braking</param>
/// <param name="slipTorque">Slip torque of the limited slip differential</param>
/// <param name="Ta">Torque output towards outputA</param>
/// <param name="Tb">Torque output towards outputB</param>
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,
}
/// <summary>
/// Differential type.
/// </summary>
[Property]
public DifferentialType Type
{
get => _differentialType;
set
{
_differentialType = value;
AssignDifferentialDelegate();
}
}
private DifferentialType _differentialType;
/// <summary>
/// Torque bias between left (A) and right (B) output in [0,1] range.
/// </summary>
[Property, Range( 0, 1 )] public float BiasAB { get; set; } = 0.5f;
/// <summary>
/// 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.
/// </summary>
[Property, Range( 0, 1 ), HideIf( nameof( _differentialType ), DifferentialType.Open )] public float Stiffness { get; set; } = 0.5f;
/// <summary>
/// Stiffness of the LSD differential under acceleration.
/// </summary>
[Property, Range( 0, 1 ), ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float PowerRamp { get; set; } = 1f;
/// <summary>
/// Stiffness of the LSD differential under braking.
/// </summary>
[Property, Range( 0, 1 ), ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float CoastRamp { get; set; } = 0.5f;
/// <summary>
/// Second output of differential.
/// </summary>
[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;
/// <summary>
/// Slip torque of limited slip differentials.
/// </summary>
[Property, ShowIf( nameof( _differentialType ), DifferentialType.LimitedSlip )] public float SlipTorque { get; set; } = 400f;
/// <summary>
/// Function delegate that will be used to split the torque between output(A) and outputB.
/// </summary>
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 );
}
}

View File

@@ -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;
/// <summary>
/// The PowertrainComponent this component will output to.
/// </summary>
[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;
}
}

View File

@@ -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;
}
}

View File

@@ -1,109 +1,676 @@
using Sandbox; using Sandbox;
using Sandbox.Audio;
using System; 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; protected override void OnAwake()
[Property, Group( "Settings" )] public float MaxRPM { get; set; } = 7000f; {
[Property, Group( "Settings" )] public override float Inertia { get; set; } = 0.151f; base.OnAwake();
[Property, Group( "Settings" )] public float LimiterDuration { get; set; } = 0.05f; Name ??= "Engine";
[Property, Group( "Settings" )] public Curve TorqueMap { get; set; } UpdatePeakPowerAndTorque();
[Property, Group( "Settings" )] public EngineStream Stream { get; set; }
[Sync] public float Throttle { get; internal set; } }
[Property] public bool IsRedlining => !limiterTimer; [Hide] public new bool Input { get; set; }
[Property] public float RPMPercent => Math.Clamp( (RPM - IdleRPM) / (MaxRPM - IdleRPM), 0, 1 );
private float masterThrottle; public delegate float CalculateTorque( float angularVelocity, float dt );
private TimeUntil limiterTimer; /// <summary>
private float finalTorque; /// Delegate for a function that modifies engine power.
/// </summary>
public delegate float PowerModifier();
public enum EngineType
{
ICE,
Electric,
}
/// <summary>
/// If true starter will be ran for [starterRunTime] seconds if engine receives any throttle input.
/// </summary>
[Property] public bool AutoStartOnThrottle { get; set; } = true;
/// <summary>
/// Assign your own delegate to use different type of torque calculation.
/// </summary>
public CalculateTorque CalculateTorqueDelegate;
/// <summary>
/// Engine type. ICE (Internal Combustion Engine) supports features such as starter, stalling, etc.
/// Electric engine (motor) can run in reverse, can not be stalled and does not use starter.
/// </summary>
[Property] public EngineType Type { get; set; } = EngineType.ICE;
/// <summary>
/// Power generated by the engine in kW
/// </summary>
public float generatedPower;
/// <summary>
/// RPM at which idler circuit will try to keep RPMs when there is no input.
/// </summary>
[Property] public float IdleRPM { get; set; } = 900;
/// <summary>
/// Maximum engine power in [kW].
/// </summary>
[Property, Group( "Power" )] public float MaxPower { get; set; } = 120;
/// <summary>
/// Loss power (pumping, friction losses) is calculated as the percentage of maxPower.
/// Should be between 0 and 1 (100%).
/// </summary>
[Range( 0, 1 ), Property] public float EngineLossPercent { get; set; } = 0.8f;
/// <summary>
/// If true the engine will be started immediately, without running the starter, when the vehicle is enabled.
/// Sets engine angular velocity to idle angular velocity.
/// </summary>
[Property] public bool FlyingStartEnabled { get; set; }
[Property] public bool Ignition { get; private set; }
/// <summary>
/// Power curve with RPM range [0,1] on the X axis and power coefficient [0,1] on Y axis.
/// Both values are represented as percentages and should be in 0 to 1 range.
/// Power coefficient is multiplied by maxPower to get the final power at given RPM.
/// </summary>
[Property, Group( "Power" )] public Curve PowerCurve { get; set; } = new( new List<Curve.Frame>() { new( 0, 0.4f ), new( 0.16f, 0.65f ), new( 0.32f, 0.85f ), new( 0.64f, 1f ), new( 0.8f, 0.9f ), new( 0.9f, 0.6f ), new( 1f, 0.2f ) } );
/// <summary>
/// Is the engine currently hitting the rev limiter?
/// </summary>
public bool RevLimiterActive;
/// <summary>
/// If engine RPM rises above revLimiterRPM, how long should fuel cutoff last?
/// Higher values make hitting rev limiter more rough and choppy.
/// </summary>
[Property] public float RevLimiterCutoffDuration { get; set; } = 0.12f;
/// <summary>
/// Engine RPM at which rev limiter activates.
/// </summary>
[Property] public float RevLimiterRPM { get; set; } = 6700;
/// <summary>
/// Is the starter currently active?
/// </summary>
[Property, ReadOnly, Group( "Info" )] public bool StarterActive = false;
/// <summary>
/// Torque starter motor can put out. Make sure that this torque is more than loss torque
/// at the starter RPM limit. If too low the engine will fail to start.
/// </summary>
[Property] public float StartDuration = 0.5f;
/// <summary>
/// Peak power as calculated from the power curve. If the power curve peaks at Y=1 peak power will equal max power field value.
/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float EstimatedPeakPower => _peakPower;
private float _peakPower;
/// <summary>
/// RPM at which the peak power is achieved.
/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float EstimatedPeakPowerRPM => _peakPowerRpm;
private float _peakPowerRpm;
/// <summary>
/// Peak torque value as calculated from the power curve.
/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float EstimatedPeakTorque => _peakTorque;
private float _peakTorque;
/// <summary>
/// RPM at which the engine achieves the peak torque, calculated from the power curve.
/// After changing power, power curve or RPM range call UpdatePeakPowerAndTorque() to get update the value.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float EstimatedPeakTorqueRPM => _peakTorqueRpm;
private float _peakTorqueRpm;
/// <summary>
/// RPM as a percentage of maximum RPM.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float RPMPercent => _rpmPercent;
[Sync] private float _rpmPercent { get; set; }
/// <summary>
/// Engine throttle position. 0 for no throttle and 1 for full throttle.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public float ThrottlePosition { get; private set; }
/// <summary>
/// Is the engine currently running?
/// Requires ignition to be enabled.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public bool IsRunning { get; private set; }
/// <summary>
/// Is the engine currently running?
/// Requires ignition to be enabled.
/// </summary>
[Property, ReadOnly, Group( "Info" )]
public bool IsActive { get; private set; }
private float _idleAngularVelocity;
/// <summary>
/// Current load of the engine, based on the power produced.
/// </summary>
public float Load => _load;
public event Action OnEngineStart;
public event Action OnEngineStop;
public event Action OnRevLimiter;
private float _load;
private EngineStreamPlayer StreamPlayer;
public float[] friction = [15.438f, 2.387f, 0.7958f];
protected override void OnStart() protected override void OnStart()
{ {
base.OnStart(); base.OnStart();
StreamPlayer = new( Stream ); if ( Type == EngineType.ICE )
{
CalculateTorqueDelegate = CalculateTorqueICE;
}
else if ( Type == EngineType.Electric )
{
IdleRPM = 0f;
FlyingStartEnabled = true;
CalculateTorqueDelegate = CalculateTorqueElectric;
StarterActive = false;
StartDuration = 0.001f;
RevLimiterCutoffDuration = 0f;
}
} }
public float GetFrictionTorque( float throttle, float rpm ) public void StartEngine()
{ {
float s = rpm < 0 ? -1f : 1f; if ( IsRunning ) return;
float r = s * rpm * 0.001f;
float f = friction[0] + friction[1] * r + friction[2] * r * r; Ignition = true;
return -s * f * (1 - throttle); OnEngineStart?.Invoke();
if ( Type != EngineType.Electric )
{
if ( FlyingStartEnabled )
{
FlyingStart();
IsRunning = true;
IsActive = true;
} }
private float GenerateTorque() else if ( !StarterActive && Controller != null )
{ {
float throttle = Throttle; StarterCoroutine();
float rpm = RPM; }
float friction = GetFrictionTorque( throttle, rpm ); }
else
float maxInitialTorque = TorqueMap.Evaluate( RPMPercent ) - friction; {
float idleFadeStart = Math.Clamp( MathX.Remap( rpm, IdleRPM - 300, IdleRPM, 1, 0 ), 0, 1 ); IsRunning = true;
float idleFadeEnd = Math.Clamp( MathX.Remap( rpm, IdleRPM, IdleRPM + 600, 1, 0 ), 0, 1 ); IsActive = true;
float additionalEnergySupply = idleFadeEnd * (-friction / maxInitialTorque) + idleFadeStart;
if ( rpm > MaxRPM )
{
throttle = 0;
limiterTimer = LimiterDuration;
} }
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 __ ) private async void StarterCoroutine()
{ {
if ( !HasOutput ) if ( Type == EngineType.Electric || StarterActive )
{
angularVelocity += GenerateTorque() / Inertia * Time.Delta;
angularVelocity = Math.Max( angularVelocity, 0 );
return 0;
}
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()
{
if ( StreamPlayer is null )
return; return;
StreamPlayer.Throttle = Throttle; try
StreamPlayer.RPMPercent = RPMPercent; {
StreamPlayer.EngineState = EngineState.Running; float startTimer = 0f;
StreamPlayer.IsRedlining = IsRedlining; StarterActive = true;
// Ensure safe start duration
StartDuration = Math.Max( 0.1f, StartDuration );
_starterTorque = ((_idleAngularVelocity - OutputAngularVelocity) * Inertia) / StartDuration;
while ( startTimer <= StartDuration && StarterActive )
{
startTimer += 0.1f;
await Task.DelaySeconds( 0.1f );
if ( OutputAngularVelocity >= _idleAngularVelocity * 0.8f )
{
break;
}
}
}
finally
{
_starterTorque = 0;
StarterActive = false;
IsActive = true;
IsRunning = true;
}
}
private void FlyingStart()
{
Ignition = true;
StarterActive = false;
OutputAngularVelocity = IdleRPM.RPMToAngularVelocity();
IsRunning = true;
IsActive = true;
}
public void StopEngine()
{
Ignition = false;
IsRunning = false;
IsActive = false;
OnEngineStop?.Invoke();
}
/// <summary>
/// Toggles engine state.
/// </summary>
public void StartStopEngine()
{
if ( IsRunning )
StopEngine();
else
StartEngine();
}
public void UpdatePeakPowerAndTorque()
{
GetPeakPower( out _peakPower, out _peakPowerRpm );
GetPeakTorque( out _peakTorque, out _peakTorqueRpm );
}
public void UpdateEngine( in float dt )
{
StreamEngineUpdate( dt, WorldPosition );
if ( IsProxy )
return;
// Cache values
_userThrottleInput = _userThrottleInput.LerpTo( Controller.SwappedThrottle, 0.05f );
ThrottlePosition = _userThrottleInput;
_idleAngularVelocity = IdleRPM.RPMToAngularVelocity();
_revLimiterAngularVelocity = RevLimiterRPM.RPMToAngularVelocity();
if ( _revLimiterAngularVelocity == 0f )
return;
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 Task.DelayRealtimeSeconds( RevLimiterCutoffDuration );
RevLimiterActive = false;
}
/// <summary>
/// Calculates torque for electric engine type.
/// </summary>
public float CalculateTorqueElectric( float angularVelocity, float dt )
{
float absAngVel = Math.Abs( angularVelocity );
// Avoid angular velocity spikes while shifting
if ( Controller.Transmission.IsShifting )
ThrottlePosition = 0;
float maxLossPower = MaxPower * 0.3f;
float lossPower = maxLossPower * (1f - ThrottlePosition) * RPMPercent;
float genPower = MaxPower * ThrottlePosition;
float totalPower = genPower - lossPower;
totalPower = MathX.Lerp( totalPower * 0.1f, totalPower, RPMPercent * 10f );
float clampedAngVel = absAngVel < 10f ? 10f : absAngVel;
return PowerInKWToTorque( clampedAngVel, totalPower );
}
/// <summary>
/// Calculates torque for ICE (Internal Combustion Engine).
/// </summary>
public float CalculateTorqueICE( float angularVelocity, float dt )
{
// Set the throttle to 0 when shifting, but avoid doing so around idle RPM to prevent stalls.
if ( Controller.Transmission.IsShifting && angularVelocity > _idleAngularVelocity )
ThrottlePosition = 0f;
// Set throttle to 0 when starter active.
if ( StarterActive )
ThrottlePosition = 0f;
// Apply idle throttle correction to keep the engine running
else
ApplyICEIdleCorrection();
// Trigger rev limiter if needed
if ( angularVelocity >= _revLimiterAngularVelocity && !RevLimiterActive )
RevLimiter();
// Calculate torque
float generatedTorque;
// Do not generate any torque while starter is active to prevent RPM spike during startup
// or while stalled to prevent accidental starts.
if ( StarterActive )
generatedTorque = 0f;
else
generatedTorque = CalculateICEGeneratedTorqueFromPowerCurve();
float lossTorque = (StarterActive || ThrottlePosition > 0.2f) ? 0f : CalculateICELossTorqueFromPowerCurve();
// Reduce the loss torque at rev limiter, but allow it to be >0 to prevent vehicle getting
// stuck at rev limiter.
if ( RevLimiterActive )
lossTorque *= 0.25f;
generatedTorque += _starterTorque + lossTorque;
return generatedTorque;
}
private float CalculateICELossTorqueFromPowerCurve()
{
// Avoid issues with large torque spike around 0 angular velocity.
if ( OutputAngularVelocity < 10f )
return -OutputAngularVelocity * MaxPower * 0.03f;
float angVelPercent = OutputAngularVelocity < 10f ? 0.1f : Math.Clamp( OutputAngularVelocity, 0, _revLimiterAngularVelocity ) / _revLimiterAngularVelocity;
float lossPower = angVelPercent * 3f * -MaxPower * Math.Clamp( _userThrottleInput + 0.5f, 0, 1 ) * EngineLossPercent;
return PowerInKWToTorque( OutputAngularVelocity, lossPower );
}
private void ApplyICEIdleCorrection()
{
if ( Ignition && OutputAngularVelocity < _idleAngularVelocity * 1.1f )
{
// Apply a small correction to account for the error since the throttle is applied only
// if the idle RPM is below the target RPM.
float idleCorrection = _idleAngularVelocity * 1.08f - OutputAngularVelocity;
idleCorrection = idleCorrection < 0f ? 0f : idleCorrection;
float idleThrottlePosition = Math.Clamp( idleCorrection * 0.01f, 0, 1f );
ThrottlePosition = Math.Max( _userThrottleInput, idleThrottlePosition );
}
}
private float CalculateICEGeneratedTorqueFromPowerCurve()
{
generatedPower = 0;
float torque = 0;
if ( !Ignition && !StarterActive )
return 0;
if ( RevLimiterActive )
ThrottlePosition = 0.2f;
else
{
// Add maximum losses to the maximum power when calculating the generated power since the maxPower is net value (after losses).
generatedPower = PowerCurve.Evaluate( _rpmPercent ) * (MaxPower * (1f + EngineLossPercent)) * ThrottlePosition;
torque = PowerInKWToTorque( OutputAngularVelocity, generatedPower );
}
return torque;
}
public void GetPeakTorque( out float peakTorque, out float peakTorqueRpm )
{
peakTorque = 0;
peakTorqueRpm = 0;
for ( float i = 0.05f; i < 1f; i += 0.05f )
{
float rpm = i * RevLimiterRPM;
float P = PowerCurve.Evaluate( i ) * MaxPower;
if ( rpm < IdleRPM )
{
continue;
}
float W = rpm.RPMToAngularVelocity();
float T = (P * 1000f) / W;
if ( T > peakTorque )
{
peakTorque = T;
peakTorqueRpm = rpm;
}
}
}
public void GetPeakPower( out float peakPower, out float peakPowerRpm )
{
float maxY = 0f;
float maxX = 1f;
for ( float i = 0f; i < 1f; i += 0.05f )
{
float y = PowerCurve.Evaluate( i );
if ( y > maxY )
{
maxY = y;
maxX = i;
}
}
peakPower = maxY * MaxPower;
peakPowerRpm = maxX * RevLimiterRPM;
}
[Property, Group( "Parsing" )] string Clipboard { get; set; }
[Button, Group( "Parsing" )]
public void FromLUT()
{
if ( Clipboard == null )
return;
using var undo = Scene.Editor?.UndoScope( "From LUT" ).WithComponentChanges( this ).Push();
var data = new List<(float RPM, float PowerHP)>();
var lines = Clipboard.Split( ['\n', '\r'], StringSplitOptions.RemoveEmptyEntries );
foreach ( var line in lines )
{
var parts = line.Split( '|' );
if ( parts.Length == 2 &&
float.TryParse( parts[0], out float rpm ) &&
float.TryParse( parts[1], out float powerHP ) )
{
data.Add( (rpm, powerHP) );
}
}
RevLimiterRPM = data.Max( x => x.RPM );
MaxPower = data.Max( x => x.PowerHP ) * 0.746f;
var frames = data.Select( d =>
new Curve.Frame(
d.RPM / RevLimiterRPM,
(d.PowerHP * 0.746f) / MaxPower
)
).ToList();
PowerCurve = new( frames );
Clipboard = null;
}
[Property] public EngineStream Stream { get; set; }
public bool EngineSoundPaused => !IsRunning;
private float _wobbleTime;
private static readonly Mixer EngineMixer = Mixer.FindMixerByName( "Car Engine" );
private readonly Dictionary<Layer, SoundHandle> EngineSounds = [];
protected override void OnDestroy()
{
base.OnDestroy();
foreach ( var item in EngineSounds.Values )
item.Dispose();
}
public void StreamEngineUpdate( float deltaTime, Vector3 position, bool isLocal = false )
{
var globalPitch = 1.0f;
// Gear wobble effect
if ( _wobbleTime > 0 )
{
_wobbleTime -= deltaTime * (0.1f + ThrottlePosition);
globalPitch += MathF.Cos( _wobbleTime * Stream.Parameters.WobbleFrequency ) * _wobbleTime * (1 - _wobbleTime) * Stream.Parameters.WobbleStrength;
}
globalPitch *= Stream.Parameters.Pitch;
// Redline effect
var redlineVolume = 1.0f;
if ( RPMPercent >= 0.95f )
{
redlineVolume = 1 - Stream.Parameters.RedlineStrength +
MathF.Cos( Time.Now * Stream.Parameters.RedlineFrequency ) *
Stream.Parameters.RedlineStrength;
}
// Process layers
foreach ( var (id, layer) in Stream.Layers )
{
EngineSounds.TryGetValue( layer, out var channel );
if ( !channel.IsValid() )
{
channel = Sound.PlayFile( layer.AudioPath );
EngineSounds[layer] = channel;
}
if ( channel.Paused && EngineSoundPaused )
continue;
// Reset controller outputs
float layerVolume = 1.0f;
float layerPitch = 1.0f;
// Apply all controllers
foreach ( var controller in layer.Controllers )
{
var inputValue = controller.InputParameter switch
{
Audio.Controller.InputTypes.Throttle => _userThrottleInput,
Audio.Controller.InputTypes.RpmFraction => RPMPercent,
_ => 0.0f
};
var normalized = Math.Clamp( inputValue, controller.InputRange.Min, controller.InputRange.Max );
var outputValue = controller.InputRange.Remap(
normalized,
controller.OutputRange.Min,
controller.OutputRange.Max
);
// Apply to correct parameter
switch ( controller.OutputParameter )
{
case Audio.Controller.OutputTypes.Volume:
layerVolume *= outputValue;
break;
case Audio.Controller.OutputTypes.Pitch:
layerPitch *= outputValue;
break;
}
}
// Apply redline effect if needed
layerVolume *= layer.UseRedline ? redlineVolume : 1.0f;
layerPitch *= globalPitch;
// Update audio channel
channel.Pitch = layerPitch;
channel.Volume = layerVolume * Stream.Parameters.Volume;
channel.Position = position;
channel.ListenLocal = isLocal;
channel.Paused = EngineSoundPaused;
channel.TargetMixer = EngineMixer;
}
}
} }

View File

@@ -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;
}
}

View File

@@ -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 );
}
}

View File

@@ -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;
}
}

View File

@@ -1,26 +1,31 @@
using Sandbox; using System;
using System; using Sandbox;
namespace VeloX.Powertrain; namespace VeloX;
public abstract class PowertrainComponent : Component public abstract class PowertrainComponent : Component
{ {
protected override void OnAwake() [Property] public VeloXBase Controller;
{ /// <summary>
Vehicle ??= Components.Get<VeloXBase>( FindMode.EverythingInSelfAndAncestors ); /// Name of the component. Only unique names should be used on the same vehicle.
} /// </summary>
[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; } /// <summary>
[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.
/// </summary>
[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;
/// <summary> /// <summary>
/// Input component. Set automatically. /// Input component. Set automatically.
@@ -34,67 +39,143 @@ public abstract class PowertrainComponent : Component
if ( value == null || value == this ) if ( value == null || value == this )
{ {
_input = null; _input = null;
InputNameHash = 0;
return; return;
} }
_input = value; _input = value;
InputNameHash = _input.GetHashCode();
} }
} }
private PowertrainComponent _input; protected PowertrainComponent _input;
public int InputNameHash; public int InputNameHash;
/// <summary> /// <summary>
/// The PowertrainComponent this component will output to. /// The PowertrainComponent this component will output to.
/// </summary> /// </summary>
[Property] [Property]
public PowertrainComponent Output public PowertrainComponent Output
{ {
get => _output; get { return _output; }
set set
{ {
if ( value == this ) if ( value == this )
{ {
Log.Warning( $"{Name}: PowertrainComponent Output can not be self." );
OutputNameHash = 0;
_output = null; _output = null;
return; return;
} }
_output = value; _output = value;
if ( _output != null ) if ( _output != null )
{
_output.Input = this; _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; /// <summary>
protected float Torque; /// Input shaft RPM of component.
/// </summary>
[Property, ReadOnly]
public float InputRPM => AngularVelocityToRPM( InputAngularVelocity );
public virtual bool HasOutput => Output.IsValid();
/// <summary>
/// Output shaft RPM of component.
/// </summary>
[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() public virtual float QueryInertia()
{ {
if ( !HasOutput ) if ( OutputNameHash == 0 )
return Inertia; 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 ) InputTorque = torque;
return angularVelocity; 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 ) // Power (W) = Torque (Nm) * Angular Velocity (rad/s)
return Torque; 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;
} }

View File

@@ -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();
}
/// <summary>
/// A class representing a single ground surface type.
/// </summary>
public class TransmissionGearingProfile
{
/// <summary>
/// List of forward gear ratios starting from 1st forward gear.
/// </summary>
public List<float> ForwardGears { get; set; } = [3.59f, 2.02f, 1.38f, 1f, 0.87f];
/// <summary>
/// List of reverse gear ratios starting from 1st reverse gear.
/// </summary>
public List<float> ReverseGears { get; set; } = [-4f,];
}
public const float INPUT_DEADZONE = 0.05f;
public float ReferenceShiftRPM => _referenceShiftRPM;
private float _referenceShiftRPM;
/// <summary>
/// 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.
/// </summary>
[Property] public bool HoldToKeepInGear { get; set; }
/// <summary>
/// Final gear multiplier. Each gear gets multiplied by this value.
/// Equivalent to axle/differential ratio in real life.
/// </summary>
[Property] public float FinalGearRatio { get; set; } = 4.3f;
/// <summary>
/// [Obsolete, will be removed]
/// Currently active gearing profile.
/// Final gear ratio will be determined from this and final gear ratio.
/// </summary>
[Property] public TransmissionGearingProfile GearingProfile { get; set; } = new();
/// <summary>
/// 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.
/// </summary>
[Property, ReadOnly, Group( "Info" )] public List<float> Gears = new();
/// <summary>
/// Number of forward gears.
/// </summary>
public int ForwardGearCount;
/// <summary>
/// Number of reverse gears.
/// </summary>
public int ReverseGearCount;
/// <summary>
/// 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.
/// </summary>
[Property, Range( 0, 4 )] public float InclineEffectCoeff { get; set; }
/// <summary>
/// Function that handles gear shifts.
/// Use External transmission type and assign this delegate manually to use a custom
/// gear shift function.
/// </summary>
public delegate void Shift( VeloXBase vc );
/// <summary>
/// Function that changes the gears as required.
/// Use transmissionType External and assign this delegate to use your own gear shift code.
/// </summary>
public Shift ShiftDelegate;
/// <summary>
/// Event that gets triggered when transmission shifts down.
/// </summary>
public event Action OnGearDownShift;
/// <summary>
/// Event that gets triggered when transmission shifts (up or down).
/// </summary>
public event Action OnGearShift;
/// <summary>
/// Event that gets triggered when transmission shifts up.
/// </summary>
public event Action OnGearUpShift;
/// <summary>
/// Time after shifting in which shifting can not be done again.
/// </summary>
[Property] public float PostShiftBan { get; set; } = 0.5f;
public enum AutomaticTransmissionDNRShiftType
{
Auto,
RequireShiftInput,
RepeatInput,
}
/// <summary>
/// Behavior when switching from neutral to forward or reverse gear.
/// </summary>
[Property] public AutomaticTransmissionDNRShiftType DNRShiftType { get; set; } = AutomaticTransmissionDNRShiftType.Auto;
/// <summary>
/// Speed at which the vehicle can switch between D/N/R gears.
/// </summary>
[Property] public float DnrSpeedThreshold { get; set; } = 0.4f;
/// <summary>
/// If set to >0, the clutch will need to be released to the value below the set number
/// for gear shifts to occur.
/// </summary>
[Property, Range( 0, 1 )] public float ClutchInputShiftThreshold { get; set; } = 1f;
/// <summary>
/// Time it takes transmission to shift between gears.
/// </summary>
[Property] public float ShiftDuration { get; set; } = 0.2f;
/// <summary>
/// Intensity of variable shift point. Higher value will result in shift point moving higher up with higher engine
/// load.
/// </summary>
[Property, Range( 0, 1 )] public float VariableShiftIntensity { get; set; } = 0.3f;
/// <summary>
/// If enabled transmission will adjust both shift up and down points to match current load.
/// </summary>
[Property] public bool VariableShiftPoint { get; set; } = true;
/// <summary>
/// Current gear ratio.
/// </summary>
[Property, ReadOnly] public float CurrentGearRatio { get; set; }
/// <summary>
/// Is the transmission currently in the post-shift phase in which the shifting is disabled/banned to prevent gear hunting?
/// </summary>
[Property, ReadOnly] public bool IsPostShiftBanActive { get; set; }
/// <summary>
/// Is a gear shift currently in progress.
/// </summary>
[Property, ReadOnly] public bool IsShifting { get; set; }
/// <summary>
/// Progress of the current gear shift in range of 0 to 1.
/// </summary>
[Property, ReadOnly] public float ShiftProgress { get; set; }
/// <summary>
/// Current RPM at which transmission will aim to downshift. All the modifiers are taken into account.
/// This value changes with driving conditions.
/// </summary>
[Property]
public float DownshiftRPM
{
get => _downshiftRPM;
set { _downshiftRPM = Math.Clamp( value, 0, float.MaxValue ); }
}
private float _downshiftRPM = 1400;
/// <summary>
/// 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.
/// </summary>
[Property]
public float TargetDownshiftRPM => _targetDownshiftRPM;
private float _targetDownshiftRPM;
/// <summary>
/// RPM at which automatic transmission will shift up. If dynamic shift point is enabled this value will change
/// depending on load.
/// </summary>
[Property]
public float UpshiftRPM
{
get => _upshiftRPM;
set { _upshiftRPM = Math.Clamp( value, 0, float.MaxValue ); }
}
private float _upshiftRPM = 2800;
/// <summary>
/// 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.
/// </summary>
[Property]
public float TargetUpshiftRPM => _targetUpshiftRPM;
private float _targetUpshiftRPM;
public enum TransmissionShiftType
{
Manual,
Automatic,
//CVT,
}
/// <summary>
/// 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)
/// </summary>
[Property]
public TransmissionShiftType TransmissionType
{
get => transmissionType; set
{
if ( value == transmissionType )
return;
transmissionType = value;
AssignShiftDelegate();
}
}
/// <summary>
/// Is the automatic gearbox sequential?
/// Has no effect on manual transmission.
/// </summary>
[Property] public bool IsSequential { get; set; } = false;
[Property] public bool AllowUpshiftGearSkipping { get; set; }
[Property] public bool AllowDownshiftGearSkipping { get; set; } = true;
private bool _repeatInputFlag;
/// <summary>
/// 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.
/// </summary>
private float _slipOutOfGearTimer = -999f;
/// <summary>
/// 0 for neutral, less than 0 for reverse gears and lager than 0 for forward gears.
/// Use 'ShiftInto' to set gear.
/// </summary>
[Property, Sync]
public int Gear
{
get => IndexToGear( gearIndex );
set => gearIndex = GearToIndex( value );
}
/// <summary>
/// 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.
/// </summary>
public int gearIndex;
private TransmissionShiftType transmissionType = TransmissionShiftType.Automatic;
private int GearToIndex( int g )
{
return g + ReverseGearCount;
}
private int IndexToGear( int g )
{
return g - ReverseGearCount;
}
/// <summary>
/// Returns current gear name as a string, e.g. "R", "R2", "N" or "1"
/// </summary>
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<int, string> _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();
}
/// <summary>
/// Total gear ratio of the transmission for current gear.
/// </summary>
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);
}
/// <summary>
/// Calculates the would-be RPM if none of the wheels was slipping.
/// </summary>
/// <returns>RPM as it would be if the wheels are not slipping or in the air.</returns>
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 );
}
}
/// <summary>
/// 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.
/// </summary>
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 Task.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 Task.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 Task.DelayRealtimeSeconds( dt );
}
// Post shift ban has finished
IsPostShiftBanActive = false;
}
}
private void CVTShift( VeloXBase car ) => AutomaticShift( car );
/// <summary>
/// Handles automatic and automatic sequential shifting.
/// </summary>
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 = car.Engine.EstimatedPeakPowerRPM - 2000;
_targetUpshiftRPM = car.Engine.EstimatedPeakPowerRPM;
// 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 );
}
}
}
}
}
/// <summary>
/// Converts axle RPM to engine RPM for given gear in Gears list.
/// </summary>
public float ReverseTransmitRPM( float inputRPM, int g )
{
float outRpm = inputRPM * Gears[GearToIndex( g )] * FinalGearRatio;
return Math.Abs( outRpm );
}
}

View File

@@ -0,0 +1,67 @@
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;
}
private float _initialRollingResistance;
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.DriveTorque = OutputTorque;
Wheel.Inertia = OutputInertia;
Wheel.AutoSimulate = false;
Wheel.StepPhys( Controller, dt );
return Math.Abs( Wheel.CounterTorque );
}
protected override void DrawGizmos()
{
if ( !Gizmo.IsSelected )
return;
Gizmo.Transform = Wheel.WorldTransform;
Wheel?.GizmoDraw();
}
}

View File

@@ -0,0 +1,127 @@
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<VeloXWheel> 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>();
Engine.Controller = this;
Engine.Inertia = 0.25f;
if ( !Clutch.IsValid() )
Clutch = new GameObject( Engine.GameObject, true, "Clutch" ).GetOrAddComponent<Clutch>();
Clutch.Controller = this;
Clutch.Inertia = 0.02f;
Engine.Output = Clutch;
if ( !Transmission.IsValid() )
Transmission = new GameObject( Clutch.GameObject, true, "Transmission" ).GetOrAddComponent<Transmission>();
Transmission.Controller = this;
Transmission.Inertia = 0.01f;
Clutch.Output = Transmission;
Differential = new TreeBuilder( Transmission, MotorWheels ).Root.Diff;
Transmission.Output = Differential;
//PowertrainWheels = Differential.Components.GetAll<WheelPowertrain>( 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<VeloXWheel> 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<VeloXWheel> 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<WheelPowertrain>() };
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<Differential>();
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;
}
}

View File

@@ -4,20 +4,173 @@ namespace VeloX;
public abstract partial class VeloXBase public abstract partial class VeloXBase
{ {
[Property, Feature( "Input" )] internal InputResolver Input { get; set; } = new(); internal readonly InputResolver Input = new();
private Guid _guid; public Guid DriverId { get; set; }
public Connection Driver => Connection.Find( DriverId );
[Sync( SyncFlags.FromHost )] public bool IsDriver => Connection.Local == Driver;
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;
public bool AnyInput => Throttle > 0 || Brakes > 0;
[Sync]
public float VerticalInput
{ {
get => _guid; get => throttle - brakes;
set set
{ {
_guid = value; float clampedValue = Math.Clamp( value, -1, 1 );
Input.Driver = Connection.Find( _guid );
if ( value > 0 )
{
throttle = clampedValue;
brakes = 0;
}
else
{
throttle = 0;
brakes = -clampedValue;
} }
} }
[Property, Feature( "Input" )] public bool IsDriver => ConnectionID == Connection.Local.Id; }
/// <summary>
/// Throttle axis.
/// For combined throttle/brake input use 'VerticalInput' instead.
/// </summary>
[Sync( SyncFlags.Interpolate ), Range( 0, 1 ), Property, ReadOnly]
public float Throttle
{
get => throttle;
set => throttle = Math.Clamp( value, 0, 1 );
}
/// <summary>
/// Brake axis.
/// For combined throttle/brake input use 'VerticalInput' instead.
/// </summary>
[Range( 0, 1 ), Property, ReadOnly]
public float Brakes
{
get => brakes;
set => brakes = Math.Clamp( value, 0, 1 );
}
[Sync( SyncFlags.Interpolate ), Range( 0, 1 ), Property, ReadOnly]
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;
}
protected void UpdateInput()
{
//VerticalInput = Input.AnalogMove.x;
if ( IsDriverActive )
{
Brakes = Input.Brake;
Throttle = Input.Throttle;
Handbrake = Input.Down( "Handbrake" ) ? 1 : 0;
SteeringAngle = Input.AnalogMove.y;
IsClutching = Input.Down( "Clutch" ) ? 1 : 0;
IsShiftingUp = Input.Pressed( "Shift Up" );
IsShiftingDown = Input.Pressed( "Shift Down" );
}
else
{
ResetInput();
}
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 TriggerHaptics( HapticEffect effect, float lengthScale = 1, float frequencyScale = 1, float amplitudeScale = 1 )
{
if ( IsDriverActive )
{
Input.TriggerHaptics( effect, lengthScale, frequencyScale, amplitudeScale );
}
}
public void StopAllHaptics()
{
if ( IsDriverActive )
Input.StopAllHaptics();
}
} }

View File

@@ -1,24 +1,63 @@
namespace VeloX; using Sandbox;
namespace VeloX;
public abstract partial class VeloXBase public abstract partial class VeloXBase
{ {
private Vector3 angForce; private Vector3 angForce;
protected const float BrakeForce = 4500f;
private void PhysicsSimulate() protected const float HandbrakeForce = 35000f;
protected void PhysicsSimulate()
{ {
if ( Body.Sleeping && Input.AnalogMove.x == 0 )
return;
//Body.PhysicsBody.SetInertiaTensor( new Vector3( 800000, 3000000, 6000000 ), Rotation.Identity );
var drag = AngularDrag; var drag = AngularDrag;
var mass = Body.Mass; var mass = Body.Mass;
var angVel = Body.AngularVelocity; var angVel = Body.AngularVelocity;
angForce.x = angVel.x * drag.x * mass; angForce = angForce.WithX( angVel.x * drag.x * mass * 1000 );
angForce.y = angVel.y * drag.y * mass; angForce = angForce.WithY( angVel.y * drag.y * mass * 1000 );
angForce.z = angVel.z * drag.z * mass; angForce = angForce.WithZ( angVel.z * drag.z * mass * 1000 );
if ( Wheels.Count > 0 ) if ( Wheels.Count > 0 )
foreach ( var v in Wheels ) {
if ( v.AutoPhysics ) v.DoPhysics( this ); Vector3 vehVel = Body.Velocity;
Vector3 vehAngVel = Body.AngularVelocity;
var dt = Time.Delta;
CombinedLoad = 0;
foreach ( var v in Wheels )
CombinedLoad += v.Fz;
foreach ( var v in Wheels )
{
if ( v.IsFront )
{
v.BrakeTorque = SwappedBrakes * BrakeForce * 1.3f;
}
else
{
v.BrakeTorque = SwappedBrakes * BrakeForce * 0.7f;
v.BrakeTorque += Handbrake * HandbrakeForce;
}
if ( TotalSpeed < 1 && !AnyInput )
{
v.BrakeTorque = HandbrakeForce;
}
}
Body.Velocity = vehVel;
Body.AngularVelocity = vehAngVel;
}
//Body.ApplyForce( linForce );
//Body.ApplyTorque( angForce );
Body.ApplyTorque( angForce );
} }
} }

View File

@@ -52,11 +52,13 @@ public abstract partial class VeloXBase : Component, Component.ICollisionListene
{ {
var hardSound = Sound.Play( HardCollisionSound, WorldPosition ); var hardSound = Sound.Play( HardCollisionSound, WorldPosition );
hardSound.Volume = volume; hardSound.Volume = volume;
TriggerHaptics( HapticEffect.HardImpact );
} }
else if ( surfaceNormal.Dot( -collision.Contact.Speed.Normal ) < 0.5f ) else if ( surfaceNormal.Dot( -collision.Contact.Speed.Normal ) < 0.5f )
{ {
var scrapSound = Sound.Play( VehicleScrapeSound, WorldPosition ); var scrapSound = Sound.Play( VehicleScrapeSound, WorldPosition );
scrapSound.Volume = 0.4f; scrapSound.Volume = 0.4f;
TriggerHaptics( HapticEffect.SoftImpact );
} }
} }
} }

View File

@@ -1,13 +1,16 @@
using Sandbox; using Sandbox;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
namespace VeloX; namespace VeloX;
public abstract partial class VeloXBase public abstract partial class VeloXBase
{ {
public float CombinedLoad { get; protected set; }
[Property] public List<VeloXWheel> Wheels { get; set; } [Property] public List<VeloXWheel> Wheels { get; set; }
[Property] public TagSet WheelIgnoredTags { get; set; } [Property] public TagSet WheelIgnoredTags { get; set; }
public bool IsOnGround => Wheels.Any( x => x.IsOnGround );
public List<VeloXWheel> FindWheels() => [.. Components.GetAll<VeloXWheel>()]; public List<VeloXWheel> FindWheels() => [.. Components.GetAll<VeloXWheel>()];
@@ -16,5 +19,4 @@ public abstract partial class VeloXBase
{ {
Wheels = FindWheels(); Wheels = FindWheels();
} }
} }

View File

@@ -1,14 +1,23 @@
using Sandbox; using Sandbox;
using System;
namespace VeloX; namespace VeloX;
public abstract partial class VeloXBase : Component public abstract partial class VeloXBase : Component, IGameObjectNetworkEvents
{ {
[Sync] public WaterState WaterState { get; set; } [Sync, Change( nameof( OnEngineIgnitionChange ) )]
[Sync] public bool IsEngineOnFire { get; set; } public bool EngineIgnition { get; set; }
[Sync, Range( 0, 1 ), Property] public float Brake { get; set; }
[Sync( SyncFlags.Interpolate ), Range( 0, 1 ), Property] public float Throttle { get; set; }
private void OnEngineIgnitionChange( bool oldvalue, bool newvalue )
{
if ( newvalue )
Engine?.StartEngine();
else
Engine?.StopEngine();
}
[Property] public Vector3 AngularDrag { get; set; } = new( -0.1f, -0.1f, -3 ); [Property] public Vector3 AngularDrag { get; set; } = new( -0.1f, -0.1f, -3 );
[Property] public float Mass { get; set; } = 900; [Property] public float Mass { get; set; } = 900;
@@ -17,20 +26,33 @@ public abstract partial class VeloXBase : Component
[Sync( SyncFlags.Interpolate )] public Angles SteerAngle { get; set; } [Sync( SyncFlags.Interpolate )] public Angles SteerAngle { get; set; }
public Vector3 LocalVelocity; [Sync( SyncFlags.Interpolate )] public Vector3 LocalVelocity { get; set; }
[Sync( SyncFlags.Interpolate )] public Vector3 Velocity { get; set; }
public float ForwardSpeed; public float ForwardSpeed;
public float TotalSpeed; public float TotalSpeed;
protected override void OnFixedUpdate() protected override void OnFixedUpdate()
{ {
if ( !IsDriver ) if ( !IsProxy )
return; {
LocalVelocity = WorldTransform.PointToLocal( WorldPosition + Body.Velocity ); LocalVelocity = WorldTransform.PointToLocal( WorldPosition + Body.Velocity );
Velocity = Body.Velocity;
}
ForwardSpeed = LocalVelocity.x; ForwardSpeed = LocalVelocity.x;
TotalSpeed = LocalVelocity.Length; TotalSpeed = LocalVelocity.Length;
if ( IsProxy )
return;
Body.PhysicsBody.Mass = Mass; Body.PhysicsBody.Mass = Mass;
FixedUpdate();
}
protected virtual void FixedUpdate()
{
UpdateInput();
PhysicsSimulate(); PhysicsSimulate();
} }

View File

@@ -1,11 +1,16 @@
namespace VeloX; using System;
namespace VeloX;
public struct Friction public struct Friction
{ {
public float SlipCoef { get; set; }
public float ForceCoef { get; set; }
public float Force { get; set; } public float Force { get; set; }
public float Slip { get; set; } public float Slip { get; set; }
public float Speed { get; set; } public float Speed { get; set; }
public override readonly string ToString()
{
return $"Force:{Math.Round(Force, 2)}\nSlip:{Math.Round(Slip, 2)}\nSpeed:{Math.Round(Speed, 2)}";
}
} }

View File

@@ -1,303 +0,0 @@
using Sandbox;
using Sandbox.Services;
using System;
namespace VeloX;
public class Pacejka
{
public struct LateralForce()
{
[Description( "Shape factor" )]
[Range( 1, 3 )] public float a0 { get; set; } = 1.4f; // 0
[Description( "Load infl on lat friction coeff (*1000) (1/kN)" )]
[Range( -100, 100 )] public float a1 { get; set; } = -0f; // 1
[Description( "Lateral friction coefficient at load = 0 (*1000)" )]
[Range( 1, 2500 )] public float a2 { get; set; } = 1688f; // 2
[Description( "Maximum stiffness (N/deg)" )]
[Range( 1, 5000 )] public float a3 { get; set; } = 2400f; // 3
[Description( "Load at maximum stiffness (kN)" )]
[Range( -100, 100 )] public float a4 { get; set; } = 6.026f; // 4
[Description( "Camber infiuence on stiffness (%/deg/100)" )]
[Range( -10, 10 )] public float a5 { get; set; } = 0f; // 5
[Description( "Curvature change with load" )]
[Range( -10, 10 )] public float a6 { get; set; } = -0.359f; // 6
[Description( "Curvature at load = 0" )]
[Range( -10, 10 )] public float a7 { get; set; } = 1.0f; // 7
[Description( "Horizontal shift because of camber (deg/deg)" )]
[Range( -10, 10 )] public float a8 { get; set; } = 0f; // 8
[Description( "Load influence on horizontal shift (deg/kN)" )]
[Range( -10, 10 )] public float a9 { get; set; } = 0;// 9
[Description( "Horizontal shift at load = 0 (deg)" )]
[Range( -10, 10 )] public float a10 { get; set; } = 0;// 10
[Description( "Camber influence on vertical shift (N/deg/kN)" )]
[Range( -10, 100 )] public float a111 { get; set; } = 0f; // 11
[Description( "Camber influence on vertical shift (N/deg/kN**2" )]
[Range( -10, 10 )] public float a112 { get; set; } = 0f; // 12
[Description( "Load influence on vertical shift (N/kN)" )]
[Range( -100, 100 )] public float a12 { get; set; } = 0f; // 13
[Description( "Vertical shift at load = 0 (N)" )]
[Range( -10, 10 )] public float a13 { get; set; } = 0f; // 14
}
public struct LongitudinalForce()
{
[Description( "Shape factor" )]
[Range( 1, 3 )] public float b0 { get; set; } = 1.65f; // 0
[Description( "Load infl on long friction coeff (*1000) (1/kN)" )]
[Range( -300, 300 )] public float b1 { get; set; } = 0f; // 1
[Description( "Longitudinal friction coefficient at load = 0 (*1000)" )]
[Range( 0, 10000 )] public float b2 { get; set; } = 1690f; // 2
[Description( "Curvature factor of stiffness (N/%/kN**2)" )]
[Range( -100, 100 )] public float b3 { get; set; } = 0f; // 3
[Description( "Change of stiffness with load at load = 0 (N/%/kN)" )]
[Range( -1000, 1000 )] public float b4 { get; set; } = 229f; // 4
[Description( "Change of progressivity of stiffness/load (1/kN)" )]
[Range( -10, 10 )] public float b5 { get; set; } = 0f; // 5
[Description( "Curvature change with load" )]
[Range( -10, 10 )] public float b6 { get; set; } = 0f; // 6
[Description( "Curvature change with load" )]
[Range( -10, 10 )] public float b7 { get; set; } = 0f; // 7
[Description( "Curvature at load = 0" )]
[Range( -10, 10 )] public float b8 { get; set; } = -10f; // 7
[Description( "Load influence on horizontal shift (%/kN)" )]
[Range( -10, 10 )] public float b9 { get; set; } = 0f; // 9
[Description( "Horizontal shift at load = 0 (%)" )]
[Range( -10, 10 )] public float b10 { get; set; } = 0f; // 10
}
public struct AligningMoment()
{
[Description( "Shape factor" )]
[Range( 1, 7 )] public float c0 { get; set; } = 2.0f; // 0
[Description( "Load influence of peak value (Nm/kN**2)" )]
[Range( -10, 10 )] public float c1 { get; set; } = -3.8f; // 1
[Description( "Load influence of peak value (Nm/kN)" )]
[Range( -10, 10 )] public float c2 { get; set; } = -3.14f; // 2
[Description( "Curvature factor of stiffness (Nm/deg/kN**2" )]
[Range( -10, 10 )] public float c3 { get; set; } = -1.16f; // 3
[Description( "Change of stiffness with load at load = 0 (Nm/deg/kN)" )]
[Range( -100, 100 )] public float c4 { get; set; } = -7.2f; // 4
[Description( "Change of progressivity of stiffness/load (1/kN)" )]
[Range( -10, 10 )] public float c5 { get; set; } = 0.0f; // 5
[Description( "Camber influence on stiffness (%/deg/100)" )]
[Range( -10, 10 )] public float c6 { get; set; } = 0.0f; // 6
[Description( "Curvature change with load" )]
[Range( -10, 10 )] public float c7 { get; set; } = 0.044f; // 7
[Description( "Curvature change with load" )]
[Range( -10, 10 )] public float c8 { get; set; } = -0.58f; // 8
[Description( "Curvature at load = 0" )]
[Range( -10, 10 )] public float c9 { get; set; } = 0.18f; // 9
[Description( "Camber influence of stiffness" )]
[Range( -10, 10 )] public float c10 { get; set; } = 0.0f; // 10
[Description( "Camber influence on horizontal shift (deg/deg)" )]
[Range( -10, 10 )] public float c11 { get; set; } = 0.0f; // 11
[Description( "Load influence on horizontal shift (deg/kN)" )]
[Range( -10, 10 )] public float c12 { get; set; } = 0.0f; // 12
[Description( "Horizontal shift at load = 0 (deg)" )]
[Range( -10, 10 )] public float c13 { get; set; } = 0.0f; // 13
[Description( "Camber influence on vertical shift (Nm/deg/kN**2" )]
[Range( -10, 10 )] public float c14 { get; set; } = 0.14f; // 14
[Description( "Camber influence on vertical shift (Nm/deg/kN)" )]
[Range( -10, 10 )] public float c15 { get; set; } = -1.029f; // 15
[Description( "Load influence on vertical shift (Nm/kN)" )]
[Range( -10, 10 )] public float c16 { get; set; } = 0.0f; // 16
[Description( "Vertical shift at load = 0 (Nm)" )]
[Range( -10, 10 )] public float c17 { get; set; } = 0.0f; // 17
}
public struct CombiningForce
{
public float gy1 = 1; // 0
public float gy2 = 1; // 1
public float gx1 = 1; // 2
public float gx2 = 1f; // 3
public CombiningForce()
{
}
}
public LateralForce Lateral { get; set; } = new();
public LongitudinalForce Longitudinal { get; set; } = new();
public AligningMoment Aligning { get; set; } = new();
public CombiningForce Combining = new();
/// pacejka magic formula for longitudinal force
public float PacejkaFx( float sigma, float Fz, float friction_coeff, out float maxforce_output )
{
var b = Longitudinal;
// shape factor
float C = b.b0;
// peak factor
float D = (b.b1 * Fz + b.b2) * Fz;
// stiffness at sigma = 0
float BCD = (b.b3 * Fz + b.b4) * Fz * MathF.Exp( -b.b5 * Fz );
// stiffness factor
float B = BCD / (C * D);
// curvature factor
float E = (b.b6 * Fz + b.b7) * Fz + b.b8;
// horizontal shift
float Sh = b.b9 * Fz + b.b10;
// composite
float S = 100 * sigma + Sh;
// longitudinal force
float BS = B * S;
float Fx = D * MathF.Sin( C * MathF.Atan( BS - E * (BS - MathF.Atan( BS )) ) );
// scale by surface friction
Fx *= friction_coeff;
maxforce_output = D;
return Fx;
}
/// pacejka magic formula for lateral force
public float PacejkaFy( float alpha, float Fz, float gamma, float friction_coeff, out float camber_alpha )
{
var a = Lateral;
// shape factor
float C = a.a0;
// peak factor
float D = (a.a1 * Fz + a.a2) * Fz;
// stiffness at alpha = 0
float BCD = a.a3 * MathF.Atan2( Fz, a.a4 ) * (1 - a.a5 * MathF.Abs( gamma ));
// stiffness factor
float B = BCD / (C * D);
// curvature factor
float E = a.a6 * Fz + a.a7;
// horizontal shift
float Sh = a.a8 * gamma + a.a9 * Fz + a.a10;
// vertical shift
float Sv = ((a.a111 * Fz + a.a112) * gamma + a.a12) * Fz + a.a13;
// composite slip angle
float S = alpha + Sh;
// lateral force
float BS = B * S;
float Fy = D * MathF.Sin( C * MathF.Atan( BS - E * (BS - MathF.Atan( BS )) ) ) + Sv;
// scale by surface friction
Fy *= friction_coeff;
camber_alpha = Sh + Sv / BCD * friction_coeff;
return Fy;
}
/// pacejka magic formula for aligning torque
public float PacejkaMz( float alpha, float Fz, float gamma, float friction_coeff )
{
var c = Aligning;
// shape factor
float C = c.c0;
// peak factor
float D = (c.c1 * Fz + c.c2) * Fz;
// stiffness at alpha = 0
float BCD = (c.c3 * Fz + c.c4) * Fz * (1 - c.c6 * MathF.Abs( gamma )) * MathF.Exp( -c.c5 * Fz );
// stiffness factor
float B = BCD / (C * D);
// curvature factor
float E = (c.c7 * Fz * Fz + c.c8 * Fz + c.c9) * (1 - c.c10 * MathF.Abs( gamma ));
// horizontal shift
float Sh = c.c11 * gamma + c.c12 * Fz + c.c13;
// composite slip angle
float S = alpha + Sh;
// vertical shift
float Sv = (c.c14 * Fz * Fz + c.c15 * Fz) * gamma + c.c16 * Fz + c.c17;
// self-aligning torque
float BS = B * S;
float Mz = D * MathF.Sin( C * MathF.Atan( BS - E * (BS - MathF.Atan( BS )) ) ) + Sv;
// scale by surface friction
Mz *= friction_coeff;
return Mz;
}
/// pacejka magic formula for the longitudinal combining factor
public float PacejkaGx( float sigma, float alpha )
{
var p = Combining;
float a = p.gx2 * sigma;
float b = p.gx1 * alpha;
float c = a * a + 1;
return MathF.Sqrt( c / (c + b * b) );
}
/// pacejka magic formula for the lateral combining factor
public float PacejkaGy( float sigma, float alpha )
{
var p = Combining;
float a = p.gy2 * alpha;
float b = p.gy1 * sigma;
float c = a * a + 1;
return MathF.Sqrt( c / (c + b * b) );
}
}

View File

@@ -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 );
}
}

View File

@@ -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 );
}
}

View File

@@ -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 );
}
}

View File

@@ -4,57 +4,45 @@ using System;
namespace VeloX; 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 class TirePreset : GameResource
{ {
public static TirePreset Default { get; } = ResourceLibrary.Get<TirePreset>( "frictions/default.tire" );
[Property] public Pacejka Pacejka { get; set; } private float peakSlip = -1;
public float RollResistanceLin { get; set; } = 1E-3f; [Property] public float B { get; set; } = 10.86f;
public float RollResistanceQuad { get; set; } = 1E-6f; [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 ) public float GetPeakSlip()
{ // 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 )
{ {
var abs_lon = Math.Max( MathF.Abs( lon_velocity ), 1e-3f ); if ( peakSlip == -1 )
peakSlip = CalcPeakSlip();
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 );
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 ); float peakSlip = -1;
return ((1 / 6.0f) * (sc * sc) + 1) * sc; float yMax = 0;
for ( float i = 0; i < 1f; i += 0.01f )
{
float y = Evaluate( i );
if ( y > yMax )
{
yMax = y;
peakSlip = i;
}
} }
public static float ComputeCamberVelocity( float sa, float vx ) return peakSlip;
{
float tansa = (1 / 3.0f * (sa * sa) + 1) * sa;
return tansa * vx;
} }
} }

View File

@@ -0,0 +1,272 @@
using Sandbox;
using System;
namespace VeloX;
public partial class VeloXWheel
{
/// <summary>
/// Constant torque acting similar to brake torque.
/// Imitates rolling resistance.
/// </summary>
[Property, Range( 0, 500 ), Sync] public float RollingResistanceTorque { get; set; } = 30f;
/// <summary>
/// The percentage this wheel is contributing to the total vehicle load bearing.
/// </summary>
public float LoadContribution { get; set; } = 0.25f;
/// <summary>
/// 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.
/// </summary>
[Property, Sync] public float LoadRating { get; set; } = 5400;
/// <summary>
/// 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.
/// </summary>
public float CounterTorque { get; private set; }
//[Property, Range( 0, 2 )] public float BrakeMult { get; set; } = 1f;
public Vector3 FrictionForce;
private Vector3 hitContactVelocity;
private Vector3 hitForwardDirection;
private Vector3 hitSidewaysDirection;
public Vector3 ContactRight => hitSidewaysDirection;
public Vector3 ContactForward => hitForwardDirection;
public float LongitudinalSlip => sx;
public float LongitudinalSpeed => vx;
public bool IsSkiddingLongitudinally => NormalizedLongitudinalSlip > 0.35f;
public float NormalizedLongitudinalSlip => Math.Clamp( Math.Abs( LongitudinalSlip ), 0, 1 );
public float LateralSlip => sy;
public float LateralSpeed => vy;
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;
// speed
[Sync] private float vx { get; set; }
[Sync] private float vy { get; set; }
// force
[Sync] private float fx { get; set; }
[Sync] private float fy { get; set; }
// slip
[Sync] private float sx { get; set; }
[Sync] private float sy { get; set; }
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;
vx = hitContactVelocity.Dot( hitForwardDirection ).InchToMeter();
vy = hitContactVelocity.Dot( hitSidewaysDirection ).InchToMeter();
}
else
{
vx = 0;
vy = 0;
}
}
private Vector3 lowSpeedReferencePosition;
private bool lowSpeedReferenceIsSet;
private Vector3 currentPosition;
private Vector3 referenceError;
private Vector3 correctiveForce;
public bool wheelIsBlocked;
private void UpdateFriction( float dt )
{
var motorTorque = DriveTorque;
var brakeTorque = BrakeTorque;
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;
float mass = Vehicle.Body.Mass;
float absForwardSpeed = Math.Abs( vx );
float forwardForceClamp = mass * LoadContribution * absForwardSpeed * invDt;
float absSideSpeed = Math.Abs( vy );
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 );
float peakForwardFrictionForce = 11000 * (1 - MathF.Exp( -0.00014f * forwardLoadFactor ));
float absCombinedBrakeTorque = Math.Max( 0, brakeTorque + RollingResistanceTorque );
float signedCombinedBrakeTorque = absCombinedBrakeTorque * (vx > 0 ? -1 : 1);
float signedCombinedBrakeForce = signedCombinedBrakeTorque * invRadius;
float motorForce = motorTorque * invRadius;
float forwardInputForce = motorForce + signedCombinedBrakeForce;
float absMotorTorque = Math.Abs( motorTorque );
float absBrakeTorque = Math.Abs( brakeTorque );
float maxForwardForce = Math.Min( peakForwardFrictionForce, forwardForceClamp );
maxForwardForce = absMotorTorque < absBrakeTorque ? maxForwardForce : peakForwardFrictionForce;
fx = forwardInputForce > maxForwardForce ? maxForwardForce
: forwardInputForce < -maxForwardForce ? -maxForwardForce : forwardInputForce;
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 = vx * 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 += vx > 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 - fx) * mRadius, -maxCounterTorque, maxCounterTorque );
sx = (vx - AngularVelocity * mRadius) / clampedAbsForwardSpeed;
sx *= slipLoadModifier;
sy = MathF.Atan2( vy, clampedAbsForwardSpeed );
sy *= slipLoadModifier;
float sideSlipSign = sy > 0 ? 1 : -1;
float absSideSlip = Math.Abs( sy );
float peakSideFrictionForce = 18000 * (1 - MathF.Exp( -0.0001f * sideLoadFactor ));
float sideForce = -sideSlipSign * Tire.Evaluate( absSideSlip ) * peakSideFrictionForce;
fy = Math.Clamp( sideForce, -sideForceClamp, sideForceClamp );
// Calculate effect of camber on friction
float camberFrictionCoeff = Math.Max( 0, Vehicle.WorldRotation.Up.Dot( ContactNormal ) );
fy *= 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 )
{
fx += correctiveForce.Dot( hitForwardDirection );
}
fy += correctiveForce.Dot( hitSidewaysDirection );
}
}
else
{
lowSpeedReferenceIsSet = false;
}
fx = Math.Clamp( fx, -peakForwardFrictionForce, peakForwardFrictionForce );
fy = Math.Clamp( fy, -peakSideFrictionForce, peakSideFrictionForce );
if ( absForwardSpeed > 0.01f || absAngularVelocity > 0.01f )
{
var f = MathF.Sqrt( fx * fx + fy * fy );
var d = Math.Abs( new Vector2( sx, sy ).Normal.y );
fy = f * d * Math.Sign( fy );
}
if ( IsOnGround )
{
FrictionForce.x = (hitSidewaysDirection.x * fy + hitForwardDirection.x * fx).MeterToInch();
FrictionForce.y = (hitSidewaysDirection.y * fy + hitForwardDirection.y * fx).MeterToInch();
FrictionForce.z = (hitSidewaysDirection.z * fy + hitForwardDirection.z * fx).MeterToInch();
//DebugOverlay.Normal( WorldPosition, hitSidewaysDirection * 10, overlay: true, color: Color.Red );
//DebugOverlay.Normal( WorldPosition, hitForwardDirection * 10, overlay: true, color: Color.Green );
//DebugOverlay.Normal( WorldPosition, FrictionForce.ClampLength( 30 ), overlay: true, color: Color.Cyan );
//DebugOverlay.ScreenText( Scene.Camera.PointToScreenPixels( WorldPosition ), $"{ForwardFriction}\nMotor:{(int)motorTorque}\nBrake:{(int)brakeTorque}", flags: TextFlag.LeftTop );
}
else
FrictionForce = Vector3.Zero;
}
}

View File

@@ -1,11 +1,14 @@
using Sandbox; using Sandbox;
using System;
namespace VeloX; namespace VeloX;
public partial class VeloXWheel : Component public partial class VeloXWheel : Component
{ {
protected override void DrawGizmos() protected override void DrawGizmos() => GizmoDraw();
public void GizmoDraw()
{ {
if ( !Gizmo.IsSelected ) if ( !Gizmo.IsSelected )
@@ -18,7 +21,7 @@ public partial class VeloXWheel : Component
// //
{ {
var suspensionStart = Vector3.Zero; 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.Color = Color.Cyan;
Gizmo.Draw.LineThickness = 0.25f; Gizmo.Draw.LineThickness = 0.25f;
@@ -28,7 +31,7 @@ public partial class VeloXWheel : Component
Gizmo.Draw.Line( suspensionStart + Vector3.Forward, suspensionStart + Vector3.Backward ); Gizmo.Draw.Line( suspensionStart + Vector3.Forward, suspensionStart + Vector3.Backward );
Gizmo.Draw.Line( suspensionEnd + Vector3.Forward, suspensionEnd + 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 // Wheel radius
// //
@@ -36,7 +39,7 @@ public partial class VeloXWheel : Component
Gizmo.Draw.LineThickness = 0.5f; Gizmo.Draw.LineThickness = 0.5f;
Gizmo.Draw.Color = Color.White; Gizmo.Draw.Color = Color.White;
Gizmo.Draw.LineCylinder( widthOffset, -widthOffset, Radius, Radius, 16 ); Gizmo.Draw.LineCylinder( widthOffset, -widthOffset, Radius.MeterToInch(), Radius.MeterToInch(), 16 );
} }
// //
@@ -51,24 +54,14 @@ public partial class VeloXWheel : Component
for ( float i = 0; i < 16; i++ ) 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 ) ); 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 ); Gizmo.Draw.Line( pos - widthOffset, pos2 + widthOffset );
} }
} }
////
//// Forward direction
////
//{
// var arrowStart = Vector3.Forward * Radius;
// var arrowEnd = arrowStart + Vector3.Forward * 8f;
// Gizmo.Draw.Color = Color.Red;
// Gizmo.Draw.Arrow( arrowStart, arrowEnd, 4, 1 );
//}
} }
} }

View File

@@ -0,0 +1,90 @@
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<LineRenderer> SkidMarks = [];
private LineRenderer _skidMark;
private void ResetSkid()
{
_skidMark = null;
}
private void CreateSkid()
{
GameObject go = new()
{
WorldPosition = ContactPosition + Vehicle.WorldRotation.Down * (Radius.MeterToInch() - 1f),
WorldRotation = Rotation.LookAt( hitSidewaysDirection )
};
_skidMark = go.AddComponent<LineRenderer>();
_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 = 1;
go.Flags = go.Flags.WithFlag( GameObjectFlags.Hidden, true );
go.Flags = go.Flags.WithFlag( GameObjectFlags.NotNetworked, true );
SkidMarks.Enqueue( _skidMark );
}
protected void UpdateSkid()
{
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() - 1f),
WorldRotation = Rotation.LookAt( ContactNormal.RotateAround( Vector3.Zero, Rotation.FromRoll( 90 ) ) )
};
go.Flags = go.Flags.WithFlag( GameObjectFlags.Hidden, true );
go.Flags = go.Flags.WithFlag( GameObjectFlags.NotNetworked, true );
_skidMark.Points.Add( go );
}
}

View File

@@ -0,0 +1,107 @@
using Sandbox;
using System;
using System.Collections.Generic;
using System.Threading;
namespace VeloX;
public partial class VeloXWheel
{
private GameObject SmokeObject;
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();
SmokeObject = GameObject.GetPrefab( "prefabs/particles/tire_smoke.prefab" ).Clone( new CloneConfig() { Parent = GameObject, StartEnabled = true } );
SmokeObject.Flags = SmokeObject.Flags.WithFlag( GameObjectFlags.NotNetworked, true );
var emitter = SmokeObject.Components.Get<ParticleSphereEmitter>( FindMode.EverythingInSelfAndDescendants );
emitter.Enabled = false;
}
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 val * 5;
}
private void UpdateSmoke()
{
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<ParticleSphereEmitter>( 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<ParticleEffect>( 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.Range,
Evaluation = ParticleFloat.EvaluationType.Life,
ConstantA = 10,
ConstantB = 100,
};
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 = Vehicle.Body.Velocity / 3 + hitForwardDirection * LongitudinalSlip * 10f;
effect.ForceDirection = 0;
effect.SheetSequence = true;
effect.SequenceSpeed = 0.5f;
effect.SequenceTime = 1f;
}
}

View File

@@ -1,14 +1,6 @@
using Sandbox; using Sandbox;
using Sandbox.Rendering; using Sandbox.Utility;
using Sandbox.Services;
using System; using System;
using System.Collections.Specialized;
using System.Numerics;
using System.Text.RegularExpressions;
using System.Threading;
using static Sandbox.CameraComponent;
using static Sandbox.Package;
using static Sandbox.SkinnedModelRenderer;
namespace VeloX; namespace VeloX;
@@ -16,290 +8,214 @@ namespace VeloX;
[Title( "VeloX - Wheel" )] [Title( "VeloX - Wheel" )]
public partial class VeloXWheel : Component 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] public float Radius { get; set; } = 15; [Property, Group( "Traction" )] public TirePreset Tire { get; set; } = ResourceLibrary.Get<TirePreset>( "frictions/default.tire" );
[Property] public float Mass { get; set; } = 20; [Property, Group( "Traction" )] public float SurfaceGrip { get; set; } = 1f;
[Property] public float RollingResistance { get; set; } = 20; [Property, Group( "Traction" )] public float SurfaceResistance { get; set; } = 0.05f;
[Property] public float SlipCircleShape { get; set; } = 1.05f; public bool AutoSimulate = true;
[Property] public TirePreset TirePreset { get; set; }
[Property] public float Width { get; set; } = 6;
public float SideSlip => sideFriction.Slip.MeterToInch(); public float BaseInertia => Mass * (Radius * Radius); // kg·m²
public float ForwardSlip => forwardFriction.Slip.MeterToInch(); [Property] public float Inertia { get; set; } = 1.5f; // kg·m²
[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 bool IsFront { get; protected set; }
[Property] public float SteerMultiplier { get; 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; public float RPM { get => AngularVelocity * 60f / MathF.Tau; set => AngularVelocity = value / (60 / MathF.Tau); }
[Property, Group( "Suspension" )] float SpringStrength { get; set; } = 800;
[Property, Group( "Suspension" )] float SpringDamper { get; set; } = 3000;
[Property] public bool AutoPhysics { get; set; } = true; [Sync] private Vector3 StartPos { get; set; }
private static Rotation CylinderOffset = Rotation.FromRoll( 90 );
public float Spin { get; private set; } [Sync] public bool IsOnGround { 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; } [Property] public float DriveTorque { get; set; }
[Property] public float BrakeTorque { get; set; }
private Vector3 StartPos { get; set; } public float Compression { get; protected set; } // meters
private static Rotation CylinderOffset => Rotation.FromRoll( 90 ); [Sync( SyncFlags.Interpolate )] public float LastLength { get; protected set; } // meters
public float Fz { get; protected set; } // N
public float AngularVelocity { get; protected set; } // rad/s
[Sync( SyncFlags.Interpolate )] public float RollAngle { get; protected set; }
public SceneTraceResult Trace { get; private set; }
public bool IsOnGround => Trace.Hit;
private float lastSpringOffset;
private float angularVelocity;
private float load;
private float lastFraction;
private Vector3 contactPos;
private Vector3 forward;
private Vector3 right;
private Vector3 up;
private Friction forwardFriction;
private Friction sideFriction;
private Vector3 force;
public float CounterTorque { get; private set; }
internal float BaseInertia => 0.5f * Mass * MathF.Pow( Radius.InchToMeter(), 2 );
public float Inertia
{
get => BaseInertia + inertia;
set => inertia = value;
}
public VeloXBase Vehicle { get; private set; }
[Sync] public Vector3 ContactNormal { get; protected set; }
[Sync] public Vector3 ContactPosition { get; protected set; }
Rotation TransformRotationSteer => Vehicle.WorldTransform.RotationToWorld( Vehicle.SteerAngle * SteerMultiplier );
protected override void OnAwake() protected override void OnAwake()
{ {
Vehicle = Components.Get<VeloXBase>( FindMode.EverythingInSelfAndAncestors );
base.OnAwake(); base.OnAwake();
if ( StartPos.IsNearZeroLength ) if ( StartPos.IsNearZeroLength )
StartPos = LocalPosition; StartPos = LocalPosition;
Inertia = BaseInertia;
} }
internal void Update( VeloXBase vehicle, in float dt ) private void UpdateVisuals()
{ {
UpdateVisuals( vehicle, dt ); WorldRotation = Vehicle.WorldTransform.RotationToWorld( Vehicle.SteerAngle * SteerMultiplier ).RotateAroundAxis( Vector3.Right, -RollAngle );
LocalPosition = StartPos + Vector3.Down * LastLength.MeterToInch();
} }
private void UpdateVisuals( VeloXBase vehicle, in float dt ) private struct WheelTraceData
{ {
Spin -= angularVelocity.RadianToDegree() * dt; internal Vector3 ContactNormal;
WorldRotation = vehicle.WorldTransform.RotationToWorld( GetSteer( vehicle.SteerAngle.yaw ) ) * Rotation.FromAxis( Vector3.Right, Spin ); internal Vector3 ContactPosition;
internal float Compression;
internal float Force;
}
public void UpdateForce()
{
Vehicle.Body.ApplyForceAt( ContactPosition, FrictionForce + ContactNormal * Fz.MeterToInch() );
FrictionForce = 0;
} }
private Rotation GetSteer( float steer ) internal void StepPhys( VeloXBase vehicle, in float dt )
{ {
float angle = (-steer * SteerMultiplier).DegreeToRadian(); const int numSamples = 3;
float halfWidth = Width.MeterToInch() * 0.5f;
float t = MathF.Tan( (MathF.PI / 2) - angle ) - Ackermann; int hitCount = 0;
float steering_angle = MathF.CopySign( float.Pi / 2, t ) - MathF.Atan( t ); WheelTraceData wheelTraceData = new();
var steering_axis = Vector3.Up * MathF.Cos( -CasterAngle.DegreeToRadian() ) + for ( int i = 0; i < numSamples; i++ )
Vector3.Right * MathF.Sin( -CasterAngle.DegreeToRadian() ); {
float t = (float)i / (numSamples - 1);
return Rotation.FromAxis( Vector3.Forward, -CamberAngle ) * Rotation.FromAxis( steering_axis, steering_angle.RadianToDegree() ); 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++;
} }
private static float GetLongitudinalLoadCoefficient( float load ) => 11000 * (1 - MathF.Exp( -0.00014f * load )); if ( hitCount > 0 )
private static float GetLateralLoadCoefficient( float load ) => 18000 * (1 - MathF.Exp( -0.0001f * load ));
float lastload;
private float inertia;
public void DoPhysics( VeloXBase vehicle )
{ {
var pos = vehicle.WorldTransform.PointToWorld( StartPos );
var ang = vehicle.WorldTransform.RotationToWorld( GetSteer( vehicle.SteerAngle.yaw ) ); IsOnGround = true;
var maxLen = SuspensionLength; Fz = Math.Max( wheelTraceData.Force / hitCount, 0 );
Compression = wheelTraceData.Compression / hitCount;
ContactNormal = (wheelTraceData.ContactNormal / hitCount).Normal;
ContactPosition = wheelTraceData.ContactPosition / hitCount;
LastLength = RestLength - Compression;
var endPos = pos + ang.Down * maxLen; UpdateHitVariables();
Trace = Scene.Trace UpdateFriction( dt );
.IgnoreGameObjectHierarchy( vehicle.GameObject ) }
.Cylinder( Width, Radius, pos, endPos ) else
.Rotated( vehicle.WorldTransform.Rotation * CylinderOffset ) {
.UseRenderMeshes( false ) IsOnGround = false;
Compression = 0f;
Fz = 0f;
ContactNormal = Vector3.Up;
ContactPosition = WorldPosition;
LastLength = RestLength;
UpdateHitVariables();
UpdateFriction( dt );
}
}
private bool TraceWheel( VeloXBase vehicle, ref WheelTraceData wheelTraceData, Vector3 start, Vector3 end, float width, in float dt )
{
SceneTraceResult trace;
if ( IsOnGround && vehicle.TotalSpeed < 550 )
{
trace = Scene.Trace
.FromTo( start, end )
.Cylinder( width, Radius.MeterToInch() )
.Rotated( vehicle.WorldRotation * CylinderOffset )
.UseHitPosition( false ) .UseHitPosition( false )
.WithoutTags( vehicle.WheelIgnoredTags ) .IgnoreGameObjectHierarchy( Vehicle.GameObject )
.WithCollisionRules( Vehicle.GameObject.Tags )
.Run(); .Run();
//forward = ang.Forward;
//right = ang.Right;
up = ang.Up;
forward = Vector3.VectorPlaneProject( ang.Forward, Trace.Normal ).Normal; if ( trace.StartedSolid )
right = Vector3.VectorPlaneProject( ang.Right, Trace.Normal ).Normal;
var fraction = Trace.Fraction;
contactPos = pos - maxLen * fraction * up;
LocalPosition = vehicle.WorldTransform.PointToLocal( contactPos );
DoSuspensionSounds( vehicle, fraction - lastFraction );
lastFraction = fraction;
var normal = Trace.Normal;
var vel = vehicle.Body.GetVelocityAtPoint( contactPos );
//var vel = vehicle.Body.GetVelocityAtPoint( pos );
if ( !IsOnGround )
{ {
forwardFriction = new Friction(); trace = Scene.Trace
sideFriction = new Friction(); .FromTo( start, end + Vehicle.WorldRotation.Down * Radius.MeterToInch() )
.UseHitPosition( false )
.IgnoreGameObjectHierarchy( Vehicle.GameObject )
.WithCollisionRules( Vehicle.GameObject.Tags )
.Run();
trace.EndPosition += Vehicle.WorldRotation.Up * Math.Min( Radius.MeterToInch(), trace.Distance );
}
}
else
{
trace = Scene.Trace
.FromTo( start, end + Vehicle.WorldRotation.Down * Radius.MeterToInch() )
.UseHitPosition( false )
.IgnoreGameObjectHierarchy( Vehicle.GameObject )
.WithCollisionRules( Vehicle.GameObject.Tags )
.Run();
trace.EndPosition += Vehicle.WorldRotation.Up * Math.Min( Radius.MeterToInch(), trace.Distance );
}
//DebugOverlay.Trace( trace, overlay: true );
if ( trace.Hit )
{
Vector3 contactPos = trace.EndPosition;
Vector3 contactNormal = trace.Normal;
float currentLength = trace.Distance.InchToMeter();
float compression = (RestLength - currentLength).Clamp( -RestLength, RestLength );
// 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;
wheelTraceData.ContactNormal += contactNormal;
wheelTraceData.ContactPosition += contactPos;
wheelTraceData.Compression += compression;
wheelTraceData.Force += Math.Max( 0, FzPoint );
return true;
}
return false;
}
public void DoPhysics( in float dt )
{
if ( IsProxy )
return; return;
if ( AutoSimulate )
StepPhys( Vehicle, dt );
StepRotation( Vehicle, dt );
} }
var offset = maxLen - (fraction * maxLen); private void StepRotation( VeloXBase vehicle, in float dt )
var springForce = (offset * SpringStrength);
var damperForce = (lastSpringOffset - offset) * SpringDamper;
lastSpringOffset = offset;
// Vector3.CalculateVelocityOffset is broken (need fix)
var velU = normal.Dot( vel );
if ( velU < 0 && offset + Math.Abs( velU * Time.Delta ) > SuspensionLength )
{ {
var impulse = (-velU / Time.Delta) * normal; RollAngle += MathX.RadianToDegree( AngularVelocity ) * dt;
var body = vehicle.Body.PhysicsBody; RollAngle = (RollAngle % 360f + 360f) % 360f;
Vector3 com = body.MassCenter;
Rotation bodyRot = body.Rotation;
Vector3 r = pos - com;
Vector3 torque = Vector3.Cross( r, impulse );
Vector3 torqueLocal = bodyRot.Inverse * torque;
Vector3 angularVelocityLocal = torqueLocal * body.Inertia.Inverse;
var centerAngularVelocity = bodyRot * angularVelocityLocal;
var centerVelocity = impulse * (1 / body.Mass);
vehicle.Body.Velocity += centerVelocity * 10;
vehicle.Body.AngularVelocity += centerAngularVelocity;
damperForce = 0;
} }
force = (springForce - damperForce) * MathF.Max( 0, up.Dot( normal ) ) * normal; protected override void OnFixedUpdate()
load = Math.Max( force.z, 0 ).InchToMeter();
if ( IsOnGround )
{ {
float forwardSpeed = vel.Dot( forward ).InchToMeter();
float sideSpeed = vel.Dot( right ).InchToMeter();
float camber_rad = CamberAngle.DegreeToRadian();
float R = Radius.InchToMeter();
//TirePreset.ComputeState(
// load,
// angularVelocity,
// forwardSpeed,
// sideSpeed,
// camber_rad,
// R,
// Inertia,
// out var tireState
//);
float linearSpeed = angularVelocity * Radius.InchToMeter();
float F_roll = TirePreset.GetRollingResistance( linearSpeed, 1.0f );
F_roll *= -Math.Clamp( linearSpeed * 0.25f, -1, 1 );
//float Fx_total = tireState.fx + F_roll;
float T_brake = Brake * BrakePowerMax;
if ( angularVelocity > 0 ) T_brake = -T_brake;
else T_brake = angularVelocity < 0 ? T_brake : -MathF.Sign( Torque ) * T_brake;
//float totalTorque = Torque + tireState.fx;
angularVelocity += Torque / Inertia * Time.Delta;
angularVelocity += T_brake / Inertia * Time.Delta;
angularVelocity += F_roll * 9.80665f / Inertia * Time.Delta;
TirePreset.ComputeSlip( forwardSpeed, sideSpeed, angularVelocity, R, out var slip, out var slip_ang );
var fx = TirePreset.Pacejka.PacejkaFx( slip, load, 1, out var maxTorque );
var fy = TirePreset.Pacejka.PacejkaFy( slip_ang * load, load, camber_rad, 1, out var _ );
maxTorque *= R;
var errorTorque = (angularVelocity - forwardSpeed / R) * Inertia / Time.Delta;
var surfaceTorque = Math.Clamp( errorTorque, -maxTorque, maxTorque );
angularVelocity -= surfaceTorque / Inertia * Time.Delta;
forwardFriction = new Friction()
{
Slip = slip,
Force = -fx,
Speed = forwardSpeed
};
sideFriction = new Friction()
{
Slip = slip_ang,
Force = fy,
Speed = sideSpeed
};
Vector3 frictionForce = forward * forwardFriction.Force + right * sideFriction.Force;
vehicle.Body.ApplyForceAt( pos, force / Time.Delta * ProjectSettings.Physics.SubSteps );
vehicle.Body.ApplyForceAt( pos, frictionForce * ProjectSettings.Physics.SubSteps );
UpdateSmoke();
} }
}
//todo
protected (float Mass, float Inertia) CalcMassAndInertia()
{
// section width in millimeters, measured from sidewall to sidewall
// ratio of sidewall height to section width in percent
// diameter of the wheel in inches
var tire_size = new Vector3( 215, 45, 17 );
float tire_width = tire_size[0] * 0.001f;
float tire_aspect_ratio = tire_size[1] * 0.01f;
float tire_radius = tire_size[2] * 0.5f * 0.0254f + tire_width * tire_aspect_ratio;
float tire_thickness = 0.02f;
float tire_density = 1000; // rubber
float rim_radius = tire_radius - tire_width * tire_aspect_ratio;
float rim_width = tire_width;
float rim_thickness = 0.01f;
float rim_density = 2700; // aluminium
float tire_volume = float.Pi * tire_width * tire_thickness * (2 * tire_radius - tire_thickness);
float rim_volume = float.Pi * rim_width * rim_thickness * (2 * rim_radius - rim_thickness);
float tire_mass = tire_density * tire_volume;
float rim_mass = rim_density * rim_volume;
float tire_inertia = tire_mass * tire_radius * tire_radius;
float rim_inertia = rim_mass * rim_radius * rim_radius;
float mass = tire_mass + rim_mass;
float inertia = tire_inertia + rim_inertia;
return (mass, inertia);
}
// debug
protected override void OnUpdate() protected override void OnUpdate()
{ {
DebugOverlay.Normal( contactPos, forward * forwardFriction.Force / 1000f, Color.Red, overlay: true ); UpdateVisuals();
DebugOverlay.Normal( contactPos, right * sideFriction.Force / 1000f, Color.Green, overlay: true ); UpdateSkid();
DebugOverlay.Normal( contactPos, up * force / 1000f, Color.Blue, overlay: true );
} }
} }

View File

@@ -0,0 +1,70 @@
using Sandbox;
using System.Linq;
namespace VeloX;
internal sealed class WheelManager : GameObjectSystem
{
public WheelManager( Scene scene ) : base( scene )
{
if ( Application.IsDedicatedServer )
return;
Listen( Stage.StartFixedUpdate, -99, UpdateWheels, "UpdateWheels" );
Listen( Stage.StartFixedUpdate, -100, UpdateEngine, "UpdateEngine" );
}
private void UpdateWheels()
{
if ( !Game.IsPlaying || Scene.IsEditor )
return;
//Stopwatch sw = Stopwatch.StartNew();
var wheels = Scene.GetAll<VeloXWheel>();
if ( !wheels.Any() ) return;
var timeDelta = Time.Delta;
//Sandbox.Utility.Parallel.ForEach( wheels, item =>
//{
// if ( !item.IsProxy && item.IsValid() )
// item.DoPhysics( timeDelta );
//} );
foreach ( var item in wheels )
if ( item.IsValid() && !item.IsProxy )
item.DoPhysics( timeDelta );
foreach ( var wheel in wheels )
if ( wheel.IsValid() && !wheel.IsProxy)
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 || Scene.IsEditor )
return;
//Stopwatch sw = Stopwatch.StartNew();
var engines = Scene.GetAll<Engine>();
if ( !engines.Any() ) return;
var timeDelta = Time.Delta;
//Sandbox.Utility.Parallel.ForEach( engines, item =>
//{
// foreach ( var wheel in engines )
// if ( !wheel.IsProxy && wheel.IsValid() )
// wheel.UpdateEngine( timeDelta );
//} );
foreach ( var wheel in engines )
if ( wheel.IsValid() )
wheel.UpdateEngine( timeDelta );
//sw.Stop();
//DebugOverlaySystem.Current.ScreenText( new Vector2( 120, 54 ), $"Engine Sim: {sw.Elapsed.TotalMilliseconds,6:F2} ms", 24 );
}
}

37
Code/Car/VeloXCar.ABS.cs Normal file
View File

@@ -0,0 +1,37 @@
using Sandbox;
using System;
namespace VeloX;
public partial class VeloXCar
{
public bool ABSActive { get; private set; } = true;
public static bool UseABS = true;
private void UpdateABS()
{
ABSActive = false;
if ( !UseABS )
return;
if ( TotalSpeed < 100 || SteeringAngle.AlmostEqual( 0, 1 ) )
return;
if ( Brakes == 0 || CarDirection != 1 || Engine.RevLimiterActive || Handbrake >= 0.1f )
return;
foreach ( var wheel in Wheels )
{
if ( !wheel.IsOnGround )
continue;
if ( wheel.wheelIsBlocked )
{
ABSActive = true;
wheel.BrakeTorque = 0f;
}
}
}
}

View File

@@ -0,0 +1,60 @@
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 = 1.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<DownforcePoint> 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 );
}
//protected override void DrawGizmos()
//{
// Gizmo.Draw.LineBBox( new BBox( -Dimensions / 2 * 39.37f, Dimensions / 2 * 39.37f ) );
//}
}

View File

@@ -0,0 +1,75 @@
using Sandbox;
using Sandbox.Audio;
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 )
{
var avgslip = 0f;
foreach ( var item in Wheels )
avgslip += item.NormalizedLongitudinalSlip + item.NormalizedLateralSlip;
float mul = Math.Clamp( avgslip, 0, 2 );
targetVolume = mul;
targetPitch = 0.75f + 0.25f * mul;
if ( mul > 0.1f && !_skidHandle.IsValid() )
{
_skidHandle = Sound.PlayFile( SkidSound );
_skidHandle.TargetMixer = Mixer.Default;
}
if ( !_skidHandle.IsValid() )
return;
_skidHandle.Pitch += (targetPitch - _skidHandle.Pitch) * dt * 5f;
_skidHandle.Volume += (targetVolume - _skidHandle.Volume) * dt * 10f;
_skidHandle.Position = WorldPosition;
}
}

44
Code/Car/VeloXCar.ESC.cs Normal file
View File

@@ -0,0 +1,44 @@
using Sandbox;
using Sandbox.VR;
using System;
using VeloX.Utils;
namespace VeloX;
public partial class VeloXCar
{
public bool ESCActive { get; private set; } = true;
public static bool UseESC = true;
private void UpdateESC()
{
ESCActive = false;
if ( !UseESC )
return;
if ( TotalSpeed < 100 || CarDirection != 1 )
return;
float angle = Body.Velocity.SignedAngle( WorldRotation.Forward, WorldRotation.Up ); ;
angle -= SteerAngle.yaw * 0.5f;
float absAngle = angle < 0 ? -angle : angle;
if ( Engine.RevLimiterActive || absAngle < 2f )
return;
foreach ( var wheel in Wheels )
{
if ( !wheel.IsOnGround )
continue;
float additionalBrakeTorque = -angle * Math.Sign( wheel.LocalPosition.y ) * 20f;
if ( additionalBrakeTorque > 0 )
{
ESCActive = true;
wheel.BrakeTorque += additionalBrakeTorque;
}
}
}
}

View File

@@ -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<int, float> 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);
//}
}

View File

@@ -1,53 +1,45 @@
using Sandbox; using Sandbox;
using System; using System;
using System.Threading; using VeloX.Utils;
namespace VeloX; namespace VeloX;
public partial class VeloXCar public partial class VeloXCar
{ {
public static float ExpDecay( float a, float b, float decay, float dt ) => b + (a - b) * MathF.Exp( -decay * dt ); 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 MaxSteerAngle { get; set; } = 40f;
[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;
[Sync] public float Steering { get; private set; } [ConVar( "steer_return_speed" )]
[Property] public static float SteerReturnSpeed { get; set; } = 6f;
private float inputSteer; [ConVar( "steer_speed" )]
public static float SteerInputResponse { get; set; } = 3f;
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; [ConVar( "assist_mult" )]
float cross_y = from.z * to.x - from.x * to.z; public static float MaxSteerAngleMultiplier { get; set; } = 1f;
float cross_z = from.x * to.y - from.y * to.x; public int CarDirection { get { return ForwardSpeed < 1 ? 0 : (VelocityAngle < 90 && VelocityAngle > -90 ? 1 : -1); } }
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 float VelocityAngle { get; private set; }
public int CarDirection { get { return ForwardSpeed.InchToMeter() < 5 ? 0 : (VelocityAngle < 90 && VelocityAngle > -90 ? 1 : -1); } }
private float currentSteerAngle;
private void UpdateSteering( float dt ) private void UpdateSteering( float dt )
{ {
var inputSteer = Input.AnalogMove.y; float targetSteerAngle = SteeringAngle * MaxSteerAngle;
if ( !Input.Down( "Jump" ) )
targetSteerAngle *= Math.Clamp( 1 - Math.Clamp( TotalSpeed / 3000, 0f, 0.85f ), -1, 1 );
VelocityAngle = -SignedAngle( Body.Velocity, WorldRotation.Forward, WorldRotation.Up ); VelocityAngle = -Body.Velocity.SignedAngle( WorldRotation.Forward, WorldRotation.Up );
var steerConeFactor = Math.Clamp( TotalSpeed / SteerConeMaxSpeed, 0, 1 ); float targetAngle = 0;
var steerCone = 1 - steerConeFactor * (1 - SteerConeMaxAngle);
inputSteer = ExpDecay( this.inputSteer, inputSteer * steerCone, SteerConeChangeRate, dt ); if ( TotalSpeed > 150 && CarDirection > 0 && IsOnGround )
this.inputSteer = inputSteer; targetAngle = VelocityAngle * MaxSteerAngleMultiplier;
float target = -inputSteer * MaxSteerAngle; float lerpSpeed = Math.Abs( SteeringAngle ) < 0.1f ? SteerReturnSpeed : SteerInputResponse;
if ( CarDirection > 0 )
target -= VelocityAngle * CounterSteer;
inputSteer = Math.Clamp( inputSteer, -1, 1 ); currentSteerAngle = ExpDecay( currentSteerAngle, targetSteerAngle, lerpSpeed, Time.Delta );
Steering = inputSteer; SteerAngle = new( 0, Math.Clamp( currentSteerAngle + targetAngle, -MaxSteerAngle, MaxSteerAngle ), 0 );
SteerAngle = new( 0, target, 0 );
} }
} }

39
Code/Car/VeloXCar.TCS.cs Normal file
View File

@@ -0,0 +1,39 @@
using Sandbox;
using System;
namespace VeloX;
public partial class VeloXCar
{
public bool TCSActive { get; private set; } = true;
public static bool UseTCS = true;
private void UpdateTCS()
{
TCSActive = false;
if ( !UseTCS )
return;
if ( TotalSpeed < 50 || CarDirection != 1 )
return;
float vehicleSpeed = TotalSpeed.InchToMeter();
foreach ( var wheel in Wheels )
{
if ( !wheel.IsOnGround )
continue;
float wheelLinearSpeed = wheel.AngularVelocity * wheel.Radius;
float wheelSlip = wheelLinearSpeed - vehicleSpeed;
if ( wheelSlip > 2.0f )
{
TCSActive = true;
wheel.BrakeTorque = wheelSlip * 1000;
}
}
}
}

View File

@@ -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 );
}
}

View File

@@ -9,19 +9,42 @@ public partial class VeloXCar : VeloXBase
{ {
protected override void OnFixedUpdate() protected override void OnFixedUpdate()
{ {
if ( !IsDriver )
return;
base.OnFixedUpdate(); base.OnFixedUpdate();
Brake = Math.Clamp( (Input.Down( "Jump" ) ? 1 : 0), 0, 1 ); if ( IsProxy )
return;
UpdateABS();
UpdateESC();
UpdateTCS();
var dt = Time.Delta; var dt = Time.Delta;
//EngineThink( dt );
EngineThink( dt ); SimulateAerodinamics( dt );
WheelThink( dt ); //WheelThink( dt );
UpdateSteering( dt ); UpdateSteering( dt );
UpdateUnflip( dt );
UpdateDrift( dt );
} }
private void UpdateUnflip( float dt )
{
if ( Math.Abs( SteeringAngle ) < 0.1f )
return;
if ( IsOnGround || Math.Abs( WorldRotation.Roll() ) < 60 )
return;
var angVel = Body.WorldTransform.PointToLocal( WorldPosition + Body.AngularVelocity );
float maxAngularVelocity = 2.0f;
float velocityFactor = 1.0f - Math.Clamp( Math.Abs( angVel.x ) / maxAngularVelocity, 0f, 1f );
if ( velocityFactor <= 0.01f )
return;
var force = SteeringAngle * velocityFactor * 150;
Body.AngularVelocity -= Body.WorldRotation.Forward * force * dt;
}
} }

View File

@@ -1,43 +1,80 @@
using Sandbox; using Sandbox;
namespace VeloX; namespace VeloX;
public class InputResolver public class InputResolver
{ {
public Connection Driver { get; internal set; }
private bool IsDriverActive => Driver is not null; public Vector2 MouseDelta => Input.MouseDelta;
public Vector2 MouseWheel => Input.MouseWheel;
public Vector2 MouseDelta => IsDriverActive ? Input.MouseDelta : default; public Angles AnalogLook => Input.AnalogLook;
public Vector2 MouseWheel => IsDriverActive ? Input.MouseWheel : default; public Vector3 AnalogMove
public Angles AnalogLook => IsDriverActive ? Input.AnalogLook : default; {
public Vector3 AnalogMove => IsDriverActive ? Input.AnalogMove : default; get
{
if ( Input.UsingController )
{
Vector2 input = 0;
input.x = Input.GetAnalog( InputAnalog.RightTrigger ) - Input.GetAnalog( InputAnalog.LeftTrigger );
input.y = -Input.GetAnalog( InputAnalog.LeftStickX );
return input;
}
return Input.AnalogMove;
}
}
public bool Down( string action ) public bool Down( string action )
{ {
return IsDriverActive && Input.Down( action ); return Input.Down( action );
}
public float Brake
{
get
{
if ( Input.UsingController )
return Input.GetAnalog( InputAnalog.LeftTrigger );
return Input.Down( "Brake" ) ? 1 : 0;
}
}
public float Throttle
{
get
{
if ( Input.UsingController )
return Input.GetAnalog( InputAnalog.RightTrigger );
return Input.Down( "Throttle" ) ? 1 : 0;
}
} }
public bool Pressed( string action ) public bool Pressed( string action )
{ {
return IsDriverActive && Input.Pressed( action ); return Input.Pressed( action );
} }
public bool Released( string action ) public bool Released( string action )
{ {
return IsDriverActive && Input.Released( action ); return Input.Released( action );
}
public float GetAnalog( InputAnalog analog )
{
return Input.GetAnalog( analog );
} }
public void TriggerHaptics( float leftMotor, float rightMotor, float leftTrigger = 0f, float rightTrigger = 0f, int duration = 500 ) 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 ); Input.TriggerHaptics( leftMotor, rightMotor, leftTrigger, rightTrigger, duration );
} }
public void TriggerHaptics( HapticEffect effect, float lengthScale = 1, float frequencyScale = 1, float amplitudeScale = 1 )
{
Input.TriggerHaptics( effect, lengthScale, frequencyScale, amplitudeScale );
} }
public void StopAllHaptics() public void StopAllHaptics()
{ {
if ( IsDriverActive )
Input.StopAllHaptics(); Input.StopAllHaptics();
} }
} }

View File

@@ -4,7 +4,8 @@ using VeloX.Audio;
namespace VeloX; 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 EngineStream : GameResource
{ {
public sealed class Layer public sealed class Layer

View File

@@ -10,7 +10,7 @@ namespace VeloX;
public class EngineStreamPlayer( EngineStream stream ) : IDisposable public class EngineStreamPlayer( EngineStream stream ) : IDisposable
{ {
private static readonly Mixer EngineMixer = Mixer.FindMixerByName( "Car Engine" ); private static readonly Mixer EngineMixer = Mixer.FindMixerByName( "Engine" );
public EngineStream Stream { get; set; } = stream; public EngineStream Stream { get; set; } = stream;
public EngineState EngineState { get; set; } public EngineState EngineState { get; set; }
@@ -26,6 +26,7 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable
public void Update( float deltaTime, Vector3 position, bool isLocal = false ) public void Update( float deltaTime, Vector3 position, bool isLocal = false )
{ {
var globalPitch = 1.0f; var globalPitch = 1.0f;
// Gear wobble effect // Gear wobble effect
@@ -51,13 +52,13 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable
{ {
EngineSounds.TryGetValue( layer, out var channel ); EngineSounds.TryGetValue( layer, out var channel );
if ( !channel.IsValid() ) if ( !channel.IsValid() && layer.AudioPath.IsValid() )
{ {
channel = Sound.PlayFile( layer.AudioPath ); channel = Sound.PlayFile( layer.AudioPath );
EngineSounds[layer] = channel; EngineSounds[layer] = channel;
} }
if ( channel.Paused && (EngineSoundPaused || layer.IsMuted) ) if ( !channel.IsValid() || channel.Paused && (EngineSoundPaused || layer.IsMuted) )
continue; continue;
// Reset controller outputs // Reset controller outputs
@@ -102,6 +103,7 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable
channel.Position = position; channel.Position = position;
channel.ListenLocal = isLocal; channel.ListenLocal = isLocal;
channel.Paused = EngineSoundPaused || layer.IsMuted; channel.Paused = EngineSoundPaused || layer.IsMuted;
channel.FollowParent = true;
channel.TargetMixer = EngineMixer; channel.TargetMixer = EngineMixer;
} }

29
Code/Utils/MathM.cs Normal file
View File

@@ -0,0 +1,29 @@
using System;
namespace VeloX.Utils;
public static class MathM
{
/// <summary>
/// Converts angular velocity (rad/s) to rotations per minute.
/// </summary>
public static float AngularVelocityToRPM( this float angularVelocity ) => angularVelocity * 9.5492965855137f;
/// <summary>
/// Converts rotations per minute to angular velocity (rad/s).
/// </summary>
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;
}
}

View File

@@ -28,44 +28,38 @@ public static class PhysicsExtensions
value.x * (xz2 - wy2) + value.y * (yz2 + wx2) + value.z * (1.0f - xx2 - yy2) value.x * (xz2 - wy2) + value.y * (yz2 + wx2) + value.z * (1.0f - xx2 - yy2)
); );
} }
/// <summary> /// <summary>
/// Calculates the linear and angular velocities on the center of mass for an offset impulse. /// Calculates the linear and angular velocities on the object's center of mass for an offset impulse.
/// </summary> /// </summary>
/// <param name="physObj">The physics object</param> /// <param name="physObj">The physics object</param>
/// <param name="impulse">The impulse acting on the object in kg*units/s (World frame)</param> /// <param name="impulse">The impulse acting on the object in kg*units/s (World frame)</param>
/// <param name="position">The location of the impulse in world coordinates</param> /// <param name="position">The location of the impulse in world coordinates</param>
/// <returns> /// <param name="LinearVelocity">Linear velocity on center of mass (World frame)</param>
/// Vector1: Linear velocity from the impulse (World frame) /// <param name="AngularVelocity">Angular velocity on center of mass (World frame)</param>
/// Vector2: Angular velocity from the impulse (Local frame) public static void CalculateVelocityOffset(
/// </returns> this Rigidbody physObj,
public static (Vector3 LinearVelocity, Vector3 AngularVelocity) CalculateVelocityOffset( this PhysicsBody physObj, Vector3 impulse, Vector3 position ) Vector3 impulse,
Vector3 position,
out Vector3 LinearVelocity,
out Vector3 AngularVelocity )
{ {
if ( !physObj.IsValid() || !physObj.MotionEnabled ) if ( !physObj.IsValid() || !physObj.MotionEnabled )
return (Vector3.Zero, Vector3.Zero); {
LinearVelocity = 0;
AngularVelocity = 0;
return;
}
Vector3 com = physObj.WorldTransform.PointToWorld( physObj.MassCenter );
Rotation bodyRot = physObj.PhysicsBody.Rotation;
Vector3 linearVelocity = impulse / physObj.Mass; Vector3 r = position - com;
Vector3 torque = Vector3.Cross( r, impulse );
Vector3 r = position - physObj.MassCenter; Vector3 torqueLocal = bodyRot.Inverse * torque;
Vector3 angularVelocityLocal = torqueLocal * physObj.PhysicsBody.Inertia.Inverse;
AngularVelocity = bodyRot * angularVelocityLocal;
// Calculate torque impulse in world frame: τ = r × impulse LinearVelocity = impulse * (1 / physObj.Mass);
Vector3 torqueImpulseWorld = r.Cross( impulse );
Rotation worldToLocal = physObj.Rotation.Inverse;
Vector3 torqueImpulseLocal = torqueImpulseWorld.Transform( worldToLocal );
var InverseInertiaDiagLocal = physObj.Inertia.Inverse;
// Compute angular velocity change in rad/s (local frame)
Vector3 angularVelocityRadLocal = new(
InverseInertiaDiagLocal.x * torqueImpulseLocal.x,
InverseInertiaDiagLocal.y * torqueImpulseLocal.y,
InverseInertiaDiagLocal.z * torqueImpulseLocal.z
);
const float radToDeg = 180f / MathF.PI;
Vector3 angularVelocityDegLocal = angularVelocityRadLocal * radToDeg;
return (linearVelocity, angularVelocityDegLocal);
} }
/// <summary> /// <summary>
@@ -74,34 +68,31 @@ public static class PhysicsExtensions
/// <param name="physObj">The physics object</param> /// <param name="physObj">The physics object</param>
/// <param name="impulse">The impulse acting on the object in kg*units/s (World frame)</param> /// <param name="impulse">The impulse acting on the object in kg*units/s (World frame)</param>
/// <param name="position">The location of the impulse in world coordinates</param> /// <param name="position">The location of the impulse in world coordinates</param>
/// <returns> /// <param name="LinearImpulse">Linear impulse on center of mass (World frame)</param>
/// Vector1: Linear impulse on center of mass (World frame) /// <param name="AngularImpulse">Angular impulse on center of mass (World frame)</param>
/// Vector2: Angular impulse on center of mass (Local frame) public static void CalculateForceOffset(
/// </returns> this Rigidbody physObj,
public static (Vector3 LinearImpulse, Vector3 AngularImpulse) CalculateForceOffset(
this PhysicsBody physObj,
Vector3 impulse, Vector3 impulse,
Vector3 position ) Vector3 position,
out Vector3 LinearImpulse,
out Vector3 AngularImpulse )
{ {
if ( !physObj.IsValid() || !physObj.MotionEnabled ) if ( !physObj.IsValid() || !physObj.MotionEnabled )
{ {
return (Vector3.Zero, Vector3.Zero); LinearImpulse = 0;
AngularImpulse = 0;
return;
} }
// 1. Linear impulse is the same as the input impulse (conservation of momentum) Vector3 com = physObj.WorldTransform.PointToWorld( physObj.MassCenter );
Vector3 linearImpulse = impulse; Rotation bodyRot = physObj.PhysicsBody.Rotation;
// 2. Calculate angular impulse (torque) from the offset force Vector3 r = position - com;
// τ = r * F (cross product of position relative to COM and force) Vector3 torque = Vector3.Cross( r, impulse );
Vector3 centerOfMass = physObj.MassCenter; Vector3 torqueLocal = bodyRot.Inverse * torque;
Vector3 relativePosition = position - centerOfMass; Vector3 angularImpulseLocal = torqueLocal * physObj.PhysicsBody.Inertia.Inverse;
Vector3 worldAngularImpulse = relativePosition.Cross( impulse ); AngularImpulse = bodyRot * angularImpulseLocal;
LinearImpulse = impulse;
// Convert angular impulse to local space (since we'll use it with LocalInertia)
Rotation bodyRotation = physObj.Transform.Rotation;
Vector3 localAngularImpulse = bodyRotation.Inverse * worldAngularImpulse;
return (linearImpulse, localAngularImpulse);
} }
} }

View File

@@ -81,6 +81,11 @@ internal sealed class EngineStreamEditor : Widget, IAssetInspector
picker.OnAssetPicked += asset => CreateStreamEditor( asset[0].LoadResource<EngineStream>() ); picker.OnAssetPicked += asset => CreateStreamEditor( asset[0].LoadResource<EngineStream>() );
picker.Show(); picker.Show();
}; };
var saveButton = a.Add( new Button( "Save File" ), 0 );
saveButton.Clicked = () =>
{
ActiveStream.StateHasChanged();
};
} }
Layout RightLayout; Layout RightLayout;
SimulatedEngineWidget SimulatedEngineWidget; SimulatedEngineWidget SimulatedEngineWidget;

View File

@@ -76,7 +76,7 @@ internal sealed class SimulatedEngineWidget : Widget
[EditorEvent.Frame] [EditorEvent.Frame]
public void OnFrame() public void OnFrame()
{ {
if ( Player.Stream is null ) if ( Player is null || !Player.Stream.IsValid() )
return; return;
Player.EngineState = IsPlaying ? EngineState.Running : EngineState.Off; Player.EngineState = IsPlaying ? EngineState.Running : EngineState.Off;
@@ -85,7 +85,7 @@ internal sealed class SimulatedEngineWidget : Widget
IsRedlining = RPM.Value == 1 && !IsRedlining; IsRedlining = RPM.Value == 1 && !IsRedlining;
Player.IsRedlining = IsRedlining; Player.IsRedlining = IsRedlining;
Player.Update( Time.Delta, Vector3.Zero, true ); Player?.Update( Time.Delta, Vector3.Zero, true );
} }
} }

View File

@@ -1,90 +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.LateralForce ) )]
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<Pacejka.LateralForce>().Properties )
{
var row = Layout.AddRow();
row.Spacing = 8;
var propetry = SerializedObject.GetProperty( item.Name );
row.Add( new Label( propetry.Name ) );
row.Add( Create( propetry ) );
}
}
}
[CustomEditor( typeof( Pacejka.LongitudinalForce ) )]
private class LongitudinalForceWidget : ControlObjectWidget
{
public LongitudinalForceWidget( SerializedProperty property ) : base( property, true )
{
Layout = Layout.Column();
Layout.Margin = 8f;
Layout.Spacing = 8;
foreach ( var item in TypeLibrary.GetType<Pacejka.LongitudinalForce>().Properties )
{
var row = Layout.AddRow();
row.Spacing = 8;
var propetry = SerializedObject.GetProperty( item.Name );
row.Add( new Label( propetry.Name ) );
row.Add( Create( propetry ) );
}
}
}
[CustomEditor( typeof( Pacejka.AligningMoment ) )]
private class AligningMomentWidget : ControlObjectWidget
{
public AligningMomentWidget( SerializedProperty property ) : base( property, true )
{
Layout = Layout.Column();
Layout.Margin = 8f;
Layout.Spacing = 8;
foreach ( var item in TypeLibrary.GetType<Pacejka.AligningMoment>().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<Pacejka>();
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 ) ) ) )
);
tabs.AddPage( nameof( Pacejka.Aligning ), null,
Layout.Add( Create( obj.GetProperty( nameof( Pacejka.Aligning ) ) ) )
);
}
}

View File

@@ -1,70 +0,0 @@
using Editor;
using Editor.Assets;
using Editor.Inspectors;
using Sandbox;
using static Editor.Inspectors.AssetInspector;
namespace VeloX;
[CanEdit( "asset:tire" )]
public class TirePresetEditor : Widget, IAssetInspector
{
TirePreset Tire;
ControlSheet MainSheet;
TirePresetPreview TirePreview;
public TirePresetEditor( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 4;
Layout.Spacing = 4;
// Create a ontrolSheet that will display all our Properties
MainSheet = new ControlSheet();
Layout.Add( MainSheet );
//// Add a randomize button below the ControlSheet
//var button = Layout.Add( new Button( "Randomize", "casino", this ) );
//button.Clicked += () =>
//{
// foreach ( var prop in Test.GetSerialized() )
// {
// // Randomize all the float values from 0-100
// if ( prop.PropertyType != typeof( float ) ) continue;
// prop.SetValue( Random.Shared.Float( 0, 100 ) );
// }
//};
Layout.AddStretchCell();
RebuildSheet();
Focus();
}
[EditorEvent.Hotload]
void RebuildSheet()
{
if ( Tire is null ) return;
if ( MainSheet is null ) return;
MainSheet.Clear( true );
var so = Tire.GetSerialized();
so.OnPropertyChanged += x =>
{
Tire.StateHasChanged();
TirePreview.Widget.UpdatePixmap();
};
MainSheet.AddObject( so );
}
void IAssetInspector.SetAssetPreview( AssetPreview preview )
{
TirePreview = preview as TirePresetPreview;
}
public void SetAsset( Asset asset )
{
Tire = asset.LoadResource<TirePreset>();
RebuildSheet();
}
}

View File

@@ -9,31 +9,11 @@ using System.Threading.Tasks;
namespace VeloX; namespace VeloX;
[AssetPreview( "tire" )] [AssetPreview( "tire" )]
class TirePresetPreview( Asset asset ) : PixmapAssetPreview( asset ) class TirePresetPreview : AssetPreview
{ {
public override bool IsAnimatedPreview => false;
[Range( 0.01f, 1 )] private float Zoom { get; set; } = 1;
private TirePreset Tire = asset.LoadResource<TirePreset>();
public AssetPreviewWidget Widget { get; private set; }
[Range( 100, 10000 )] private float Load { get; set; } = 2500f;
[Range( 0, 1 )] private float Zoom { get; set; } = 0;
[Range( -10, 10 )] private float Camber { get; set; } = 0;
public override Widget CreateWidget( Widget parent )
{
Widget = parent as AssetPreviewWidget;
Task.Run( async () =>
{
while ( Widget != null )
{
await MainThread.Wait();
await Widget.UpdatePixmap();
await Task.Delay( 100 );
}
} );
return null;
}
public override Widget CreateToolbar() public override Widget CreateToolbar()
{ {
var info = new IconButton( "settings" ); var info = new IconButton( "settings" );
@@ -42,116 +22,6 @@ class TirePresetPreview( Asset asset ) : PixmapAssetPreview( asset )
info.MouseLeftPress = () => OpenSettings( info ); info.MouseLeftPress = () => OpenSettings( info );
return info; return info;
} }
static List<Vector2> pointCache = [];
private void DrawPacejka()
{
float load = Load * 0.001f;
float zoom = (1.0f - Zoom) * 4.0f + 0.1f;
var tire = Tire.Pacejka;
var width = Paint.LocalRect.Width;
var height = Paint.LocalRect.Height;
{ // draw lateral line
pointCache.Clear();
Paint.SetPen( Color.Red, 1 );
float x0 = -zoom * 0.5f * 20.0f;
float xn = zoom * 0.5f * 20.0f;
float ymin = -1000.0f;
float ymax = 1000.0f;
int points = 500;
for ( float x = x0; x <= xn; x += (xn - x0) / points )
{
float yval = tire.PacejkaFy( x, load, Camber, 1.0f, out float _ ) / load;
float xval = width * (x - x0) / (xn - x0);
yval /= ymax - ymin;
yval = (yval + 1.0f) * 0.5f;
yval = 1.0f - yval;
yval *= height;
pointCache.Add( new( xval, yval ) );
}
Paint.DrawLine( pointCache );
}
{ // draw longitudinal line
pointCache.Clear();
Paint.SetPen( Color.Green, 1 );
float x0 = -zoom * 0.5f;
float xn = zoom * 0.5f;
float ymin = -1000.0f;
float ymax = 1000.0f;
int points = 500;
for ( float x = x0; x <= xn; x += (xn - x0) / points )
{
float yval = tire.PacejkaFx( x, load, 1.0f, out var _ ) / load;
float xval = width * (x - x0) / (xn - x0);
yval /= ymax - ymin;
yval = (yval + 1.0f) * 0.5f;
yval = 1.0f - yval;
yval *= height;
pointCache.Add( new( xval, yval ) );
}
Paint.DrawLine( pointCache );
}
{ // draw aligning line
pointCache.Clear();
Paint.SetPen( Color.Blue, 1 );
float x0 = -zoom * 0.5f * 10.0f;
float xn = zoom * 0.5f * 10.0f;
float ymin = -60.0f;
float ymax = 60.0f;
int points = 500;
for ( float x = x0; x <= xn; x += (xn - x0) / points )
{
float yval = tire.PacejkaMz( x, load, Camber * (180.0f / MathF.PI), 1.0f ) / load;
float xval = width * (x - x0) / (xn - x0);
yval /= ymax - ymin;
yval = (yval + 1.0f) * 0.5f;
yval = 1.0f - yval;
yval *= height;
pointCache.Add( new( xval, yval ) );
}
Paint.DrawLine( pointCache );
}
pointCache.Clear();
}
public override Task RenderToPixmap( Pixmap pixmap )
{
Paint.ToPixmap( pixmap );
Paint.Antialiasing = true;
Paint.SetBrush( Color.Gray );
Paint.DrawRect( Paint.LocalRect );
Paint.ClearBrush();
Paint.SetPen( Color.Black, 1 );
var width = Paint.LocalRect.Width;
var height = Paint.LocalRect.Height;
float xc = width / 2;
float yc = height / 2;
Paint.DrawLine( new( xc, 0 ), new( xc, yc * 2 ) );
Paint.DrawLine( new( 0, yc ), new( xc * 2, yc ) );
DrawPacejka();
return Task.CompletedTask;
}
public void OpenSettings( Widget parent ) public void OpenSettings( Widget parent )
{ {
var popup = new PopupWidget( parent ) var popup = new PopupWidget( parent )
@@ -164,9 +34,7 @@ class TirePresetPreview( Asset asset ) : PixmapAssetPreview( asset )
var ps = new ControlSheet(); var ps = new ControlSheet();
ps.AddProperty( this, x => x.Load );
ps.AddProperty( this, x => x.Zoom ); ps.AddProperty( this, x => x.Zoom );
ps.AddProperty( this, x => x.Camber );
popup.Layout.Add( ps ); popup.Layout.Add( ps );
popup.MaximumWidth = 300; popup.MaximumWidth = 300;
@@ -175,4 +43,83 @@ class TirePresetPreview( Asset asset ) : PixmapAssetPreview( asset )
popup.ConstrainToScreen(); popup.ConstrainToScreen();
} }
public override async Task InitializeAsset()
{
await Task.Yield();
using ( Scene.Push() )
{
PrimaryObject = new( true )
{
WorldTransform = Transform.Zero
};
var spriteRenderer = PrimaryObject.AddComponent<SpriteRenderer>();
var bitmap = new Bitmap( 512, 512 );
var tire = Asset.LoadResource<TirePreset>();
Draw( bitmap, tire );
spriteRenderer.Sprite = new() { Animations = [new() { Frames = [new() { Texture = bitmap.ToTexture() }] },] }; // Set the texture on the renderer
spriteRenderer.Size = 512;
}
return;
}
public override void UpdateScene( float cycle, float timeStep )
{
base.UpdateScene( cycle, timeStep );
Camera.Orthographic = true;
Camera.OrthographicHeight = 512;
Camera.WorldPosition = Vector3.Backward * 512;
Camera.WorldRotation = Rotation.LookAt( Vector3.Forward );
var bitmap = new Bitmap( 512, 512 );
var tire = Asset.LoadResource<TirePreset>();
Draw( bitmap, tire );
PrimaryObject.Components.Get<SpriteRenderer>().Sprite = new() { Animations = [new() { Frames = [new() { Texture = bitmap.ToTexture() }] },] }; // Set the texture on the renderer
PrimaryObject.Components.Get<SpriteRenderer>().Size = 512;
}
private readonly List<Vector2> pointCache = [];
public TirePresetPreview( Asset asset ) : base( asset )
{
}
private void DrawPacejka( Bitmap bitmap, TirePreset tire )
{
var width = bitmap.Width;
var height = bitmap.Height;
{ // draw lateral line
pointCache.Clear();
bitmap.SetPen( Color.Red, 1 );
for ( float x = 0; x <= 1; x += 0.01f )
{
float val = tire.Evaluate( x ) * Zoom;
pointCache.Add( new( width * x, height - height * val ) );
}
bitmap.DrawLines( pointCache.ToArray() );
}
pointCache.Clear();
}
private void Draw( Bitmap bitmap, TirePreset tire )
{
bitmap.Clear( Color.Black );
bitmap.SetAntialias( true );
DrawPacejka( bitmap, tire );
}
} }

View File

@@ -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<TirePreset>();
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 ) ) ) );
}
}

View File

@@ -6,6 +6,6 @@ public class TestInit
[AssemblyInitialize] [AssemblyInitialize]
public static void ClassInitialize( TestContext context ) public static void ClassInitialize( TestContext context )
{ {
Sandbox.Application.InitUnitTest(); Sandbox.Application.InitUnitTest<TestInit>( false, false );
} }
} }