Component that manages continuous music and ambience beds and crossfades between a calm and in-run music layer. It keeps both music layers playing, adjusts their volumes based on game state and player settings, and retriggers looping beds when they finish.
namespace Coilgarden;
/// <summary>
/// Keeps the music and ambient beds running, and crossfades between the two music layers.
/// <para>
/// <b>Looping.</b> s&box has no "loop this SoundEvent" flag, so a bed is looped by
/// re-triggering it as it finishes. The music is written as a whole number of bars with its
/// partials at whole cycle counts across the buffer, so the end meets the start exactly and a
/// re-trigger is indistinguishable from a looping asset - see <c>Tools/generate_audio.py</c>,
/// which asserts the seam is quiet before writing the file.
/// </para>
/// <para>
/// <b>Both layers always play.</b> They are mixed, never switched. Starting a track is audible
/// and stopping one mid-phrase clicks, so both run continuously from the moment the game loads
/// and only their volumes move. Because they share the same harmony, any mix of the two is
/// consonant and a transition never sounds like one.
/// </para>
/// </summary>
public sealed class MusicDirector : Component
{
[Property] public GameSession Session { get; set; }
/// <summary>Turn the beds off entirely, to check the game still reads on effects alone.</summary>
[Property] public bool Enable { get; set; } = true;
/// <summary>
/// Live off the player's settings rather than an authored <c>[Property]</c>, for the same
/// reason as <see cref="GameAudio.SfxVolume"/>: one slider, one meaning. Falls back to the
/// designed default in edit mode, where there is no session to ask.
/// </summary>
public float MusicVolume => Session?.Settings?.MusicVolume ?? GameConfig.MusicVolume;
public float AmbienceVolume => Session?.Settings?.AmbienceVolume ?? GameConfig.AmbienceVolume;
private float MasterVolume => Session?.Settings?.MasterVolume ?? GameConfig.MasterVolume;
private SoundHandle calm;
private SoundHandle play;
private SoundHandle ambience;
/// <summary>How far towards the in-run bed the mix currently is, 0 to 1.</summary>
private float blend;
protected override void OnEnabled()
{
Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();
blend = 0f;
}
protected override void OnDisabled() => StopAll();
protected override void OnUpdate()
{
if ( !Enable )
{
StopAll();
return;
}
// The in-run bed is wanted while a run is actually advancing. A pause drifts back to the
// calm layer, which is a more pleasant way to signal "stopped" than ducking the volume.
var wantPlay = Session is not null && Session.State == GameState.Playing;
var target = wantPlay ? 1f : 0f;
blend = Approach( blend, target, GameConfig.MusicCrossfade );
Keep( ref calm, GameSounds.MusicCalm, MusicVolume * (1f - blend) );
Keep( ref play, GameSounds.MusicPlay, MusicVolume * blend );
Keep( ref ambience, GameSounds.Ambience, AmbienceVolume );
}
/// <summary>
/// Moves towards a target at a rate set by a crossfade duration, rather than by a lerp
/// factor. Framerate-independent, and the duration is a number a designer can reason about.
/// </summary>
private static float Approach( float current, float target, float duration )
{
if ( duration <= 0f ) return target;
var step = Time.Delta / duration;
return current < target
? MathF.Min( current + step, target )
: MathF.Max( current - step, target );
}
/// <summary>
/// Starts a bed if it is not playing, restarts it when it finishes, and keeps its volume up
/// to date.
/// <para>
/// A bed whose volume has reached zero is left playing rather than stopped. Stopping and
/// restarting it would put an audible seam at exactly the moment it starts fading back in,
/// and a silent voice costs nothing.
/// </para>
/// </summary>
private void Keep( ref SoundHandle handle, string sound, float volume )
{
var level = volume * MasterVolume;
if ( handle is null || handle.IsStopped || handle.Finished )
{
handle = Sound.Play( sound );
if ( handle is null ) return;
}
handle.Volume = MathF.Max( 0f, level );
}
private void StopAll()
{
calm?.Stop();
play?.Stop();
ambience?.Stop();
calm = null;
play = null;
ambience = null;
}
/// <summary>The live mix, for the debug readout.</summary>
public float Blend => blend;
public bool CalmPlaying => calm is not null && !calm.IsStopped;
public bool PlayPlaying => play is not null && !play.IsStopped;
public bool AmbiencePlaying => ambience is not null && !ambience.IsStopped;
}