Compare commits

...

22 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
Valera
f0f89ff947 уже лучше 2025-06-15 21:59:42 +07:00
Valera
4912d0ae1a make it savable 2025-06-15 17:17:08 +07:00
Valera
4899a38265 not work 2025-06-15 03:23:47 +07:00
Valera
629ae6715c new pacejka implementation (car not working) 2025-06-14 18:16:26 +07:00
Valera
964b46e1c5 new powertrain смерть чуркам 2025-06-13 21:16:20 +07:00
53 changed files with 4317 additions and 1078 deletions

View File

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

View File

@@ -1,22 +0,0 @@
{
"Longitudinal": {
"B": 18,
"C": 1.5,
"D": 1.5,
"E": 0.3
},
"Lateral": {
"B": 12,
"C": 1.3,
"D": 1.8,
"E": -1.8
},
"Aligning": {
"B": 2.8,
"C": 2.1,
"D": 0.1,
"E": -2.5
},
"__references": [],
"__version": 0
}

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,22 +0,0 @@
{
"Longitudinal": {
"B": 0,
"C": 1,
"D": 1,
"E": 0.3
},
"Lateral": {
"B": 1,
"C": 1,
"D": 1,
"E": 0.3
},
"Aligning": {
"B": 2.8,
"C": 2.1,
"D": 0.1,
"E": -2.5
},
"__references": [],
"__version": 0
}

View File

