Game/Audio.cs
namespace Monolith;

/// <summary>
/// Every sound in the game, in one place, and every call guarded.
///
/// **Why this exists.** The Devil Daggers research (GOALS section 6d) found that its spatial
/// audio is a MECHANIC rather than polish: it is how you track what is behind you. Every hazard
/// we have was silent, which meant all threat information competed for the same screen. This is
/// the layer that fixes that.
///
/// **Why paths and not SoundEvent properties.** These are engine sound events shipped in
/// `core/sounds`, addressed by path. Nothing here is authored by us and nothing needs importing,
/// which is the whole reason this could be built at all: no usable sounds ship in `addons/base`,
/// and the procedural route (`SoundStream`) has no documented way to submit samples.
///
/// **Why everything is wrapped.** A sound path that stops resolving after an engine update
/// should cost us a noise, never a frame. Audio is the one subsystem where silent failure is
/// the correct failure.
/// </summary>
public static class Audio
{
	// ---------------------------------------------------------------- the palette
	//
	// Chosen from the 63 addressable sound events in core/sounds. The constraint shaped the
	// design: there is no "sci-fi searchlight" in the engine, so the Spotter speaks with an
	// electrical buzz, and rock breaking speaks with concrete bullet impacts. Both are close
	// enough that a player reads them correctly, which is the only test that matters.

	public const string RockHit = "sounds/impacts/bullets/impact-bullet-concrete.sound";
	public const string MetalHit = "sounds/impacts/bullets/impact-bullet-metal.sound";
	public const string GenericHit = "sounds/impacts/bullets/impact-bullet-generic.sound";
	public const string Explosion = "sounds/effects/explosion/explosion_small.sound";

	/// <summary>Jump and land, reused as the bunny-hop timing cue. See PlayerMovement.</summary>
	public const string Jump = "sounds/footsteps/footstep-concrete-jump.sound";
	public const string Land = "sounds/footsteps/footstep-concrete-land.sound";

	/// <summary>A sting, used where something needs to be noticed rather than located.</summary>
	public const string Alert = "sounds/ambience/stings/sting-crow.sound";

	public const string UiPress = "sounds/kenney/ui/ui.button.press.sound";
	public const string UiDeny = "sounds/kenney/ui/ui.button.deny.sound";

	// The rest of the Kenney set. Every menu press used to be the same click at a different
	// pitch, which is a way of having sound without having feedback: nothing told you whether
	// you had moved, chosen, bought or been refused. These are all distinct events, so they get
	// distinct sounds.
	public const string UiHover = "sounds/kenney/ui/ui.button.over.sound";
	public const string UiForward = "sounds/kenney/ui/ui.navigate.forward.sound";
	public const string UiBackward = "sounds/kenney/ui/ui.navigate.back.sound";
	public const string UiNavDeny = "sounds/kenney/ui/ui.navigate.deny.sound";
	public const string UiReward = "sounds/kenney/ui/ui.upvote.sound";
	public const string UiLoss = "sounds/kenney/ui/ui.downvote.sound";
	public const string UiOpen = "sounds/kenney/ui/ui.popup.message.open.sound";
	public const string UiClose = "sounds/kenney/ui/ui.popup.message.close.sound";
	public const string UiMark = "sounds/kenney/ui/ui.favourite.sound";

	/// <summary>Water, used well away from its label: it is the only soft non-percussive hit.</summary>
	public const string Soft = "sounds/water/water_bullet_impact.sound";

	public const string Splash = "sounds/water/water_splash_medium.sound";

	/// <summary>Glass, for anything that should read as shattering rather than breaking.</summary>
	public const string GlassHit = "sounds/impacts/bullets/impact-bullet-glass.sound";

	/// <summary>Melee concrete: heavier and duller than the bullet impact, for big collapses.</summary>
	public const string HeavyRock = "sounds/impacts/melee/impact-melee-concrete.sound";

	public const string HeavyMetal = "sounds/impacts/melee/impact-melee-metal.sound";

	/// <summary>Sand, the closest thing to a crumble in the set.</summary>
	public const string Crumble = "sounds/impacts/bullets/impact-bullet-sand.sound";

	/// <summary>A fire loop, used as the charge tell rather than as fire.</summary>
	public const string Burn = "sounds/effects/fire/fire_burn_loop01.sound";

	// The three remaining stings. Used sparingly and only for things that happen a handful of
	// times in a run, because a sting that plays often stops being a sting.
	public const string StingLow = "sounds/ambience/stings/sting-coyote-sting.sound";
	public const string StingHigh = "sounds/ambience/stings/sting-gulls.sound";
	public const string StingShort = "sounds/ambience/stings/sting-frog.sound";

