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