@@ -0,0 +1,8 @@
{
"B": 9,
"C": 2.15,
"D": 0.933,
"E": 0.971,
"__references": [],
"__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

@@ -0,0 +1,213 @@
using System;
using System.Collections.Generic;
using Sandbox;
namespace VeloX;
public partial class Clutch : PowertrainComponent
{
protected override void OnAwake()
{
base.OnAwake();
Name ??= "Clutch";
}
/// <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()
{
if ( OutputNameHash == 0 )
{
return Inertia;
}
float I = Inertia + Output.QueryInertia() * _clutchEngagement;
return I;
}
public override float ForwardStep( float torque, float inertiaSum, float dt )
{
InputTorque = torque;
InputInertia = inertiaSum;
if ( OutputNameHash == 0 )
return torque;
// 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
// 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;
// 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

@@ -0,0 +1,676 @@
using Sandbox;
using Sandbox.Audio;
using System;
using System.Collections.Generic;
using System.Linq;
using VeloX.Utils;
using static Sandbox.VertexLayout;
using static VeloX.EngineStream;
namespace VeloX;
public class Engine : PowertrainComponent, IScenePhysicsEvents
{
protected override void OnAwake()
{
base.OnAwake();
Name ??= "Engine";
UpdatePeakPowerAndTorque();
}
[Hide] public new bool Input { get; set; }
public delegate float CalculateTorque( float angularVelocity, float dt );
/// <summary>
/// Delegate for a function that modifies engine power.
/// </summary>
public delegate float PowerModifier();
public enum EngineType
{
ICE,
Electric,
}
/// <summary>
/// If true starter will be ran for [starterRunTime] seconds if engine receives any throttle input.
/// </summary>
[Property] public bool AutoStartOnThrottle { get; set; } = true;
/// <summary>
/// Assign your own delegate to use different type of torque calculation.
/// </summary>
public CalculateTorque CalculateTorqueDelegate;
/// <summary>
/// Engine type. ICE (Internal Combustion Engine) supports features such as starter, stalling, etc.
/// Electric engine (motor) can run in reverse, can not be stalled and does not use starter.
/// </summary>
[Property] public EngineType Type { get; set; } = EngineType.ICE;
/// <summary>
/// Power generated by the engine in kW
/// </summary>
public float generatedPower;
/// <summary>
/// RPM at which idler circuit will try to keep RPMs when there is no input.
/// </summary>
[Property] public float IdleRPM { get; set; } = 900;
/// <summary>
/// Maximum engine power in [kW].
/// </summary>
[Property, Group( "Power" )] public float MaxPower { get; set; } = 120;
/// <summary>
/// Loss power (pumping, friction losses) is calculated as the percentage of maxPower.
/// Should be between 0 and 1 (100%).
/// </summary>
[Range( 0, 1 ), Property] public float EngineLossPercent { get; set; } = 0.8f;
/// <summary>
/// If true the engine will be started immediately, without running the starter, when the vehicle is enabled.
/// Sets engine angular velocity to idle angular velocity.
/// </summary>
[Property] public bool FlyingStartEnabled { get; set; }
[Property] public bool Ignition { get; 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;
protected override void OnStart()
{
base.OnStart();
if ( Type == EngineType.ICE )
{
CalculateTorqueDelegate = CalculateTorqueICE;
}
else if ( Type == EngineType.Electric )
{
IdleRPM = 0f;
FlyingStartEnabled = true;
CalculateTorqueDelegate = CalculateTorqueElectric;
StarterActive = false;
StartDuration = 0.001f;
RevLimiterCutoffDuration = 0f;
}
}
public void StartEngine()
{
if ( IsRunning ) return;
Ignition = true;
OnEngineStart?.Invoke();
if ( Type != EngineType.Electric )
{
if ( FlyingStartEnabled )
{
FlyingStart();
IsRunning = true;
IsActive = true;
}
else if ( !StarterActive && Controller != null )
{
StarterCoroutine();
}
}
else
{
IsRunning = true;
IsActive = true;
}
}
private async void StarterCoroutine()
{
if ( Type == EngineType.Electric || StarterActive )
return;
try
{
float startTimer = 0f;
StarterActive = true;
// Ensure safe start duration
StartDuration = Math.Max( 0.1f, StartDuration );
_starterTorque = ((_idleAngularVelocity - OutputAngularVelocity) * Inertia) / StartDuration;
while ( startTimer <= StartDuration && StarterActive )
{
startTimer += 0.1f;
await Task.DelaySeconds( 0.1f );
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 );
}
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

@@ -0,0 +1,181 @@
using System;
using Sandbox;
namespace VeloX;
public abstract class PowertrainComponent : Component
{
[Property] public VeloXBase Controller;
/// <summary>
/// Name of the component. Only unique names should be used on the same vehicle.
/// </summary>
[Property] public string Name { get; set; }
/// <summary>
/// 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>
/// Input component. Set automatically.
/// </summary>
[Property]
public PowertrainComponent Input
{
get => _input;
set
{
if ( value == null || value == this )
{
_input = null;
InputNameHash = 0;
return;
}
_input = value;
InputNameHash = _input.GetHashCode();
}
}
protected PowertrainComponent _input;
public int InputNameHash;
/// <summary>
/// The PowertrainComponent this component will output to.
/// </summary>
[Property]
public PowertrainComponent Output
{
get { return _output; }
set
{
if ( value == this )
{
Log.Warning( $"{Name}: PowertrainComponent Output can not be self." );
OutputNameHash = 0;
_output = null;
return;
}
_output = value;
if ( _output != null )
{
_output.Input = this;
OutputNameHash = _output.GetHashCode();
}
else
{
OutputNameHash = 0;
}
}
}
protected PowertrainComponent _output;
public int OutputNameHash { get; private set; }
/// <summary>
/// Input shaft RPM of component.
/// </summary>
[Property, ReadOnly]
public float InputRPM => AngularVelocityToRPM( InputAngularVelocity );
/// <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()
{
if ( OutputNameHash == 0 )
return Inertia;
float Ii = Inertia;
float Ia = _output.QueryInertia();
return Ii + Ia;
}
public virtual float ForwardStep( float torque, float inertiaSum, float dt )
{
InputTorque = torque;
InputInertia = inertiaSum;
if ( OutputNameHash == 0 )
return torque;
OutputTorque = InputTorque;
OutputInertia = inertiaSum + Inertia;
return _output.ForwardStep( OutputTorque, OutputInertia, dt );
}
public static float TorqueToPowerInKW( in float angularVelocity, in float torque )
{
// 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 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
{
[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 Guid ConnectionID
public bool IsDriver => Connection.Local == Driver;
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
{
_guid = value;
Input.Driver = Connection.Find( _guid );
float clampedValue = Math.Clamp( value, -1, 1 );
if ( value > 0 )
{
throttle = clampedValue;
brakes = 0;
}
else
{
throttle = 0;
brakes = -clampedValue;
}
}
[Property, Feature( "Input" )] public bool IsDriver => ConnectionID == Connection.Local.Id;
}
/// <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

@@ -6,26 +6,58 @@ public abstract partial class VeloXBase
{
private Vector3 angForce;
private void PhysicsSimulate()
protected const float BrakeForce = 4500f;
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 mass = Body.Mass;
var angVel = Body.AngularVelocity;
angForce.x = angVel.x * drag.x * mass;
angForce.y = angVel.y * drag.y * mass;
angForce.z = angVel.z * drag.z * mass;
angForce = angForce.WithX( angVel.x * drag.x * mass * 1000 );
angForce = angForce.WithY( angVel.y * drag.y * mass * 1000 );
angForce = angForce.WithZ( angVel.z * drag.z * mass * 1000 );
if ( Wheels.Count > 0 )
{
Vector3 vehVel = Body.Velocity;
Vector3 vehAngVel = Body.AngularVelocity;
var dt = Time.Delta;
CombinedLoad = 0;
foreach ( var v in Wheels )
v.DoPhysics( this, in dt );
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;
}
Body.ApplyTorque( angForce );
if ( TotalSpeed < 1 && !AnyInput )
{
v.BrakeTorque = HandbrakeForce;
}
}
Body.Velocity = vehVel;
Body.AngularVelocity = vehAngVel;
}
//Body.ApplyForce( linForce );
//Body.ApplyTorque( angForce );
}
}

View File

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

View File

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

View File

@@ -1,36 +1,58 @@
using Sandbox;
using System;
namespace VeloX;
public abstract partial class VeloXBase : Component
public abstract partial class VeloXBase : Component, IGameObjectNetworkEvents
{
[Sync] public WaterState WaterState { get; set; }
[Sync] public bool IsEngineOnFire { get; set; }
[Sync, Range( 0, 1 ), Property] public float Brake { get; set; }
[Sync( SyncFlags.Interpolate ), Range( 0, 1 ), Property] public float Throttle { get; set; }
[Sync, Change( nameof( OnEngineIgnitionChange ) )]
public bool EngineIgnition { 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 float Mass { get; set; } = 900;
[Property, Group( "Components" )] public Rigidbody Body { get; protected set; }
[Property, Group( "Components" )] public Collider Collider { get; protected set; }
[Sync] 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 TotalSpeed;
protected override void OnFixedUpdate()
{
if ( !IsDriver )
return;
if ( !IsProxy )
{
LocalVelocity = WorldTransform.PointToLocal( WorldPosition + Body.Velocity );
Velocity = Body.Velocity;
}
ForwardSpeed = LocalVelocity.x;
TotalSpeed = LocalVelocity.Length;
if ( IsProxy )
return;
Body.PhysicsBody.Mass = Mass;
FixedUpdate();
}
protected virtual void FixedUpdate()
{
UpdateInput();
PhysicsSimulate();
}

View File

@@ -1,11 +1,16 @@
namespace VeloX;
using System;
namespace VeloX;
public struct Friction
{
public float SlipCoef { get; set; }
public float ForceCoef { get; set; }
public float Force { get; set; }
public float Slip { 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,16 +0,0 @@
using System;
namespace VeloX;
public class FrictionPreset
{
public float B { get; set; } = 10.86f;
public float C { get; set; } = 2.15f;
public float D { get; set; } = 0.933f;
public float E { get; set; } = 0.992f;
public float Evaluate( float slip )
{
var t = Math.Abs( slip );
return D * MathF.Sin( C * MathF.Atan( B * t - E * (B * t - MathF.Atan( B * t )) ) );
}
}

View File

@@ -0,0 +1,48 @@
using Sandbox;
using System;
namespace VeloX;
[AssetType( Name = "Wheel Friction", Extension = "tire", Category = "VeloX" )]
public class TirePreset : GameResource
{
public static TirePreset Default { get; } = ResourceLibrary.Get<TirePreset>( "frictions/default.tire" );
private float peakSlip = -1;
[Property] public float B { get; set; } = 10.86f;
[Property] public float C { get; set; } = 2.15f;
[Property] public float D { get; set; } = 0.933f;
[Property] public float E { get; set; } = 0.992f;
public float GetPeakSlip()
{
if ( peakSlip == -1 )
peakSlip = CalcPeakSlip();
return peakSlip;
}
public float Evaluate( float t ) => D * MathF.Sin( C * MathF.Atan( B * t - E * (B * t - MathF.Atan( B * t )) ) );
private float CalcPeakSlip()
{
float peakSlip = -1;
float yMax = 0;
for ( float i = 0; i < 1f; i += 0.01f )
{
float y = Evaluate( i );
if ( y > yMax )
{
yMax = y;
peakSlip = i;
}
}
return peakSlip;
}
}

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 System;
namespace VeloX;
public partial class VeloXWheel : Component
{
protected override void DrawGizmos()
protected override void DrawGizmos() => GizmoDraw();
public void GizmoDraw()
{
if ( !Gizmo.IsSelected )
@@ -18,7 +21,7 @@ public partial class VeloXWheel : Component
//
{
var suspensionStart = Vector3.Zero;
var suspensionEnd = Vector3.Zero + Vector3.Down * SuspensionLength;
var suspensionEnd = Vector3.Zero + Vector3.Down * RestLength.MeterToInch();
Gizmo.Draw.Color = Color.Cyan;
Gizmo.Draw.LineThickness = 0.25f;
@@ -28,7 +31,7 @@ public partial class VeloXWheel : Component
Gizmo.Draw.Line( suspensionStart + Vector3.Forward, suspensionStart + Vector3.Backward );
Gizmo.Draw.Line( suspensionEnd + Vector3.Forward, suspensionEnd + Vector3.Backward );
}
var widthOffset = Vector3.Right * Width * 0.5f;
var widthOffset = Vector3.Right * Width.MeterToInch() * 0.5f;
//
// Wheel radius
//
@@ -36,7 +39,7 @@ public partial class VeloXWheel : Component
Gizmo.Draw.LineThickness = 0.5f;
Gizmo.Draw.Color = Color.White;
Gizmo.Draw.LineCylinder( widthOffset, -widthOffset, Radius, Radius, 16 );
Gizmo.Draw.LineCylinder( widthOffset, -widthOffset, Radius.MeterToInch(), Radius.MeterToInch(), 16 );
}
//
@@ -51,24 +54,14 @@ public partial class VeloXWheel : Component
for ( float i = 0; i < 16; i++ )
{
var pos = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( i / 16 * 360, 0, 0 ) ) * Radius;
var pos = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( i / 16 * 360, 0, 0 ) ) * Radius.MeterToInch();
Gizmo.Draw.Line( new Line( pos - widthOffset, pos + widthOffset ) );
var pos2 = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( (i + 1) / 16 * 360, 0, 0 ) ) * Radius;
var pos2 = circlePosition + Vector3.Up.RotateAround( Vector3.Zero, new Angles( (i + 1) / 16 * 360, 0, 0 ) ) * Radius.MeterToInch();
Gizmo.Draw.Line( pos - widthOffset, pos2 + widthOffset );
}
}
////
//// 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

@@ -0,0 +1,28 @@
using Sandbox;
using System;
namespace VeloX;
public partial class VeloXWheel : Component
{
private RealTimeUntil expandSoundCD;
private RealTimeUntil contractSoundCD;
private void DoSuspensionSounds( VeloXBase vehicle, float change )
{
if ( change > 0.1f && expandSoundCD )
{
expandSoundCD = 0.3f;
var sound = Sound.Play( vehicle.SuspensionUpSound, WorldPosition );
sound.Volume = Math.Clamp( Math.Abs( change ) * 5f, 0, 0.5f );
}
if ( change < -0.1f && contractSoundCD )
{
contractSoundCD = 0.3f;
change = MathF.Abs( change );
var sound = Sound.Play( change > 0.3f ? vehicle.SuspensionHeavySound : vehicle.SuspensionDownSound, WorldPosition );
sound.Volume = Math.Clamp( change * 5f, 0, 1 );
}
}
}

View File

@@ -1,11 +1,6 @@
using Sandbox;
using Sandbox.UI;
using Sandbox.Utility;
using System;
using System.Buffers.Text;
using System.Numerics;
using System.Runtime.Intrinsics.Arm;
using System.Text.RegularExpressions;
using System.Threading;
namespace VeloX;
@@ -13,342 +8,214 @@ namespace VeloX;
[Title( "VeloX - Wheel" )]
public partial class VeloXWheel : Component
{
[Property, Group( "Suspension" )] public float Radius { get; set; } = 0.35f;
[Property, Group( "Suspension" )] public float Width { get; set; } = 0.1f;
[Property] public float Mass { get; set; } = 5;
[Property, Group( "Suspension" )] float RestLength { get; set; } = 0.22f;
[Property, Group( "Suspension" )] public float SpringStiffness { get; set; } = 20000.0f;
[Property, Group( "Suspension" )] float ReboundStiffness { get; set; } = 2200;
[Property, Group( "Suspension" )] float CompressionStiffness { get; set; } = 2400;
[Property] public float Radius { get; set; } = 15;
[Property] public float Mass { get; set; } = 20;
[Property] public float RollingResistance { get; set; } = 20;
[Property] public float SlipCircleShape { get; set; } = 1.05f;
public FrictionPreset LongitudinalFrictionPreset => WheelFriction.Longitudinal;
public FrictionPreset LateralFrictionPreset => WheelFriction.Lateral;
public FrictionPreset AligningFrictionPreset => WheelFriction.Aligning;
[Property] public WheelFriction WheelFriction { get; set; }
[Property, Group( "Traction" )] public TirePreset Tire { get; set; } = ResourceLibrary.Get<TirePreset>( "frictions/default.tire" );
[Property, Group( "Traction" )] public float SurfaceGrip { get; set; } = 1f;
[Property, Group( "Traction" )] public float SurfaceResistance { get; set; } = 0.05f;
public bool AutoSimulate = true;
[Property] public float Width { get; set; } = 6;
[Sync] public float SideSlip { get; private set; }
[Sync] public float ForwardSlip { get; private set; }
[Sync, Range( 0, 1 )] public float BrakePower { get; set; }
[Sync] public float Torque { get; set; }
[Sync, Range( 0, 1 )] public float Brake { get; set; }
[Property] float BrakePowerMax { get; set; } = 3000;
public float BaseInertia => Mass * (Radius * Radius); // kg·m²
[Property] public float Inertia { get; set; } = 1.5f; // kg·m²
[Property] public bool IsFront { get; protected set; }
[Property] public float SteerMultiplier { get; set; }
[Property] public float CasterAngle { get; set; } = 7; // todo
[Property] public float CamberAngle { get; set; } = -3;
[Property] public float ToeAngle { get; set; } = 0.5f;
[Property, Group( "Suspension" )] float SuspensionLength { get; set; } = 10;
[Property, Group( "Suspension" )] float SpringStrength { get; set; } = 800;
[Property, Group( "Suspension" )] float SpringDamper { get; set; } = 3000;
public float RPM { get => AngularVelocity * 60f / MathF.Tau; set => AngularVelocity = value / (60 / MathF.Tau); }
public float Spin { get; private set; }
[Sync] private Vector3 StartPos { get; set; }
private static Rotation CylinderOffset = Rotation.FromRoll( 90 );
public float RPM { get => angularVelocity * 60f / MathF.Tau; set => angularVelocity = value / (60 / MathF.Tau); }
internal float DistributionFactor { get; set; }
[Sync] public bool IsOnGround { get; private set; }
private Vector3 StartPos { get; set; }
private static Rotation CylinderOffset => Rotation.FromRoll( 90 );
public SceneTraceResult Trace { get; private set; }
public bool IsOnGround => Trace.Hit;
[Property] public float DriveTorque { get; set; }
[Property] public float BrakeTorque { get; set; }
private float lastSpringOffset;
private float angularVelocity;
private float load;
private float lastFraction;
private RealTimeUntil expandSoundCD;
private RealTimeUntil contractSoundCD;
public float Compression { get; protected set; } // meters
[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; }
private Vector3 contactPos;
private Vector3 forward;
private Vector3 right;
private Vector3 up;
private Friction forwardFriction;
private Friction sideFriction;
private Vector3 force;
private float BaseInertia => 0.5f * Mass * MathF.Pow( Radius.InchToMeter(), 2 );
private float Inertia => BaseInertia;
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()
{
Vehicle = Components.Get<VeloXBase>( FindMode.EverythingInSelfAndAncestors );
base.OnAwake();
if ( StartPos.IsNearZeroLength )
StartPos = LocalPosition;
Inertia = BaseInertia;
}
private void DoSuspensionSounds( VeloXBase vehicle, float change )
private void UpdateVisuals()
{
if ( change > 0.1f && expandSoundCD )
{
expandSoundCD = 0.3f;
var sound = Sound.Play( vehicle.SuspensionUpSound, WorldPosition );
sound.Volume = Math.Clamp( Math.Abs( change ) * 5f, 0, 0.5f );
WorldRotation = Vehicle.WorldTransform.RotationToWorld( Vehicle.SteerAngle * SteerMultiplier ).RotateAroundAxis( Vector3.Right, -RollAngle );
LocalPosition = StartPos + Vector3.Down * LastLength.MeterToInch();
}
if ( change < -0.1f && contractSoundCD )
private struct WheelTraceData
{
contractSoundCD = 0.3f;
change = MathF.Abs( change );
var sound = Sound.Play( change > 0.3f ? vehicle.SuspensionHeavySound : vehicle.SuspensionDownSound, WorldPosition );
sound.Volume = Math.Clamp( change * 5f, 0, 1 );
internal Vector3 ContactNormal;
internal Vector3 ContactPosition;
internal float Compression;
internal float Force;
}
public void UpdateForce()
{
Vehicle.Body.ApplyForceAt( ContactPosition, FrictionForce + ContactNormal * Fz.MeterToInch() );
FrictionForce = 0;
}
internal void Update( VeloXBase vehicle, in float dt )
internal void StepPhys( VeloXBase vehicle, in float dt )
{
UpdateVisuals( vehicle, dt );
const int numSamples = 3;
float halfWidth = Width.MeterToInch() * 0.5f;
int hitCount = 0;
WheelTraceData wheelTraceData = new();
for ( int i = 0; i < numSamples; i++ )
{
float t = (float)i / (numSamples - 1);
float offset = MathX.Lerp( -halfWidth, halfWidth, t );
Vector3 start = vehicle.WorldTransform.PointToWorld( StartPos + Vector3.Right * offset );
Vector3 end = start + vehicle.WorldRotation.Down * RestLength.MeterToInch();
if ( TraceWheel( vehicle, ref wheelTraceData, start, end, Width.MeterToInch() / numSamples, dt ) )
hitCount++;
}
private void UpdateVisuals( VeloXBase vehicle, in float dt )
if ( hitCount > 0 )
{
var entityAngles = vehicle.WorldRotation;
Spin -= angularVelocity.MeterToInch() * dt;
IsOnGround = true;
Fz = Math.Max( wheelTraceData.Force / hitCount, 0 );
Compression = wheelTraceData.Compression / hitCount;
ContactNormal = (wheelTraceData.ContactNormal / hitCount).Normal;
ContactPosition = wheelTraceData.ContactPosition / hitCount;
LastLength = RestLength - Compression;
var steerRotated = entityAngles.RotateAroundAxis( Vector3.Up, vehicle.SteerAngle.yaw * SteerMultiplier + ToeAngle );
var camberRotated = steerRotated.RotateAroundAxis( Vector3.Forward, -CamberAngle );
var angularVelocityRotated = camberRotated.RotateAroundAxis( Vector3.Right, Spin );
WorldRotation = angularVelocityRotated;
UpdateHitVariables();
UpdateFriction( dt );
}
private (float, float, float, float) StepLongitudinal( float Vx, float Lc, float kFx, float kSx, float dt )
{
float Tm = Torque;
float Tb = Brake * BrakePowerMax + RollingResistance;
float R = Radius.InchToMeter();
float I = Inertia;
float Winit = angularVelocity;
float W = angularVelocity;
float VxAbs = MathF.Abs( Vx );
float Sx;
if ( VxAbs >= 0.1f )
Sx = (Vx - W * R) / VxAbs;
else
Sx = (Vx - W * R) * 0.6f;
{
IsOnGround = false;
Compression = 0f;
Fz = 0f;
ContactNormal = Vector3.Up;
ContactPosition = WorldPosition;
LastLength = RestLength;
Sx = Math.Clamp( Sx * kSx, -1, 1 );
W += Tm / I * dt;
Tb *= W > 0 ? -1 : 1;
float TbCap = MathF.Abs( W ) * I / dt;
float Tbr = MathF.Abs( Tb ) - MathF.Abs( TbCap );
Tbr = MathF.Max( Tbr, 0 );
Tb = Math.Clamp( Tb, -TbCap, TbCap );
W += Tb / I * dt;
float maxTorque = LongitudinalFrictionPreset.Evaluate( Sx ) * Lc * kFx;
float errorTorque = (W - Vx / R) * I / dt;
float surfaceTorque = MathX.Clamp( errorTorque, -maxTorque, maxTorque );
W -= surfaceTorque / I * dt;
float Fx = surfaceTorque / R;
Tbr *= W > 0 ? -1 : 1;
float TbCap2 = MathF.Abs( W ) * I / dt;
float Tb2 = Math.Clamp( Tbr, -TbCap2, TbCap2 );
W += Tb2 / I * dt;
float deltaOmegaTorque = (W - Winit) * I / dt;
float Tcnt = -surfaceTorque + Tb + Tb2 - deltaOmegaTorque;
if ( Lc < 0.001f )
Sx = 0;
return (W, Sx, Fx, Tcnt);
UpdateHitVariables();
UpdateFriction( dt );
}
private (float, float) StepLateral( float Vx, float Vy, float Lc, float kFy, float kSy, float dt )
{
float VxAbs = MathF.Abs( Vx );
float Sy;
if ( VxAbs > 0.1f )
Sy = MathF.Atan( Vy / VxAbs ).RadianToDegree() * 0.01111f;
else
Sy = Vy * (0.003f / dt);
Sy *= kSy * 0.95f;
Sy = Math.Clamp( Sy * kSy, -1, 1 );
float Fy = -MathF.Sign( Sy ) * LateralFrictionPreset.Evaluate( Sy ) * Lc * kFy;
if ( Lc < 0.0001f )
Sy = 0;
return (Sy, Fy);
}
private void SlipCircle( float Sx, float Sy, float Fx, ref float Fy )
{
float SxAbs = Math.Abs( Sx );
if ( SxAbs > 0.01f )
{
float SxClamped = Math.Clamp( Sx, -1, 1 );
float SyClamped = Math.Clamp( Sy, -1, 1 );
Vector2 combinedSlip = new(
SxClamped * SlipCircleShape,
SyClamped
);
Vector2 slipDir = combinedSlip.Normal;
float F = MathF.Sqrt( Fx * Fx + Fy * Fy );
float absSlipDirY = MathF.Abs( slipDir.y );
Fy = F * absSlipDirY * MathF.Sign( Fy );
}
}
private static float GetLongitudinalLoadCoefficient( float load ) => 11000 * (1 - MathF.Exp( -0.00014f * load ));
private static float GetLateralLoadCoefficient( float load ) => 18000 * (1 - MathF.Exp( -0.0001f * load ));
public void DoPhysics( VeloXBase vehicle, in float dt )
private bool TraceWheel( VeloXBase vehicle, ref WheelTraceData wheelTraceData, Vector3 start, Vector3 end, float width, in float dt )
{
var pos = vehicle.WorldTransform.PointToWorld( StartPos );
var ang = vehicle.WorldTransform.RotationToWorld( vehicle.SteerAngle * SteerMultiplier );
forward = ang.Forward;
right = ang.Right;
up = ang.Up;
var maxLen = SuspensionLength;
var endPos = pos + ang.Down * maxLen;
Trace = Scene.Trace
.IgnoreGameObjectHierarchy( vehicle.GameObject )
.Cylinder( Width, Radius, pos, endPos )
.Rotated( vehicle.WorldTransform.Rotation * CylinderOffset )
.UseRenderMeshes( false )
SceneTraceResult trace;
if ( IsOnGround && vehicle.TotalSpeed < 550 )
{
trace = Scene.Trace
.FromTo( start, end )
.Cylinder( width, Radius.MeterToInch() )
.Rotated( vehicle.WorldRotation * CylinderOffset )
.UseHitPosition( false )
.WithoutTags( vehicle.WheelIgnoredTags )
.IgnoreGameObjectHierarchy( Vehicle.GameObject )
.WithCollisionRules( Vehicle.GameObject.Tags )
.Run();
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( pos );
vel.x = vel.x.InchToMeter();
vel.y = vel.y.InchToMeter();
vel.z = vel.z.InchToMeter();
if ( !IsOnGround )
if ( trace.StartedSolid )
{
SideSlip = 0;
ForwardSlip = 0;
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 );
}
}
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;
if ( AutoSimulate )
StepPhys( Vehicle, dt );
StepRotation( Vehicle, dt );
}
var offset = maxLen - (fraction * maxLen);
var springForce = (offset * SpringStrength);
var damperForce = (lastSpringOffset - offset) * SpringDamper;
lastSpringOffset = offset;
force = (springForce - damperForce) * MathF.Max( 0, up.Dot( normal ) ) * normal / dt;
// Vector3.CalculateVelocityOffset is broken (need fix)
//var velU = normal.Dot( vel ).MeterToInch();
//if ( velU < 0 && offset + Math.Abs( velU * dt ) > SuspensionLength )
//{
// var (linearVel, angularVel) = vehicle.Body.PhysicsBody.CalculateVelocityOffset( (-velU.InchToMeter() / dt) * normal, pos );
// vehicle.Body.Velocity += linearVel;
// vehicle.Body.AngularVelocity += angularVel;
//}
load = springForce - damperForce;
load = Math.Max( load, 0 );
var longitudinalLoadCoefficient = GetLongitudinalLoadCoefficient( load );
var lateralLoadCoefficient = GetLateralLoadCoefficient( load );
float forwardSpeed = 0;
float sideSpeed = 0;
if ( IsOnGround )
private void StepRotation( VeloXBase vehicle, in float dt )
{
forwardSpeed = vel.Dot( forward );
sideSpeed = vel.Dot( right );
RollAngle += MathX.RadianToDegree( AngularVelocity ) * dt;
RollAngle = (RollAngle % 360f + 360f) % 360f;
}
(float W, float Sx, float Fx, float _) = StepLongitudinal(
forwardSpeed,
longitudinalLoadCoefficient,
0.95f,
0.9f,
dt
);
(float Sy, float Fy) = StepLateral(
forwardSpeed,
sideSpeed,
lateralLoadCoefficient,
0.95f,
0.9f,
dt
);
SlipCircle( Sx, Sy, Fx, ref Fy );
angularVelocity = W;
forwardFriction = new Friction()
protected override void OnFixedUpdate()
{
Slip = Sx,
Force = Fx.MeterToInch(),
Speed = forwardSpeed
};
sideFriction = new Friction()
{
Slip = Sy,
Force = Fy.MeterToInch(),
Speed = sideSpeed
};
var frictionforce = right * sideFriction.Force + forward * forwardFriction.Force;
vehicle.Body.ApplyForceAt( contactPos, force + frictionforce );
UpdateSmoke();
}
// debug
protected override void OnUpdate()
{
DebugOverlay.Normal( contactPos, forward * forwardFriction.Force / 10000f, Color.Red, overlay: true );
DebugOverlay.Normal( contactPos, right * sideFriction.Force / 10000f, Color.Green, overlay: true );
DebugOverlay.Normal( contactPos, up * force / 50000f, Color.Blue, overlay: true );
UpdateVisuals();
UpdateSkid();
}
}

View File

@@ -1,12 +0,0 @@
using Sandbox;
namespace VeloX;
[GameResource( "Wheel Friction", "whfric", "Wheel Friction", Category = "VeloX", Icon = "radio_button_checked" )]
public class WheelFriction : GameResource
{
public FrictionPreset Longitudinal { get; set; }
public FrictionPreset Lateral { get; set; }
public FrictionPreset Aligning { get; set; }
}

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,354 +0,0 @@
using Sandbox;
using System;
using System.Collections.Generic;
namespace VeloX;
public partial class VeloXCar
{
[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,46 +1,45 @@
using Sandbox;
using System;
using System.Threading;
using VeloX.Utils;
namespace VeloX;
public partial class VeloXCar
{
public static float ExpDecay( float a, float b, float decay, float dt ) => b + (a - b) * MathF.Exp( -decay * dt );
[Property, Feature( "Steer" )] public float SteerConeMaxSpeed { get; set; } = 1800;
[Property, Feature( "Steer" )] public float SteerConeMaxAngle { get; set; } = 0.25f;
[Property, Feature( "Steer" )] public float SteerConeChangeRate { get; set; } = 8;
[Property, Feature( "Steer" )] public float CounterSteer { get; set; } = 0.1f;
[Property, Feature( "Steer" )] public float MaxSteerAngle { get; set; } = 35f;
[Property, Feature( "Steer" )] public float MaxSteerAngle { get; set; } = 40f;
[Sync] public float Steering { get; private set; }
private float jTurnMultiplier;
[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;
[ConVar( "assist_mult" )]
public static float MaxSteerAngleMultiplier { get; set; } = 1f;
public int CarDirection { get { return ForwardSpeed < 1 ? 0 : (VelocityAngle < 90 && VelocityAngle > -90 ? 1 : -1); } }
public float VelocityAngle { get; private set; }
private float currentSteerAngle;
private void UpdateSteering( float dt )
{
var inputSteer = Input.AnalogMove.y;
var absInputSteer = Math.Abs( inputSteer );
float targetSteerAngle = SteeringAngle * MaxSteerAngle;
var sideSlip = Math.Clamp( avgSideSlip, -1, 1 );
if ( !Input.Down( "Jump" ) )
targetSteerAngle *= Math.Clamp( 1 - Math.Clamp( TotalSpeed / 3000, 0f, 0.85f ), -1, 1 );
var steerConeFactor = Math.Clamp( TotalSpeed / SteerConeMaxSpeed, 0, 1 );
var steerCone = 1 - steerConeFactor * (1 - SteerConeMaxAngle);
VelocityAngle = -Body.Velocity.SignedAngle( WorldRotation.Forward, WorldRotation.Up );
steerCone = Math.Clamp( steerCone, Math.Abs( sideSlip ), 1 );
float targetAngle = 0;
inputSteer = ExpDecay( this.inputSteer, inputSteer * steerCone, SteerConeChangeRate, dt );
this.inputSteer = inputSteer;
var counterSteer = sideSlip * steerConeFactor * (1 - absInputSteer);
counterSteer = Math.Clamp( counterSteer, -1, 1 ) * CounterSteer;
if ( TotalSpeed > 150 && CarDirection > 0 && IsOnGround )
targetAngle = VelocityAngle * MaxSteerAngleMultiplier;
inputSteer = Math.Clamp( inputSteer + counterSteer, -1, 1 );
Steering = inputSteer;
SteerAngle = new( 0, inputSteer * MaxSteerAngle, 0 );
float lerpSpeed = Math.Abs( SteeringAngle ) < 0.1f ? SteerReturnSpeed : SteerInputResponse;
if ( ForwardSpeed < -100 )
jTurnMultiplier = 0.5f;
else
jTurnMultiplier = ExpDecay( jTurnMultiplier, 1, 2, dt );
currentSteerAngle = ExpDecay( currentSteerAngle, targetSteerAngle, lerpSpeed, Time.Delta );
SteerAngle = new( 0, Math.Clamp( currentSteerAngle + targetAngle, -MaxSteerAngle, MaxSteerAngle ), 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,46 +0,0 @@
namespace VeloX;
public partial class VeloXCar
{
private void WheelThink( in float dt )
{
var maxRPM = GetTransmissionMaxRPM( Gear );
var frontTorque = availableFrontTorque;
var rearTorque = availableRearTorque;
groundedCount = 0;
float avgRPM = 0, totalSideSlip = 0, totalForwardSlip = 0;
foreach ( var w in Wheels )
{
w.Update( this, dt );
totalSideSlip += w.SideSlip;
totalForwardSlip += w.ForwardSlip;
var rpm = w.RPM;
avgRPM += rpm * w.DistributionFactor;
w.Torque = w.DistributionFactor * (w.IsFront ? frontTorque : rearTorque);
w.Brake = w.IsFront ? frontBrake : rearBrake;
if ( inputHandbrake && !w.IsFront )
w.RPM = 0;
if ( rpm > maxRPM )
w.RPM = maxRPM;
if ( w.IsOnGround )
groundedCount++;
}
avgPoweredRPM = avgRPM;
avgSideSlip = totalSideSlip / Wheels.Count;
avgForwardSlip = totalForwardSlip / Wheels.Count;
}
}

View File

@@ -7,46 +7,44 @@ namespace VeloX;
[Title( "VeloX - Car" )]
public partial class VeloXCar : VeloXBase
{
protected override void OnStart()
{
base.OnStart();
StreamPlayer = new( Stream );
if ( IsDriver )
{
UpdateGearList();
UpdatePowerDistribution();
}
}
protected override void OnUpdate()
{
base.OnUpdate();
if ( StreamPlayer is not null )
{
StreamPlayer.Throttle = Throttle;
StreamPlayer.RPMPercent = RPMPercent;
StreamPlayer.EngineState = EngineState;
StreamPlayer.IsRedlining = IsRedlining;
StreamPlayer.Update( Time.Delta, WorldPosition );
}
}
protected override void OnFixedUpdate()
{
if ( !IsDriver )
return;
base.OnFixedUpdate();
Brake = Math.Clamp( frontBrake + rearBrake + (Input.Down( "Jump" ) ? 1 : 0), 0, 1 );
if ( IsProxy )
return;
UpdateABS();
UpdateESC();
UpdateTCS();
var dt = Time.Delta;
EngineThink( dt );
WheelThink( dt );
//EngineThink( dt );
SimulateAerodinamics( dt );
//WheelThink( dt );
UpdateSteering( dt );
UpdateUnflip( dt );
UpdateDrift( dt );
}
private void UpdateUnflip( float dt )
{
if ( Math.Abs( 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;
namespace VeloX;
public class InputResolver
{
public Connection Driver { get; internal set; }
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;
public Vector2 MouseDelta => Input.MouseDelta;
public Vector2 MouseWheel => Input.MouseWheel;
public Angles AnalogLook => Input.AnalogLook;
public Vector3 AnalogMove
{
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 )
{
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 )
{
return IsDriverActive && Input.Pressed( action );
return Input.Pressed( 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 )
{
if ( IsDriverActive )
{
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()
{
if ( IsDriverActive )
Input.StopAllHaptics();
}
}

View File

@@ -4,7 +4,8 @@ using VeloX.Audio;
namespace VeloX;
[GameResource( "Engine Stream", "engstr", "Engine Sound", Category = "VeloX", Icon = "time_to_leave" )]
[AssetType( Name = "Engine Stream", Extension = "engstr", Category = "VeloX" )]
[Icon( "time_to_leave" )]
public sealed class EngineStream : GameResource
{
public sealed class Layer

View File

@@ -10,7 +10,7 @@ namespace VeloX;
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 EngineState EngineState { get; set; }
@@ -26,6 +26,7 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable
public void Update( float deltaTime, Vector3 position, bool isLocal = false )
{
var globalPitch = 1.0f;
// Gear wobble effect
@@ -51,13 +52,13 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable
{
EngineSounds.TryGetValue( layer, out var channel );
if ( !channel.IsValid() )
if ( !channel.IsValid() && layer.AudioPath.IsValid() )
{
channel = Sound.PlayFile( layer.AudioPath );
EngineSounds[layer] = channel;
}
if ( channel.Paused && (EngineSoundPaused || layer.IsMuted) )
if ( !channel.IsValid() || channel.Paused && (EngineSoundPaused || layer.IsMuted) )
continue;
// Reset controller outputs
@@ -102,6 +103,7 @@ public class EngineStreamPlayer( EngineStream stream ) : IDisposable
channel.Position = position;
channel.ListenLocal = isLocal;
channel.Paused = EngineSoundPaused || layer.IsMuted;
channel.FollowParent = true;
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)
);
}
/// <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>
/// <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="position">The location of the impulse in world coordinates</param>
/// <returns>
/// Vector1: Linear velocity from the impulse (World frame)
/// Vector2: Angular velocity from the impulse (Local frame)
/// </returns>
public static (Vector3 LinearVelocity, Vector3 AngularVelocity) CalculateVelocityOffset( this PhysicsBody physObj, Vector3 impulse, Vector3 position )
/// <param name="LinearVelocity">Linear velocity on center of mass (World frame)</param>
/// <param name="AngularVelocity">Angular velocity on center of mass (World frame)</param>
public static void CalculateVelocityOffset(
this Rigidbody physObj,
Vector3 impulse,
Vector3 position,
out Vector3 LinearVelocity,
out Vector3 AngularVelocity )
{
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 - physObj.MassCenter;
// Calculate torque impulse in world frame: τ = r × impulse
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);
Vector3 r = position - com;
Vector3 torque = Vector3.Cross( r, impulse );
Vector3 torqueLocal = bodyRot.Inverse * torque;
Vector3 angularVelocityLocal = torqueLocal * physObj.PhysicsBody.Inertia.Inverse;
AngularVelocity = bodyRot * angularVelocityLocal;
LinearVelocity = impulse * (1 / physObj.Mass);
}
/// <summary>
@@ -74,34 +68,31 @@ public static class PhysicsExtensions
/// <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="position">The location of the impulse in world coordinates</param>
/// <returns>
/// Vector1: Linear impulse on center of mass (World frame)
/// Vector2: Angular impulse on center of mass (Local frame)
/// </returns>
public static (Vector3 LinearImpulse, Vector3 AngularImpulse) CalculateForceOffset(
this PhysicsBody physObj,
/// <param name="LinearImpulse">Linear impulse on center of mass (World frame)</param>
/// <param name="AngularImpulse">Angular impulse on center of mass (World frame)</param>
public static void CalculateForceOffset(
this Rigidbody physObj,
Vector3 impulse,
Vector3 position )
Vector3 position,
out Vector3 LinearImpulse,
out Vector3 AngularImpulse )
{
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 linearImpulse = impulse;
Vector3 com = physObj.WorldTransform.PointToWorld( physObj.MassCenter );
Rotation bodyRot = physObj.PhysicsBody.Rotation;
// 2. Calculate angular impulse (torque) from the offset force
// τ = r * F (cross product of position relative to COM and force)
Vector3 centerOfMass = physObj.MassCenter;
Vector3 relativePosition = position - centerOfMass;
Vector3 worldAngularImpulse = relativePosition.Cross( 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);
Vector3 r = position - com;
Vector3 torque = Vector3.Cross( r, impulse );
Vector3 torqueLocal = bodyRot.Inverse * torque;
Vector3 angularImpulseLocal = torqueLocal * physObj.PhysicsBody.Inertia.Inverse;
AngularImpulse = bodyRot * angularImpulseLocal;
LinearImpulse = impulse;
}
}

View File

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

View File

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

View File

@@ -1,55 +0,0 @@
//using Editor;
//using Editor.Assets;
//using Sandbox;
//using VeloX;
//using static Editor.Inspectors.AssetInspector;
//[CanEdit( "asset:engstr" )]
//public class EngineStreamInspector : Widget, IAssetInspector
//{
// EngineStream EngineStream;
// ControlSheet MainSheet;
// public EngineStreamInspector( Widget parent ) : base( parent )
// {
// Layout = Layout.Column();
// Layout.Margin = 12;
// Layout.Spacing = 12;
// MainSheet = new ControlSheet();
// Layout.Add( MainSheet, 1 );
// }
// [EditorEvent.Hotload]
// void RebuildSheet()
// {
// if ( EngineStream is null || MainSheet is null )
// return;
// Layout.Clear( true );
// var text = Layout.Add( new Editor.TextEdit() );
// var but = Layout.Add( new Editor.Button( "Load JSON" ) );
// but.Clicked += () =>
// {
// EngineStream.LoadFromJson( text.PlainText );
// };
// var so = EngineStream.GetSerialized();
// so.OnPropertyChanged += _ =>
// {
// EngineStream.StateHasChanged();
// };
// Layout.Add( ControlWidget.Create( so.GetProperty( nameof( EngineStream.Layers ) ) ) );
// Layout.Add( ControlWidget.Create( so.GetProperty( nameof( EngineStream.Parameters ) ) ) );
// }
// public void SetAsset( Asset asset )
// {
// EngineStream = asset.LoadResource<EngineStream>();
// RebuildSheet();
// }
//}

View File

@@ -0,0 +1,125 @@
using Editor;
using Editor.Assets;
using Editor.Inspectors;
using Sandbox;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace VeloX;
[AssetPreview( "tire" )]
class TirePresetPreview : AssetPreview
{
public override bool IsAnimatedPreview => false;
[Range( 0.01f, 1 )] private float Zoom { get; set; } = 1;
public override Widget CreateToolbar()
{
var info = new IconButton( "settings" );
info.Layout = Layout.Row();
info.MinimumSize = 16;
info.MouseLeftPress = () => OpenSettings( info );
return info;
}
public void OpenSettings( Widget parent )
{
var popup = new PopupWidget( parent )
{
IsPopup = true,
Layout = Layout.Column()
};
popup.Layout.Margin = 16;
var ps = new ControlSheet();
ps.AddProperty( this, x => x.Zoom );
popup.Layout.Add( ps );
popup.MaximumWidth = 300;
popup.Show();
popup.Position = parent.ScreenRect.TopRight - popup.Size;
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]
public static void ClassInitialize( TestContext context )
{
Sandbox.Application.InitUnitTest();
Sandbox.Application.InitUnitTest<TestInit>( false, false );
}
}