Audio/EggMusic.cs

Music manager for the Easter egg altar and boss fight. It chooses which music cue to play each frame (preview, boss, defense), starts/stops looping SoundHandles, fades volume in/out, schedules seamless loop copies, and provides a console command to preview or mute tracks.

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

namespace NZombies;

/// <summary>
/// BASALT'S EASTER EGG MUSIC — the altar defense's challenge and the boss fight: *"i think we should add a boss music to the boss
/// fight, and also some challenge music to the step where we defend 1 minute against an endless horde"* (2026-09-28). A map names
/// its tracks (`Gameplay.DefenseMusic`, `Gameplay.BossMusic`) and a sting for the kill (`Gameplay.BossWinSound`). Basalt's are
/// Gorod Krovi's Pavlov's defend and its dragon fight, made into seamless loops by `Tools/basalt_music.py`, and Ancient Evil's
/// round end for the kill.
///
/// ⛔ EACH MACHINE, FROM THE MIRROR, NOTHING SENT. The defense and the fight are host state mirrored to everyone
/// (`NZNet.AltarDefenseState`, `NZNet.BossFightState`), and a joiner is caught up on both. So every machine asks what they are every
/// frame (`LobbyMenu.OnUpdate`, beside `MapAmbience`) and plays for itself — a joiner arriving mid-fight hears the fight.
///
/// ⚠️ THE ARC. The challenge's track comes up as the defense begins and goes as it ends, held or lost. The boss's comes up with him,
/// out of the lava — not in the quiet before — drops low through each phase change's breather and swells back as he returns; at
/// his death it goes, and the sting marks the kill. ⚠️ ONLY A KILL HEARD GETS THE STING: a joiner arriving after it hears nothing.
///
/// ⚠️ ITS OWN CHANNEL, like the ambience bed — `NZMusic` is the lobby's. A track loops by starting its next copy the moment the last
/// reaches its length (`SoundHandle.Time` against it, as `SoundSpotManager` loops); the files are seamless loops. Every fade is by
/// volume, from the event's own.
/// ⚠️ THE SWITCH IS NULLABLE-BACKED (INSTRUCTIONS §1).
/// </summary>
public static class EggMusic
{
	/// <summary>How long a track takes to come up.</summary>
	public const float FadeIn = 2f;

	/// <summary>How long it takes to go, when its moment ends.</summary>
	public const float FadeOut = 3.5f;

	/// <summary>Where the boss's track sits through a phase change's breather — the dive, the arena changing, the wait.</summary>
	public const float BreatherLevel = 0.35f;

	/// <summary>How long before a copy runs out its successor starts: the files are seamless, so only a frame's worth.</summary>
	const float LoopLead = 0.05f;

	sealed class Voice
	{
		public SoundHandle Handle;
		public string Cue;
		public float EventVolume;
		public float Level;
		public bool Leaving;
		public RealTimeSince Age;
		public float Length;
	}

	static readonly List<Voice> _voices = new();
	static string _cue;
	static bool _wasFighting;

	/// <summary>A track played by hand (`nz_egg_music`), to hear it without the fight; null when none.</summary>
	static string _preview;

	/// <summary>`nz_egg_music mute` — none of it, for this session.</summary>
	public static bool Muted
	{
		get => _muted ?? false;
		set => _muted = value;
	}

	static bool? _muted;

	/// <summary>What plays now, for `nz_egg_music`.</summary>
	public static string Playing => _voices.Exists( v => !v.Leaving ) ? _cue : null;

	/// <summary>Every frame (`LobbyMenu.OnUpdate`): what this moment wants, faded in, looped, faded out.</summary>
	public static void Tick()
	{
		var (want, level) = Wanted();
		var dt = RealTime.Delta;

		// ⚠️ ANOTHER TRACK, OR NONE: whatever plays leaves, fading, while the new one comes up
		if ( want != _cue )
		{
			foreach ( var v in _voices ) v.Leaving = true;
			_cue = want;
		}

		if ( _cue is not null && NZSound.Enabled && NZSound.Exists( _cue ) && !_voices.Exists( v => !v.Leaving ) )
			Start( _cue, 0f );

		for ( var i = _voices.Count - 1; i >= 0; i-- )
		{
			var v = _voices[i];
			if ( !v.Handle.IsValid() ) { _voices.RemoveAt( i ); continue; }

			var target = v.Leaving ? 0f : level;
			v.Level = target > v.Level
				? MathF.Min( target, v.Level + dt / FadeIn )
				: MathF.Max( target, v.Level - dt / FadeOut );

			if ( v.Leaving && v.Level <= 0f )
			{
				v.Handle.Stop();
				_voices.RemoveAt( i );
				continue;
			}

			v.Handle.Volume = v.EventVolume * NZSound.VolumeOf( v.Cue ) * v.Level;

			if ( v.Leaving ) continue;

			// ⚠️ THE LOOP: the next copy the moment this one reaches its length, at this one's level. ⚠️ `Time`, THE HANDLE'S OWN CLOCK —
			// not `TimeUntilFinished`, which is a reverb tail and not what is left. ⚠️ NOT IN ITS FIRST SECOND: a handle not yet under
			// way reads as stopped, and every frame would start another. One that stopped anyway, or whose length is unknown, is
			// replaced the frame it is found — a gap of a frame, where the scheduled start has none.
			var due = v.Length > 1f && v.Handle.Time >= v.Length - LoopLead - dt;
			if ( v.Age > 1f && (due || v.Handle.Finished || !v.Handle.IsPlaying) )
			{
				v.Leaving = true;
				Start( v.Cue, v.Level );
			}
		}
	}

