Audio/MapAmbience.cs

Static utility that manages a map-wide ambient sound bed. It tracks the current cue, fades volume in and out over FadeSeconds, starts/stops the SoundHandle as needed, and exposes a console command to mute or report status.

using Sandbox;
using System;

namespace NZombies;

/// <summary>
/// THE MAP'S AMBIENCE — one quiet bed under the whole game, always there (`Gameplay.AmbientBed`): *"some ambience sounds very
/// lightly but always there so it does not feel so empty"* (2026-09-28). Basalt's is its own (`NZSound.BasaltAmbience`, made by
/// `Tools/basalt_ambience.py`); a map that names none has none.
///
/// ⛔ ITS OWN CHANNEL, NOT `NZMusic`. Music is one track at a time — the lobby's, a special round's loop — and the bed has to sit
/// under both, so it keeps a handle of its own. It restarts itself when it ends (there is no loop flag), as the music does, so a
/// bed made for it loops without a seam.
///
/// ⚠️ EACH MACHINE, FOR ITSELF: a 2D bed, wanted or not by what this machine shows (`LobbyMenu.OnUpdate` asks every frame). It
/// fades in and out over <see cref="FadeSeconds"/> rather than cutting, and plays at its sound event's own volume — the event is
/// where "very lightly" lives.
/// </summary>
public static class MapAmbience
{
	static SoundHandle _handle;
	static string _cue;
	static float _level;          // 0..1, eased toward what is wanted
	static float _eventVolume;    // the handle's volume as the event set it, which every frame scales from

	/// <summary>How long it takes to come up, and to go.</summary>
	public const float FadeSeconds = 3f;

	/// <summary>`nz_ambience 0` — off for this session, whatever the map wants.</summary>
	public static bool Muted { get; set; }

	/// <summary>Every frame: the cue this moment wants, or null for none.</summary>
	public static void Tick( string wanted )
	{
		if ( Muted ) wanted = null;
		var target = string.IsNullOrWhiteSpace( wanted ) ? 0f : 1f;

		// ⚠️ ANOTHER BED — a map with a different one: the old goes at once, the new fades up
		if ( target > 0f && _cue != wanted )
		{
			Stop();
			_cue = wanted;
		}

		var step = RealTime.Delta / FadeSeconds;
		_level = target > _level ? MathF.Min( target, _level + step ) : MathF.Max( target, _level - step );

		if ( _level <= 0f )
		{
			if ( target <= 0f && _cue is not null ) Stop();
			return;
		}

		if ( !_handle.IsValid() || !_handle.IsPlaying )
		{
			if ( !NZSound.Enabled || !NZSound.Exists( _cue ) ) return;

			_handle = Sound.Play( _cue );
			if ( !_handle.IsValid() ) return;
			_eventVolume = _handle.Volume;
		}

		_handle.Volume = _eventVolume * NZSound.VolumeOf( _cue ) * _level;
	}

	/// <summary>Silent, now.</summary>
	public static void Stop()
	{
		if ( _handle.IsValid() ) _handle.Stop();
		_handle = default;
		_cue = null;
		_level = 0f;
	}

	/// <summary>`nz_ambience [0|1]` — the map's bed muted or not for this session; bare, what is playing.</summary>
	[ConCmd( "nz_ambience" )]
	public static void Cmd( int on = -1 )
	{
		if ( on >= 0 ) Muted = on == 0;

		var named = ActiveConfig.Current?.Gameplay?.AmbientBed;
		Log.Info( $"[nz-audio] ambience: {(Muted ? "MUTED" : "on")} · this map's bed {(string.IsNullOrWhiteSpace( named ) ? "(none)" : named)}"
			+ $" · playing {(_handle.IsValid() && _handle.IsPlaying ? $"'{_cue}' at {_level:0.00}" : "nothing")}" );
	}
}