	// ---------------------------------------------------------------- named events
	//
	// Call sites should say what HAPPENED, not which file to play. Everything below is a moment
	// in the game rather than a sound, which is what lets the palette above be re-cast later
	// without touching twenty call sites, and what stopped the UI from being one click forever.

	/// <summary>An upgrade was bought.</summary>
	public static void Purchase() => PlayUi( UiReward, 0.55f, 1f );

	/// <summary>An upgrade could not be afforded.</summary>
	public static void Refused() => PlayUi( UiDeny, 0.7f, 1f );

	/// <summary>The highlight moved. Quiet on purpose: it fires on every flick of the stick.</summary>
	public static void Navigate() => PlayUi( UiHover, 0.3f, Vary( 1.2f, 0.06f ) );

	/// <summary>A sheet or panel opened.</summary>
	public static void Open() => PlayUi( UiOpen, 0.6f, 1f );

	/// <summary>A sheet or panel closed.</summary>
	public static void Close() => PlayUi( UiClose, 0.6f, 1f );

	/// <summary>A page or tab moved forward.</summary>
	public static void Forward() => PlayUi( UiForward, 0.5f, 1f );

	/// <summary>A page or tab moved back.</summary>
	public static void Back() => PlayUi( UiBackward, 0.5f, 1f );

	/// <summary>A Mark was earned. Rare, so it gets to be loud.</summary>
	public static void MarkEarned()
	{
		PlayUi( UiMark, 0.8f, 1f );
		PlayUi( StingHigh, 0.35f, 1.3f );
	}

	/// <summary>A stage was cleared.</summary>
	public static void StageClear( Vector3 at )
	{
		Play( HeavyRock, at, 0.9f, 0.6f );
		PlayUi( UiReward, 0.7f, 0.85f );
	}

	/// <summary>A stage was lost and reset.</summary>
	public static void StageLost() => PlayUi( UiLoss, 0.8f, 0.9f );

	/// <summary>A collapse: the run ends and the cores are banked.</summary>
	public static void Collapse()
	{
		PlayUi( StingLow, 0.7f, 0.7f );
		PlayUi( UiReward, 0.8f, 0.6f );
	}

	// ---------------------------------------------------------------- playback

	/// <summary>
	/// Plays a one-shot at a world position. Pitch and volume are applied to the handle, which
	/// is what lets one sound file cover a range of events: the same concrete impact at 1.4
	/// pitch reads as a small chip and at 0.7 as a heavy break.
	/// </summary>
	/// <remarks>
	/// Returns void, and there is deliberately no Stop, no null check and no IsValid here.
	/// `SoundHandle` documents `Volume`, `Pitch` and `Position` but NO `Stop` method, and does
	/// not say whether it is a class or a struct, so `return null` and `handle.IsValid()` are
	/// both guesses. Everything we play is fire-and-forget, so the whole question can be
	/// avoided: assign inside the try and let a null handle be caught like any other fault.
	/// </remarks>
	public static void Play( string path, Vector3 position,
		float volume = 1f, float pitch = 1f )
	{
		// Checked before playing rather than by setting volume to zero, so a muted game is not
		// still allocating a voice for every bolt that hits rock. This and PlayUi are the only
		// two ways sound leaves this game, which is what makes one mute switch possible at all.
		if ( AudioSettings.EffectsMuted )
			return;

		try
		{
			var handle = Sound.Play( path, position );

			handle.Volume = volume * AudioSettings.EffectsVolume;
			handle.Pitch = pitch;
		}
		catch ( Exception )
		{
		}
	}

	/// <summary>A one-shot with no position, for UI.</summary>
	public static void PlayUi( string path, float volume = 1f, float pitch = 1f )
	{
		if ( AudioSettings.EffectsMuted )
			return;

		try
		{
			var handle = Sound.Play( path );

			handle.Volume = volume * AudioSettings.EffectsVolume;
			handle.Pitch = pitch;
		}
		catch ( Exception )
		{
		}
	}

	/// <summary>
	/// Randomised pitch around a centre. Repetition is what makes a sound annoying, and a mining
	/// game fires many shots per second, so nothing that plays often should ever play identically
	/// twice.
	/// </summary>
	public static float Vary( float centre, float spread = 0.12f )
		=> centre * Game.Random.Float( 1f - spread, 1f + spread );
}