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().
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();
}