Systems/MusicManager.cs

MusicManager component that plays background music with shuffled tracks, crossfading, and ducking behavior. It manages playback state, a shuffle bag for gameplay tracks, responds to game state changes, and exposes static helpers to duck/unduck music and skip tracks.

NetworkingFile Access
using Machines.Events;
using Machines.GameModes;
using Sandbox.Audio;

namespace Machines.Systems;

/// <summary>
/// Background music with shuffled tracks, crossfading, and ducking. Reacts to game state changes.
/// </summary>
public sealed class MusicManager : Component, IGameStateChanged
{
	/// <summary>
	/// Tracks to play during gameplay (racing).
	/// </summary>
	[Property, Group( "Tracks" )]
	public List<MusicTrack> GameplayTracks { get; set; } = new();

	/// <summary>
	/// Base volume for music playback (0–1).
	/// </summary>
	[Property, Group( "Volume" )]
	public float BaseVolume { get; set; } = 0.5f;

	[Property]
	public MixerHandle TargetMixer { get; set; }

	/// <summary>
	/// Duration (seconds) to fade in the music when it first starts playing.
	/// </summary>
	[Property, Group( "Volume" )]
	public float InitialFadeIn { get; set; } = 3f;

	/// <summary>
	/// Duration (seconds) to crossfade when switching tracks.
	/// </summary>
	[Property, Group( "Playback" )]
	public float CrossfadeDuration { get; set; } = 2f;

	/// <summary>
	/// Auto-advance to the next track when the current one ends.
	/// </summary>
	[Property, Group( "Playback" )]
	public bool AutoAdvance { get; set; } = true;

	// Singleton
	public static MusicManager Current { get; private set; }

	// Playback state
	private SoundHandle _current;
	private SoundHandle _outgoing;
	private float _crossfadeTimer;
	private bool _isCrossfading;

	// Shuffle bag
	private readonly List<int> _gameplayBag = new();
	private int _gameplayIndex;

	// Duck state
	private float _duckMultiplier = 0f;
	private float _duckTarget = 1f;
	private float _duckFadeSpeed;
	private float _duckForTimer;
	private float _duckForUnduckSpeed;
	private bool _duckForActive;

	protected override void OnEnabled()
	{
		Current = this;
		RebuildBag( GameplayTracks, _gameplayBag, ref _gameplayIndex );
	}

	protected override void OnStart()
	{
		// Start music as soon as we load in. The initial WaitingForPlayers state
		// has no transition event, so kick it off here rather than via OnGameStateChanged.
		PlayNext();
		Unduck( InitialFadeIn );
	}

	protected override void OnDisabled()
	{
		if ( Current == this )
			Current = null;

		StopAll();
	}

	protected override void OnUpdate()
	{
		UpdateDuck();
		UpdateCrossfade();
		UpdateVolumes();

		// Auto-advance when current track ends
		if ( AutoAdvance && _current is not null && !_current.IsPlaying && !_isCrossfading )
			PlayNext();
	}

	/// <summary>
	/// Duck the music volume down to <paramref name="targetVolume"/> over <paramref name="fadeDown"/> seconds. Call <see cref="Unduck"/> to restore.
	/// </summary>
	public static void Duck( float targetVolume, float fadeDown = 0.3f )
	{
		if ( Current == null ) return;

		Current._duckTarget = targetVolume.Clamp( 0f, 1f );
		Current._duckFadeSpeed = fadeDown > 0.001f ? 1f / fadeDown : 100f;
	}

	/// <summary>
	/// Restore music volume back to normal over <paramref name="fadeUp"/> seconds.
	/// </summary>
	public static void Unduck( float fadeUp = 3f )
	{
		if ( Current == null ) return;

		Current._duckTarget = 1f;
		Current._duckFadeSpeed = fadeUp > 0.001f ? 1f / fadeUp : 100f;
	}

	/// <summary>
	/// Duck to <paramref name="targetVolume"/> for <paramref name="duration"/> seconds, then automatically restore over <paramref name="fadeUp"/> seconds.
	/// </summary>
	public static void DuckFor( float targetVolume, float duration, float fadeDown = 0.3f, float fadeUp = 3f )
	{
		Duck( targetVolume, fadeDown );

		if ( Current == null ) return;

		Current._duckForActive = true;
		Current._duckForTimer = duration;
		Current._duckForUnduckSpeed = fadeUp;
	}

