Music/MusicManager.cs

Music manager component that plays background tracks, shuffles gameplay tracks with a nonrepeating "bag" shuffle, crossfades between tracks, and supports ducking (temporarily lowering) and timed unducking. Exposes a static Current for other code to Duck/Unduck/Skip.

NetworkingFile AccessHttp Calls
using Sandbox.Audio;
using System;

namespace Breakout;

/// <summary>
/// Plays the background music. It shuffles through the gameplay tracks, crossfades from one to the
/// next, and can "duck" (briefly lower) the volume so an important sound effect can cut through.
/// Other code reaches it through the static Current instance.
/// </summary>
public sealed class MusicManager : Component
{
	[Property, Group( "Tracks" )]
	public List<MusicTrack> GameplayTracks { get; set; } = new();

	[Property, Group( "Volume" )]
	public float BaseVolume { get; set; } = 0.5f;

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

	[Property, Group( "Volume" )]
	public float InitialFadeIn { get; set; } = 3f;

	[Property, Group( "Playback" )]
	public float CrossfadeDuration { get; set; } = 2f;

	[Property, Group( "Playback" )]
	public bool AutoAdvance { get; set; } = true;

	/// <summary>
	/// The active music manager, so any code can duck or skip the music.
	/// </summary>
	public static MusicManager Current { get; private set; }

	private SoundHandle _current;
	private SoundHandle _outgoing;
	private float _crossfadeTimer;
	private bool _isCrossfading;

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

	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()
	{
		PlayNext();
		Unduck( InitialFadeIn );
	}

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

		StopAll();
	}

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

		if ( AutoAdvance && _current is not null && !_current.IsPlaying && !_isCrossfading )
			PlayNext();
	}

	/// <summary>
	/// Duck (lower) the music toward targetVolume over fadeDown seconds. Used so a loud moment,
	/// like a big combo or game over, can be heard clearly over the music.
	/// </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>
	/// Fades the music back up to full volume over 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 for a set number of seconds, then automatically fade the music back up.
	/// </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>
	/// Immediately crossfades to the next shuffled track.
	/// </summary>
	public static void Skip()
	{
		if ( Current == null ) return;
		Current.CrossfadeToNext();
	}

	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];
	}

	// Refill and shuffle the "bag" of track indexes. Using a bag means every track plays once, in
	// a random order, before any of them repeat, which feels better than picking purely at random.
	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;
		}
	}

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

		_outgoing = _current;
		_current = Sound.Play( track.Sound );

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

		_crossfadeTimer = 0f;
		_isCrossfading = true;
	}

	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 );
		}

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