	/// <summary>
	/// The track this moment asks for, and its level: a preview first; then the fight, from his coming up to his death; then the
	/// defense while it runs. Nothing in the lobby, in Creative, or muted.
	/// </summary>
	static (string Cue, float Level) Wanted()
	{
		if ( !string.IsNullOrEmpty( _preview ) ) return (_preview, 1f);
		if ( Muted || NZGame.Mode is not (GameMode.Survival or GameMode.Spectator) ) return (null, 0f);

		var g = ActiveConfig.Current?.Gameplay;
		var fight = HexPlatforms.FightMusic;

		// ⚠️ THE KILL: his track goes, and the sting — for a fight this machine was hearing, not a joiner told of it afterwards
		if ( fight.Won && _wasFighting )
		{
			_wasFighting = false;
			var sting = Cue( g?.BossWinSound );
			if ( sting is not null ) NZSound.PlayUi( sting );
		}

		if ( fight.On )
		{
			_wasFighting = true;
			return (Cue( g?.BossMusic ), fight.Breather ? BreatherLevel : 1f);
		}

		if ( !fight.Won ) _wasFighting = false;

		return HexPlatforms.DefenseMusic ? (Cue( g?.DefenseMusic ), 1f) : (null, 0f);
	}

	static string Cue( string configured ) => string.IsNullOrWhiteSpace( configured ) ? null : configured.Trim();

	/// <summary>
	/// How long a track runs: its sound's own length when the file says, else the length `Tools/basalt_music.py` made it. ⚠️ THE LOOP
	/// NEEDS IT: a handle says where it is (`Time`), not how long it is.
	/// </summary>
	static float LengthOf( string cue )
	{
		if ( ResourceLibrary.TryGet<SoundEvent>( $"sounds/nz/{cue}.sound", out var ev ) && ev.Sounds is { Count: > 0 } sounds )
		{
			var longest = 0f;
			foreach ( var s in sounds )
				if ( s is not null && s.Duration > longest ) longest = s.Duration;

			if ( longest > 1f ) return longest;
		}

		return Made.TryGetValue( cue, out var made ) ? made : 0f;
	}

	/// <summary>The loops as `Tools/basalt_music.py` made them (ffprobe). ⚠️ A PROPERTY THAT BUILDS THE TABLE (INSTRUCTIONS §1).</summary>
	static Dictionary<string, float> Made => new()
	{
		[NZSound.BasaltMusicBoss] = 144.010f,
		[NZSound.BasaltMusicBossTakeo] = 128.000f,
		[NZSound.BasaltMusicBossDarkTech] = 114.869f,
		[NZSound.BasaltMusicDefend] = 114.474f,
		[NZSound.BasaltMusicDefendTrial] = 123.043f,
	};

	static void Start( string cue, float level )
	{
		var h = Sound.Play( cue );
		if ( !h.IsValid() ) return;

		var v = new Voice { Handle = h, Cue = cue, EventVolume = h.Volume, Level = level, Age = 0f, Length = LengthOf( cue ) };
		h.Volume = v.EventVolume * NZSound.VolumeOf( cue ) * level;
		_voices.Add( v );
	}

	/// <summary>Silent, now — every copy.</summary>
	public static void Stop()
	{
		foreach ( var v in _voices )
			if ( v.Handle.IsValid() ) v.Handle.Stop();

		_voices.Clear();
		_cue = null;
	}

	/// <summary>
	/// `nz_egg_music [boss|defend|off|mute|unmute] [cue]` — the Easter egg's music by hand, on this machine. `boss` or `defend` plays
	/// this map's track to hear it; a cue after it tries another there first (the config in memory — `nz_save` keeps it). `off` stops
	/// it; `mute` silences it all for the session. Bare: what plays, and what each moment names. Basalt's alternatives: boss
	/// `nz.basalt.music.boss.takeo`, `nz.basalt.music.boss.darktech`; defend `nz.basalt.music.defend.trial`.
	/// </summary>
	[ConCmd( "nz_egg_music" )]
	public static void Cmd( string what = "", string cue = "" )
	{
		var g = ActiveConfig.Current?.Gameplay;

		switch ( what.Trim().ToLowerInvariant() )
		{
			case "off":
				_preview = null;
				break;

			case "boss":
				if ( cue.Length > 0 && g is not null ) g.BossMusic = cue.Trim();
				_preview = Cue( g?.BossMusic );
				break;

			case "defend":
				if ( cue.Length > 0 && g is not null ) g.DefenseMusic = cue.Trim();
				_preview = Cue( g?.DefenseMusic );
				break;

			case "mute":
				Muted = true;
				_preview = null;
				break;

			case "unmute":
				Muted = false;
				break;

			case "":
				break;

			default:
				Log.Warning( "[nz-audio] nz_egg_music [boss|defend|off|mute|unmute] [cue]" );
				return;
		}

		if ( _preview is not null && !NZSound.Exists( _preview ) )
			Log.Warning( $"[nz-audio] no sound event '{_preview}' — Assets/sounds/nz/{_preview}.sound" );

		Log.Info( $"[nz-audio] the Easter egg's music{(Muted ? " — MUTED" : "")} · playing {Playing ?? "nothing"}"
			+ $"{(_preview is not null ? " (by hand — nz_egg_music off)" : "")}"
			+ $" · defense '{g?.DefenseMusic}', boss '{g?.BossMusic}', his death '{g?.BossWinSound}' · nz_save keeps a change" );
	}
}