	/// <summary>
	/// Skip to the next track (with crossfade).
	/// </summary>
	public static void Skip()
	{
		if ( Current == null ) return;
		Current.CrossfadeToNext();
	}

	public void OnGameStateChanged( GameModeState oldState, GameModeState newState )
	{
		switch ( newState )
		{
			case GameModeState.Playing:
				Unduck( fadeUp: 3f );
				break;

			case GameModeState.Podium:
				Duck( 0.3f, fadeDown: 1f );
				break;
		}
	}

	private MusicTrack PickNext()
	{
		if ( GameplayTracks.Count == 0 ) return default;

		if ( _gameplayBag.Count == 0 || _gameplayIndex >= _gameplayBag.Count )
			RebuildBag( GameplayTracks, _gameplayBag, ref _gameplayIndex );

		var trackIndex = _gameplayBag[_gameplayIndex];
		_gameplayIndex++;
		return GameplayTracks[trackIndex];
	}

	private static void RebuildBag( List<MusicTrack> tracks, List<int> bag, ref int index )
	{
		bag.Clear();
		for ( int i = 0; i < tracks.Count; i++ )
			bag.Add( i );

		// Fisher-Yates
		for ( int i = bag.Count - 1; i > 0; i-- )
		{
			var j = Game.Random.Int( 0, i );
			(bag[i], bag[j]) = (bag[j], bag[i]);
		}

		index = 0;
	}

	private void PlayNext()
	{
		var track = PickNext();
		if ( track.Sound is null ) return;

		_current = Sound.Play( track.Sound );
		if ( _current is not null )
		{
			_current.TargetMixer = TargetMixer.Get();
			_current.Volume = EffectiveVolume();
			_current.ListenLocal = true;
			_current.SpacialBlend = 0;
		}

		Scene.RunEvent<IMusicTrackChanged>( x => x.OnMusicTrackChanged( track ) );
	}

	private void CrossfadeToNext()
	{
		var track = PickNext();
		if ( track.Sound is null ) return;

		_outgoing = _current; // push current to outgoing
		_current = Sound.Play( track.Sound );

		if ( _current is not null )
		{
			_current.TargetMixer = TargetMixer.Get();
			_current.Volume = 0f;
		}

		_crossfadeTimer = 0f;
		_isCrossfading = true;

		Scene.RunEvent<IMusicTrackChanged>( x => x.OnMusicTrackChanged( track ) );
	}

	private void UpdateCrossfade()
	{
		if ( !_isCrossfading )
			return;

		_crossfadeTimer += Time.Delta;
		var t = CrossfadeDuration > 0.001f
			? (_crossfadeTimer / CrossfadeDuration).Clamp( 0f, 1f )
			: 1f;

		var effective = EffectiveVolume();

		if ( _current is not null )
			_current.Volume = effective * t;

		if ( _outgoing is not null )
			_outgoing.Volume = effective * (1f - t);

		if ( t >= 1f )
		{
			_outgoing?.Stop();
			_outgoing = null;
			_isCrossfading = false;
		}
	}

	private void UpdateDuck()
	{
		if ( MathF.Abs( _duckMultiplier - _duckTarget ) > 0.001f )
		{
			var direction = _duckTarget > _duckMultiplier ? 1f : -1f;
			_duckMultiplier += direction * _duckFadeSpeed * Time.Delta;
			_duckMultiplier = direction > 0f
				? MathF.Min( _duckMultiplier, _duckTarget )
				: MathF.Max( _duckMultiplier, _duckTarget );
		}

		// DuckFor: auto-restore after timer.
		if ( _duckForActive )
		{
			_duckForTimer -= Time.Delta;
			if ( _duckForTimer <= 0f )
			{
				_duckForActive = false;
				Unduck( _duckForUnduckSpeed );
			}
		}
	}

	private void UpdateVolumes()
	{
		if ( _isCrossfading ) return; // crossfade owns volumes

		var vol = EffectiveVolume();
		if ( _current is not null && _current.IsPlaying )
			_current.Volume = vol;
	}

	private float EffectiveVolume() => BaseVolume * _duckMultiplier;

	private void StopAll()
	{
		_current?.Stop();
		_outgoing?.Stop();
		_current = null;
		_outgoing = null;
	}
}