Audio/GameAudio.cs

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.

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