Component that turns game events into sound effects. It observes the GameSession and SnakeGame run to play start/restart/paused/gameover/turn/apple eat/spawn and UI sounds, enforces per-sound minimum gaps and applies volume/pitch adjustments and throttling.
namespace Coilgarden;
/// <summary>
/// Turns game events into sounds. The one place that calls <see cref="Sound"/> for effects.
/// <para>
/// It is the audio counterpart of <see cref="Juice"/> and works the same way: it watches the run
/// rather than subscribing to it, so no other component has to know audio exists and there is no
/// event lifecycle to get wrong. <see cref="Juice"/> handles what you see, this handles what you
/// hear, and neither knows about the other.
/// </para>
/// <para>
/// Three rules keep repeated sounds from becoming irritating, which is the whole difficulty with
/// audio in a game played in short bursts over and over:
/// </para>
/// <list type="number">
/// <item><b>Variation.</b> The apple has three recorded variants and a pitch range on top, so it
/// is never twice the same. That is set on the SoundEvent, not here.</item>
/// <item><b>Progression.</b> The apple's pitch rises across a run, capped, then resets. Free
/// feedback on how far along you are.</item>
/// <item><b>Gaps.</b> A minimum interval per sound, so nothing can machine-gun.</item>
/// </list>
/// </summary>
public sealed class GameAudio : Component
{
[Property] public GameSession Session { get; set; }
/// <summary>Play a quiet tick when the snake changes direction.</summary>
[Property] public bool TurnSounds { get; set; } = true;
/// <summary>
/// The live effects volume, straight off the player's settings. Not an authored
/// <c>[Property]</c>: a component-level override sitting on top of a slider the player
/// already set is exactly the kind of second knob that quietly undoes their choice.
/// Falls back to the designed default when there is no session to ask, which is only
/// true in edit mode.
/// </summary>
public float SfxVolume => Session?.Settings?.SfxVolume ?? GameConfig.SfxVolume;
private float MasterVolume => Session?.Settings?.MasterVolume ?? GameConfig.MasterVolume;
private readonly SoundThrottle throttle = new();
private int seenTick = -1;
private Direction seenDirection;
private GameState seenState = GameState.MainMenu;
private bool seenGameOver;
private bool announcedBest;
/// <summary>
/// Whether the first frame's sync has happened.
/// <para>
/// Without it the very first update compares against default field values - tick -1 and
/// <see cref="Direction.Up"/> - decides the snake has just turned, and plays a turn sound
/// before the player has touched anything. The first observation has to establish a baseline,
/// not fire events.
/// </para>
/// </summary>
private bool synced;
protected override void OnEnabled()
{
Current = this;
Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();
// Loaded up front so the first apple does not pay for a disk read mid-run.
foreach ( var sound in Preloaded ) Sound.Preload( sound );
}
private static readonly string[] Preloaded =
{
GameSounds.AppleEat, GameSounds.AppleSpawn, GameSounds.Turn,
GameSounds.GameStart, GameSounds.GameOver, GameSounds.Restart,
GameSounds.HighScore, GameSounds.ArenaFilled,
GameSounds.UiHover, GameSounds.UiClick
};
protected override void OnUpdate()
{
var run = Session?.Run;
if ( run is null ) return;
if ( !synced )
{
synced = true;
seenTick = run.Ticks;
seenDirection = run.Snake.Direction;
seenState = Session.State;
return;
}
WatchState();
WatchTicks( run );
}
// ------------------------------------------------------------------ observation
/// <summary>
/// Reacts to the state machine: starting, restarting, and ending a run.
/// </summary>
private void WatchState()
{
var state = Session.State;
if ( state == seenState ) return;
var previous = seenState;
seenState = state;
switch ( state )
{
case GameState.Playing when previous == GameState.MainMenu:
Play( GameSounds.GameStart );
break;
case GameState.Playing when previous == GameState.GameOver:
// A restart from a finished run gets its own short breath rather than the start
// fanfare: it happens constantly, and a fanfare on every retry would grate within
// a dozen attempts.
Play( GameSounds.Restart );
break;
// Pausing and resuming were silent, which made the one state change the player asks
// for directly the only one the game did not answer. The click is reused rather than
// synthesised anew, pitched down to close and up to open - the same gesture in both
// directions, which is what stops it reading as two unrelated noises.
case GameState.Paused:
Play( GameSounds.UiClick, 0f, PausePitch );
break;
case GameState.Playing when previous == GameState.Paused:
Play( GameSounds.UiClick, 0f, ResumePitch );
break;
case GameState.GameOver:
break;
}
if ( state != GameState.GameOver )
{
seenGameOver = false;
announcedBest = false;
}
}
/// <summary>
/// Reacts to what happened on a tick, and to the run ending.
/// <para>
/// Death is taken off the state rather than the step, so it still fires if a frame observed
/// several ticks at once.
/// </para>
/// </summary>
private void WatchTicks( SnakeGame run )
{
// A restart winds the counter back; resyncing stops that being mistaken for a tick.
if ( run.Ticks < seenTick )
{
seenTick = run.Ticks;
seenDirection = run.Snake.Direction;
throttle.Clear();
return;
}
if ( run.Ticks != seenTick )
{
seenTick = run.Ticks;
OnTick( run, Session.LastStep );
}
if ( Session.State != GameState.GameOver || seenGameOver ) return;
seenGameOver = true;
OnRunEnded( run );
}
private void OnTick( SnakeGame run, StepResult step )
{
if ( step.Grew )
{
// Rises across the run and resets with it. Capped so it never gets shrill.
var progress = MathF.Min( 1f, run.ApplesEaten / (float)GameConfig.ApplePitchRiseOver );
var semitones = progress * GameConfig.ApplePitchRise;
Play( GameSounds.AppleEat, GameConfig.AppleSoundGap, Semitones( semitones ) );
// The new apple's own arrival note, quiet and slightly behind the bite so the two
// read as one gesture rather than as two events.
Play( GameSounds.AppleSpawn, GameConfig.AppleSoundGap );
return;
}
if ( !TurnSounds || step.IsTerminal ) return;
if ( run.Snake.Direction == seenDirection ) return;
seenDirection = run.Snake.Direction;
Play( GameSounds.Turn, GameConfig.TurnSoundGap );
}
private void OnRunEnded( SnakeGame run )
{
if ( run.EndedBy == StepOutcome.FilledArena )
{
Play( GameSounds.ArenaFilled );
return;
}
Play( GameSounds.GameOver );
// The record sting lands over the tail of the game-over note rather than replacing it -
// the player needs to hear both that the run ended and that it was their best.
if ( !Session.IsNewBest || announcedBest ) return;
announcedBest = true;
Play( GameSounds.HighScore );
}
// ------------------------------------------------------------------ playing
/// <summary>A semitone offset as a pitch multiplier.</summary>
private static float Semitones( float offset ) => MathF.Pow( 2f, offset / 12f );
/// <summary>
/// Pause and resume, as the click a fourth below and a fourth above. Both are scale degrees
/// rather than arbitrary ratios, so they stay inside the game's pentatonic tuning even when
/// one lands on top of a music bed.
/// </summary>
private static readonly float PausePitch = Semitones( -5f );
private static readonly float ResumePitch = Semitones( 5f );
/// <summary>The last sound that actually played, and how many have. For the debug readout.</summary>
public string LastPlayed { get; private set; } = "-";
public int PlayCount { get; private set; }
/// <summary>
/// How many plays were refused because a handle came back null - which is what a missing or
/// uncompiled <c>.vsnd</c> looks like from here. A non-zero count is the signal that an asset
/// did not resolve, and it is otherwise completely silent in every sense.
/// </summary>
public int FailedCount { get; private set; }
/// <summary>
/// Plays a sound, subject to its minimum gap. Public so the UI can reach the hover and click
/// sounds - which is the one case where the trigger is not a game event.
/// </summary>
public void Play( string sound, float minimumGap = 0f, float pitch = 1f )
{
if ( string.IsNullOrEmpty( sound ) ) return;
var level = SfxVolume * MasterVolume;
if ( level <= 0f ) return;
if ( !throttle.Allow( sound, Time.Now, minimumGap ) ) return;
var handle = Sound.Play( sound );
if ( handle is null )
{
FailedCount++;
return;
}
handle.Volume *= level;
handle.Pitch *= pitch;
LastPlayed = sound;
PlayCount++;
}
/// <summary>
/// The live instance, so anything without a component reference can reach the audio - the
/// UI panels in particular, which is how <see cref="GameSounds.UiHover"/> and
/// <see cref="GameSounds.UiClick"/> will be triggered once there are controls to trigger them.
/// <para>
/// Set from <see cref="OnEnabled"/> and cleared on disable, because <c>OnStart</c> runs once
/// per lifetime and a component that is disabled and re-enabled would otherwise leave this
/// null forever.
/// </para>
/// </summary>
public static GameAudio Current { get; private set; }
protected override void OnDisabled()
{
if ( Current == this ) Current = null;
}
}