Component that plays and controls a looping tyre screech sound for a Car. It starts the loop when enabled, updates position, volume and pitch each frame based on speed, drift slip and steering, and stops the sound when disabled or destroyed.
using System;
namespace Machines.Player;
/// <summary>
/// Looping tyre screech: volume ramps with skid intensity, pitch varies with slip angle.
/// </summary>
public sealed class TyreScreechAudio : Component
{
[RequireComponent]
public Car Car { get; private set; }
/// <summary>
/// Looping screech sound; volume controls audibility.
/// </summary>
[Property, Group( "Sound" )]
public SoundEvent ScreechSound { get; set; }
[Property, Group( "Sound" ), Range( 0f, 2f )]
public float MaxVolume { get; set; } = 0.2f;
[Property, Group( "Sound" ), Range( 0.3f, 1.5f )]
public float MinPitch { get; set; } = 0.6f;
[Property, Group( "Sound" ), Range( 0.8f, 3f )]
public float MaxPitch { get; set; } = 1.2f;
/// <summary>
/// How fast volume fades in/out per second.
/// </summary>
[Property, Group( "Sound" ), Range( 1f, 20f )]
public float FadeSpeed { get; set; } = 8f;
[Property, Group( "Threshold" )]
public float MinSpeed { get; set; } = 80f;
/// <summary>
/// How fast pitch changes per second (lower = smoother).
/// </summary>
[Property, Group( "Sound" ), Range( 0.5f, 10f )]
public float PitchSpeed { get; set; } = 2f;
private SoundHandle _sound;
private float _currentVolume;
private float _currentPitch;
protected override void OnEnabled()
{
EnsurePlaying();
}
protected override void OnUpdate()
{
if ( !Car.IsValid() || !Car.Movement.IsValid() || !Car.Drift.IsValid() )
return;
if ( ScreechSound == null )
return;
// Keep sound alive (volume 0 = silent but running).
EnsurePlaying();
float dt = Time.Delta;
float intensity = GetIntensity();
float targetVolume = intensity * MaxVolume;
_currentVolume = MathX.Approach( _currentVolume, targetVolume, FadeSpeed * dt );
// Update position/volume every frame.
_sound.Position = WorldPosition;
_sound.Volume = _currentVolume;
// Pitch driven by steering/slip, smoothed to avoid jumps.
float steerAmount = Car.Input != null ? MathF.Abs( Car.Input.Current.Steer ) : 0f;
float slipAmount = MathX.Clamp( MathF.Abs( Car.Drift.SlipAngle ) / 60f, 0f, 1f );
float pitchT = MathF.Max( steerAmount, slipAmount );
float targetPitch = MathX.Lerp( MinPitch, MaxPitch, pitchT );
_currentPitch = MathX.Approach( _currentPitch, targetPitch, PitchSpeed * dt );
_sound.Pitch = _currentPitch;
}
/// <summary>
/// Screech intensity 0-1; non-zero only while drifting after the hop.
/// </summary>
private float GetIntensity()
{
if ( !IsLocallyGrounded() )
return 0f;
float speed = Car.Movement.CurrentSpeed;
if ( speed < MinSpeed )
return 0f;
if ( !Car.Drift.IsDrifting || Car.Drift.IsHopping )
return 0f;
float slip = MathF.Abs( Car.Drift.SlipAngle );
if ( slip < 1f )
return 0.5f; // fallback on remote clients
return MathX.Clamp( slip / 45f, 0.3f, 1f );
}
private void EnsurePlaying()
{
if ( ScreechSound == null ) return;
if ( _sound is null || !_sound.IsPlaying )
{
_sound = Sound.Play( ScreechSound, WorldPosition );
_sound.Volume = 0f;
}
}
protected override void OnDisabled()
{
_sound?.Stop();
_currentVolume = 0f;
}
protected override void OnDestroy()
{
_sound?.Stop();
}
private bool IsLocallyGrounded()
{
var tr = Scene.Trace.Ray( WorldPosition + Vector3.Up * 10f, WorldPosition - Vector3.Up * 50f )
.WithoutTags( "player", "car" )
.Run();
return tr.Hit;
}
}