Audio/NZMusic.cs

Static music manager for NZombies. Starts, stops and keeps a single music SoundHandle alive by cue name, applies a master volume and per-cue volume, warns once if a cue asset is missing, and can restart ended tracks to implement looping via Tick().

File Access
using Sandbox;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// One music track at a time, started and stopped by name.
///
/// ⚠️ SEPARATE FROM NZSound ON PURPOSE. Every cue there is fire-and-forget — it
/// plays and nobody keeps the handle. Music is the opposite: exactly one may be
/// playing, it has to survive across frames, and something has to be able to stop
/// it. Mixing the two would put handle lifetime into a class whose whole point is
/// that callers do not manage handles.
///
/// ⚠️ THE TRACK ITSELF DOES NOT EXIST YET. There is no menu music in
/// Assets/sounds and none ships with the engine. This is inert until one is
/// added — see NZSound.MusicLobby.
/// </summary>
public static class NZMusic
{
	static SoundHandle _handle;

	/// <summary>What is playing, or null. Read by nz_music to report state.</summary>
	public static string Current { get; private set; }

	/// <summary>Master volume for music, independent of effects.</summary>
	public static float Volume { get; set; } = 0.55f;

	/// <summary>
	/// Start a track, or do nothing if it is already the one playing.
	///
	/// ⚠️ IDEMPOTENT BECAUSE THE CALLER IS A UI PANEL. The lobby drives this from
	/// its open/close state, which is evaluated every frame — a non-idempotent
	/// Play would stack a new voice per frame and the menu would roar.
	/// </summary>
	public static void Play( string cue )
	{
		if ( Current == cue && _handle.IsValid() && _handle.IsPlaying )
			return;

		Stop();

		if ( !NZSound.Enabled ) return;

		// Same existence check as PlayUi, and for the same reason: a missing cue
		// is silent rather than loud, so without this "no music" and "no asset"
		// look identical.
		if ( !NZSound.Exists( cue ) )
		{
			if ( _warned.Add( cue ) )
				Log.Warning( $"[nz-audio] no music asset for '{cue}'. "
					+ $"Add Assets/sounds/nz/{cue}.sound pointing at a .vsnd." );
			return;
		}

		_handle = Sound.Play( cue );
		Current = cue;

		// ⚠️ MASTER x PER-CUE, so nz_vol tunes music the same way it tunes every
		// other cue and nz_music_vol still rides over the top of whatever that
		// lands on. Assigned rather than multiplied because this runs once at
		// start — multiplying would compound on every restart of the track.
		if ( _handle.IsValid() )
			_handle.Volume = Volume * NZSound.VolumeOf( cue );

		Log.Info( $"[nz-audio] music '{cue}' started" );
	}

	/// <summary>
	/// Scale the playing track, 0 to 1, from its own level — the loading screen easing the lobby's music out as it goes to
	/// black (`LobbyMenu`, 2026-09-28). ⚠️ ASSIGNED FROM THE BASE, NEVER MULTIPLIED, so it can be asked every frame; `Play`
	/// starts every track back at full.
	/// </summary>
	public static void SetLevel( float k )
	{
		if ( !_handle.IsValid() || string.IsNullOrEmpty( Current ) ) return;
		_handle.Volume = Volume * NZSound.VolumeOf( Current ) * System.Math.Clamp( k, 0f, 1f );
	}

	public static void Stop()
	{
		if ( _handle.IsValid() )
			_handle.Stop();

		_handle = default;
		Current = null;
	}

	/// <summary>
	/// Keep the track going. Call every frame while it should be playing.
	///
	/// ⛔ LOOPING IS NOT A FLAG WE HAVE. `SoundEvent` exposes Volume, Pitch,
	/// Falloff, Distance and selection mode — read from the engine's own type list
	/// — but nothing for looping, so a track that ends simply ends. Restarting it
	/// here loops any asset without needing the .vsnd to be authored as a loop,
	/// which matters because the track will be dropped in by hand later.
	/// </summary>
	public static void Tick()
	{
		if ( string.IsNullOrEmpty( Current ) ) return;
		if ( _handle.IsValid() && _handle.IsPlaying ) return;

		var cue = Current;
		Current = null;          // force Play past its idempotence check
		Play( cue );
	}

	static readonly HashSet<string> _warned = new();
}