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