first commit

This commit is contained in:
Valera
2025-06-11 20:19:35 +07:00
commit 35790cbd34
107 changed files with 3400 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
using Sandbox;
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace VeloX.Audio;
[JsonConverter( typeof( ControllerConverter ) )]
public sealed class Controller
{
public enum InputTypes
{
Throttle,
RpmFraction,
}
public enum OutputTypes
{
Volume,
Pitch,
}
public required InputTypes InputParameter { get; set; }
[Property] public ValueRange InputRange { get; set; }
public required OutputTypes OutputParameter { get; set; }
public ValueRange OutputRange { get; set; }
}
public record struct ValueRange
{
public ValueRange( float inputMin, float inputMax )
{
Min = inputMin;
Max = inputMax;
}
[Range( 0, 1 )] public float Min { get; set; }
[Range( 0, 1 )] public float Max { get; set; }
public readonly float Remap( float value, float newMin, float newMax )
{
var normalized = (value - Min) / (Max - Min);
return newMin + normalized * (newMax - newMin);
}
};
public sealed class ControllerConverter : JsonConverter<Controller>
{
public override Controller Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
{
if ( reader.TokenType != JsonTokenType.StartArray )
throw new JsonException( "Expected array for Controller" );
reader.Read();
string inputParam = reader.GetString() ?? string.Empty;
reader.Read();
float inputMin = reader.GetSingle();
reader.Read();
float inputMax = reader.GetSingle();
reader.Read();
string outputParam = reader.GetString() ?? string.Empty;
reader.Read();
float outputMin = reader.GetSingle();
reader.Read();
float outputMax = reader.GetSingle();
reader.Read(); // End of array
return new Controller
{
InputParameter = Enum.Parse<Controller.InputTypes>( inputParam, true ),
InputRange = new ValueRange( inputMin, inputMax ),
OutputParameter = Enum.Parse<Controller.OutputTypes>( outputParam, true ),
OutputRange = new ValueRange( outputMin, outputMax )
};
}
public override void Write( Utf8JsonWriter writer, Controller value, JsonSerializerOptions options )
{
writer.WriteStartArray();
writer.WriteStringValue( value.InputParameter.ToString() );
writer.WriteNumberValue( value.InputRange.Min );
writer.WriteNumberValue( value.InputRange.Max );
writer.WriteStringValue( value.OutputParameter.ToString() );
writer.WriteNumberValue( value.OutputRange.Min );
writer.WriteNumberValue( value.OutputRange.Max );
writer.WriteEndArray();
}
}

View File

@@ -0,0 +1,35 @@
using Sandbox;
using System.Collections.Generic;
using VeloX.Audio;
namespace VeloX;
[GameResource( "Engine Stream", "engstr", "Engine Sound", Category = "VeloX", Icon = "time_to_leave" )]
public sealed class EngineStream : GameResource
{
public sealed class Layer
{
[Property,
Title( "Sound File" ),
Description( "Sound asset for this layer" )]
public SoundFile AudioPath { get; set; }
[Property, Title( "Use Redline Effect" ),
Description( "Enable RPM-based vibration effect" )]
public bool UseRedline { get; set; } = true;
[Property, Title( "Controllers" ),
Description( "Audio parameter controllers" )]
public List<Controller> Controllers { get; set; }
[Property, Title( "Is Muted" )]
public bool IsMuted { get; set; }
}
[Property, Title( "Stream Parameters" ),
Description( "Global engine sound parameters" )]
public StreamParameters Parameters { get; set; }
[Property, Title( "Sound Layers" ),
Description( "Individual sound layers" )]
public Dictionary<string, Layer> Layers { get; set; }
}

View File

