Player/TyreScreechAudio.cs

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.

NetworkingFile Access
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;
	}
}