@@ -0,0 +1,119 @@
using Sandbox;
using Sandbox.Audio;
using System;
using System.Collections.Generic;
using System.IO;
using VeloX.Audio;
using static VeloX.EngineStream;
namespace VeloX;
public class EngineStreamPlayer( EngineStream stream ) : IDisposable
{
private static readonly Mixer EngineMixer = Mixer.FindMixerByName( "Car Engine" );
public EngineStream Stream { get; set; } = stream;
public EngineState EngineState { get; set; }
public bool EngineSoundPaused => EngineState != EngineState.Running;
public float Throttle { get; set; }
public bool IsRedlining { get; set; }
public float RPMPercent { get; set; }
private float _wobbleTime;
public readonly Dictionary<Layer, SoundHandle> EngineSounds = [];
public void Update( float deltaTime, Vector3 position, bool isLocal = false )
{
var globalPitch = 1.0f;
// Gear wobble effect
if ( _wobbleTime > 0 )
{
_wobbleTime -= deltaTime * (0.1f + Throttle);
globalPitch += MathF.Cos( _wobbleTime * Stream.Parameters.WobbleFrequency ) * _wobbleTime * (1 - _wobbleTime) * Stream.Parameters.WobbleStrength;
}
globalPitch *= Stream.Parameters.Pitch;
// Redline effect
var redlineVolume = 1.0f;
if ( IsRedlining )
{
redlineVolume = 1 - Stream.Parameters.RedlineStrength +
MathF.Cos( RealTime.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 || layer.IsMuted) )
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
{
Controller.InputTypes.Throttle => Throttle,
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 Controller.OutputTypes.Volume:
layerVolume *= outputValue;
break;
case 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 || layer.IsMuted;
channel.TargetMixer = EngineMixer;
}
}
public void Dispose()
{
foreach ( var item in EngineSounds )
{
item.Value?.Stop();
item.Value?.Dispose();
}
}
}

View File

@@ -0,0 +1,24 @@
using Sandbox;
namespace VeloX;
public sealed class StreamParameters
{
[Property, Range( 0.1f, 2.0f ), Description( "Base pitch multiplier" )]
public float Pitch { get; set; } = 1.0f;
[Property, Range( 0.0f, 2.0f ), Description( "Base volume level" )]
public float Volume { get; set; } = 1.0f;
[Property, Range( 1f, 100f ), Description( "Vibration frequency at max RPM" )]
public float RedlineFrequency { get; set; } = 55.0f;
[Property, Range( 0.0f, 1.0f ), Description( "Vibration intensity at max RPM" )]
public float RedlineStrength { get; set; } = 0.2f;
[Property, Range( 1f, 50f ), Description( "Idle vibration frequency" )]
public float WobbleFrequency { get; set; } = 25.0f;
[Property, Range( 0.0f, 0.5f ), Description( "Idle vibration intensity" )]
public float WobbleStrength { get; set; } = 0.13f;
}

View File

@@ -0,0 +1,78 @@
using Sandbox;
namespace VeloX;
public static class PhysicsExtensions
{
/// <summary>
/// Calculates the linear and angular velocities on the 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 )
{
if ( !physObj.IsValid() || !physObj.MotionEnabled )
return (Vector3.Zero, Vector3.Zero);
Vector3 linearVelocity = impulse / physObj.Mass;
Vector3 centerOfMass = physObj.MassCenter;
Vector3 relativePosition = position - centerOfMass;
Vector3 torque = relativePosition.Cross( impulse );
Rotation bodyRotation = physObj.Transform.Rotation;
Vector3 localTorque = bodyRotation.Inverse * torque;
Vector3 localInverseInertia = physObj.Inertia.Inverse;
Vector3 localAngularVelocity = new(
localTorque.x * localInverseInertia.x,
localTorque.y * localInverseInertia.y,
localTorque.z * localInverseInertia.z );
return (linearVelocity, localAngularVelocity);
}
/// <summary>
/// Calculates the linear and angular impulses 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 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,
Vector3 impulse,
Vector3 position )
{
if ( !physObj.IsValid() || !physObj.MotionEnabled )
{
return (Vector3.Zero, Vector3.Zero);
}
// 1. Linear impulse is the same as the input impulse (conservation of momentum)
Vector3 linearImpulse = impulse;
// 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);
}
}