Audio/EggFanfare.cs

Static helper managing the Easter egg fanfare sequence: maps steps to click/roar/motif plans, schedules playback (click, roar, motif) with delays, fades and stops sounds, triggers camera shake and quake effects, and exposes a console command to inspect/control fanfares.

File Access
using Sandbox;
using System;

namespace NZombies;

/// <summary>
/// BASALT'S EASTER EGG FANFARES — each step done plays the map's motif again, a little more of it on a little more, and at steps 7,
/// 10 and 13 the beast roars and the map shakes, each longer, harder and louder than the last: *"at the end of the following steps i
/// want to hear the roar and the map shake, each step becoming a longer more agressive shake and louder roar, the first one being
/// something light — 7, 10, 13 — i would also like for you to compose a motif for the map that will play with diferent slight
/// variations instruments and intensity as we progress trough the steps"* (2026-09-28).
///
/// The sounds are `Tools/basalt_egg_motifs.py`'s: the box's figure (D A F A, G D C D) as the power motif has it, on the box's stones
/// alone at the first step and on the whole ensemble at the teleporter, turning to D major for his death and the end; the roars
/// are Oberon's own voice, lowered, in a cave, over falling rock.
///
/// ⛔ THE HOST SAYS WHEN, EVERY MACHINE PLAYS ITS OWN. `HexPlatforms` sends the step where it decides it is done
/// (`NZNet.EggStepDone`). Each machine then plays the motif and the roar, and shakes its own player's view, on its own clock from
/// that word. It is an event, so a joiner does not replay one. Nothing plays while testing (the selftest, `nz_hex_skipto`),
/// just as no step's other sounds do.
///
/// ⚠️ IN TURN, NOT ON TOP:
/// 1. The step's own clicking plays first (`nz.hex.done`, 5.2 s): at once, or `HexPlatforms.DoneCueDelay` later, whichever the
///    step does.
/// 2. The roar comes once the clicking's loud first three seconds are past, and the shaking comes with it.
/// 3. The motif comes out of the roar's tail. With no roar, it comes after the clicking.
/// <see cref="For"/> says what each step plays.
///
/// ⚠️ STEP 10'S SHAKE IS THE MASTERMIND'S QUAKE, MOVED HERE. It used to come with the clicking; now the same quake
/// (`HexPlatforms.QuakePeak`) comes with the roar, and the roar's rumble is held after it.
/// ⚠️ THE TUNABLES ARE NULLABLE-BACKED (INSTRUCTIONS §1), and the tables are switches, which a hotload sees.
/// </summary>
public static class EggFanfare
{
	/// <summary>When a step's clicking plays, as the step itself plays it: not at all, at once, or `DoneCueDelay` after.</summary>
	public enum Click { None, Now, Later }

	/// <summary>
	/// A step's fanfare:
	/// - its clicking;
	/// - its roar (0 for none, else 1-3);
	/// - its motif's cue (null for none);
	/// - how long a motif waits when the step has neither clicking nor roar.
	/// </summary>
	public readonly record struct Plan( Click Click, int Roar, string Motif, float Wait = 0.4f );

	/// <summary>
	/// Step by step, numbered as in the Easter egg's table. ⚠️ STEPS 6 AND 14 PLAY NOTHING:
	/// - 6: the altar's defense begins at once, with its own music;
	/// - 14: the arena's quiet before the fight belongs to the fight.
	/// Step 15 is his death, with the fight's music leaving under it. Step 16 is everyone home, a moment after the teleporter
	/// sets them down.
	/// </summary>
	public static Plan For( int step ) => step switch
	{
		1 => new( Click.Later, 0, "nz.basalt.motif.1" ),          // Color Smash — the box's stones alone
		2 => new( Click.None, 0, "nz.basalt.motif.2" ),           // Bonfire — a lyre over embers; the step has no clicking
		3 => new( Click.Now, 0, "nz.basalt.motif.3" ),            // Color Rings — stones and lyre in canon
		4 => new( Click.Later, 0, "nz.basalt.motif.4" ),          // Torch Carry — one dark horn and the fire
		5 => new( Click.Now, 0, "nz.basalt.motif.5" ),            // Shield Lock — bright, the answer rising to the octave
		7 => new( Click.Later, 1, "nz.basalt.motif.7" ),          // Altar Defense — THE FIRST ROAR, light; then the horns
		8 => new( Click.Now, 0, "nz.basalt.motif.8" ),            // Twin Shield — the horns, a lyre running under
		9 => new( Click.Later, 0, "nz.basalt.motif.9" ),          // Shrieker Platform — quicker, and the climb
		10 => new( Click.Now, 2, "nz.basalt.motif.10" ),          // The Mastermind — THE SECOND ROAR and its quake; heavy, dark
		11 => new( Click.None, 0, "nz.basalt.motif.11" ),         // Rising Lava — the whole shape, falling as it sinks
		12 => new( Click.Later, 0, "nz.basalt.motif.12" ),        // The Junctions — the stones ringing above the horns
		13 => new( Click.Later, 3, "nz.basalt.motif.13" ),        // Teleporter Buttons — THE THIRD ROAR, the loudest; everything
		15 => new( Click.Now, 0, "nz.basalt.motif.15" ),          // Oberon dead — the turn to D major
		16 => new( Click.None, 0, "nz.basalt.motif.16", 2.2f ),   // The core — everyone home: the finale
		_ => new( Click.None, 0, null ),
	};

	/// <summary>Each roar's sound: far off and short, closer and angrier, the loudest and the longest.</summary>
	public static string RoarCue( int roar ) => roar switch
	{
		1 => "nz.basalt.roar.1",
		2 => "nz.basalt.roar.2",
		3 => "nz.basalt.roar.3",
		_ => null,
	};

	/// <summary>
	/// A roar's shaking, in four parts:
	/// - a held rumble (`CameraShake.Rumble`: up over its first fifth, down over its last half);
	/// - a quake on top, which starts at its height and eases out (`HexPlatforms.StartQuake`), as a fraction of `QuakePeak`;
	/// - for the third roar, a second quake at its second breath;
	/// - the ceiling's dust (`CeilingDust`), as a multiple of the spawn-in's.
	/// </summary>
	public readonly record struct Shake( float Rumble, float Seconds, float Quake, float QuakeSeconds, float Again, float AgainAt,
		float AgainSeconds, float Dust );

	/// <summary>
	/// Each roar's shaking. Trauma 1 is 26° of swing; the spawn-in shakes at 0.08, the power at 0.1.
	/// - 1, light: a rumble of 0.07 for 6 s, and a little dust.
	/// - 2: the Mastermind's quake at full for 3 s, then a rumble of 0.17 held to 9 s.
	/// - 3: a quake at full for 4.5 s, another at three quarters 4.2 s in, a rumble of 0.3 held to 13 s, and twice the dust.
	/// Each matches its roar's length (`Tools/basalt_egg_motifs.py`).
	/// </summary>
	public static Shake ShakeFor( int roar ) => roar switch
	{
		1 => new( 0.07f, 6f, 0f, 0f, 0f, 0f, 0f, 0.6f ),
		2 => new( 0.17f, 9f, 1f, 3f, 0f, 0f, 0f, 1.2f ),
		3 => new( 0.3f, 13f, 1f, 4.5f, 0.75f, 4.2f, 3f, 2f ),
		_ => default,
	};

	/// <summary>How long after a roar starts its motif comes: out of its tail, once the voice is done.</summary>
	static float MotifAfterRoar( int roar ) => roar switch { 1 => 4.8f, 2 => 6.5f, _ => 9.5f };

	/// <summary>`nz_egg_fanfare off` silences them all (this session).</summary>
	public static bool Enabled
	{
		get => _enabled ?? true;
		set => _enabled = value;
	}

	static bool? _enabled;

	/// <summary>How long after the clicking starts the roar comes: once its loud first three seconds are past (it runs 5.2 s).</summary>
	public static float RoarAfterClick
	{
		get => _roarAfterClick ?? 3f;
		set => _roarAfterClick = value;
	}

	static float? _roarAfterClick;

	/// <summary>How long after the clicking starts a motif comes, when no roar does.</summary>
	public static float MotifAfterClick
	{
		get => _motifAfterClick ?? 2.6f;
		set => _motifAfterClick = value;
	}

	static float? _motifAfterClick;

	/// <summary>Bumped by every fanfare, and by <see cref="Stop"/>: a wait that finds it has moved on does nothing.</summary>
	static int _fanfare;

	static SoundHandle _motif, _roar;

	static bool InGame => NZGame.Mode is GameMode.Survival or GameMode.Spectator;

	/// <summary>
	/// A step done: its fanfare on this machine, starting now (`NZNet.EggStepDone`, the host's word). `nz_egg_fanfare` passes `click`
	/// to play the step's own clicking too, as the step would, and `anywhere` to play it outside a game.
	/// </summary>
	public static void Play( int step, bool click = false, bool anywhere = false )
	{
		var plan = For( step );
		if ( !Enabled || (plan.Roar <= 0 && plan.Motif is null) ) return;

		var id = ++_fanfare;
		var clickAt = plan.Click switch { Click.Now => 0f, Click.Later => MathF.Max( 0f, HexPlatforms.DoneCueDelay ), _ => -1f };
		var roarAt = plan.Roar > 0 ? MathF.Max( 0f, clickAt ) + RoarAfterClick : -1f;
		var motifAt = plan.Motif is null ? -1f
			: plan.Roar > 0 ? roarAt + MotifAfterRoar( plan.Roar )
			: clickAt >= 0f ? clickAt + MotifAfterClick
			: plan.Wait;

		if ( click && clickAt >= 0f ) Later( id, clickAt, anywhere, () => NZSound.PlayUi( HexPlatforms.DoneCue ) );
		if ( roarAt >= 0f ) Later( id, roarAt, anywhere, () => Roar( plan.Roar, anywhere ) );
		if ( motifAt >= 0f ) Later( id, motifAt, anywhere, () => Motif( plan.Motif ) );

		Log.Info( $"[nz-egg] step {step}'s fanfare"
			+ (roarAt >= 0f ? $" — the roar ({plan.Roar} of 3) in {roarAt:0.#}s" : "")
			+ (motifAt >= 0f ? $" — {plan.Motif} in {motifAt:0.#}s" : "") );
	}

	/// <summary>
	/// Wait, then do it — unless another fanfare or a stop has come since, or the game has gone. ⚠️ `GameTask.Delay`, the project's
	/// own (`VultureStink.ReportAfter`), RE-VALIDATED AFTER THE WAIT as `HexPlatforms.DoneCueLater` is.
	/// </summary>
	static async void Later( int id, float seconds, bool anywhere, Action then )
	{
		if ( seconds > 0f ) await GameTask.Delay( (int)(seconds * 1000f) );

		if ( id != _fanfare || (!anywhere && !InGame) ) return;
		then();
	}

	/// <summary>
	/// A motif, now, on this machine, everywhere alike. A new one fades out one still playing rather than playing over it — his
	/// death's, say, when the orb is taken before it ends and the finale begins.
	/// </summary>
	public static void Motif( string cue )
	{
		if ( string.IsNullOrEmpty( cue ) ) return;

		FadeOut( _motif, 0.5f );
		_motif = NZSound.PlayUi( cue );
	}

	/// <summary>Take a sound down to nothing over these seconds, then stop it. ⚠️ Re-validated at every step: it may end first.</summary>
	static async void FadeOut( SoundHandle h, float seconds )
	{
		if ( !h.IsValid() ) return;

		const int steps = 10;
		var from = h.Volume;
		for ( var i = 1; i <= steps; i++ )
		{
			await GameTask.Delay( (int)(seconds * 1000f / steps) );
			if ( !h.IsValid() ) return;
			h.Volume = from * (1f - (float)i / steps);
		}

		h.Stop();
	}

	/// <summary>
	/// A roar and its shaking, now, on this machine: the sound everywhere alike, this machine's own player's view shaken, and the
	/// ceiling's dust shed over them.
	/// </summary>
	public static void Roar( int roar, bool anywhere = false )
	{
		var cue = RoarCue( roar );
		if ( cue is null ) return;

		FadeOut( _roar, 0.5f );
		_roar = NZSound.PlayUi( cue );

		var s = ShakeFor( roar );
		CameraShake.Rumble( s.Rumble, s.Seconds );
		if ( s.Quake > 0f ) HexPlatforms.Ensure()?.StartQuake( s.QuakeSeconds, s.Quake * HexPlatforms.QuakePeak );
		if ( s.Again > 0f )
			Later( _fanfare, s.AgainAt, anywhere, () => HexPlatforms.Ensure()?.StartQuake( s.AgainSeconds, s.Again * HexPlatforms.QuakePeak ) );
		CeilingDust.Shed( s.Seconds, s.Dust );

		Log.Info( $"[nz-egg] THE BEAST ROARS ({roar} of 3) — the ground shakes, {s.Rumble:0.00} held for {s.Seconds:0.#}s"
			+ (s.Quake > 0f ? $", a quake from {s.Quake * HexPlatforms.QuakePeak:0.##} over {s.QuakeSeconds:0.#}s" : "")
			+ (s.Again > 0f ? $", another {s.AgainAt:0.#}s in" : "") );
	}

	/// <summary>Silent, now: the motif and the roar stopped, and anything still waiting dropped. The shaking eases out by itself.</summary>
	public static void Stop()
	{
		_fanfare++;
		if ( _motif.IsValid() ) _motif.Stop();
		if ( _roar.IsValid() ) _roar.Stop();
	}

	/// <summary>
	/// `nz_egg_fanfare [step [all] | roar 1-3 | motif step | stop | on | off]` — basalt's Easter egg fanfares, by hand.
	/// - Bare, it lists each step's plan and whether its sounds are there.
	/// - A step plays that step's fanfare on this machine, the step's own clicking included, on the step's clock.
	/// - `all` after a step sends it to everyone, as the step itself does (HOST).
	/// - `roar 1-3` plays a roar and its shaking at once; `motif` and a step plays that step's motif alone.
	/// - `stop` silences what plays; `off` and `on` switch them (this session).
	/// </summary>
	[ConCmd( "nz_egg_fanfare" )]
	public static void Cmd( string what = "", string arg = "" )
	{
		var w = what.Trim().ToLowerInvariant();
		int.TryParse( arg.Trim(), out var n );

		switch ( w )
		{
			case "":
				for ( var step = 1; step <= 16; step++ )
				{
					var p = For( step );
					var roar = RoarCue( p.Roar );
					Log.Info( $"[nz-egg] step {step,2}: "
						+ (p.Motif is null ? "nothing" : $"{p.Motif} {(NZSound.Exists( p.Motif ) ? "✓" : "MISSING")}")
						+ (roar is null ? "" : $" · roar {p.Roar}, {roar} {(NZSound.Exists( roar ) ? "✓" : "MISSING")}")
						+ $" · clicking {p.Click}" );
				}
				Log.Info( $"[nz-egg] {(Enabled ? "on" : "OFF")} · the roar {RoarAfterClick:0.#}s after the clicking, a motif"
					+ $" {MotifAfterClick:0.#}s after it · nz_egg_fanfare <step> to hear one" );
				return;

			case "off":
				Enabled = false;
				Stop();
				Log.Info( "[nz-egg] the Easter egg's fanfares are OFF — nz_egg_fanfare on for them back" );
				return;

			case "on":
				Enabled = true;
				Log.Info( "[nz-egg] the Easter egg's fanfares are ON" );
				return;

			case "stop":
				Stop();
				return;

			case "roar":
				if ( RoarCue( n ) is null ) { Log.Warning( "[nz-egg] nz_egg_fanfare roar 1, 2 or 3" ); return; }
				_fanfare++;
				Roar( n, anywhere: true );
				return;

			case "motif":
				if ( For( n ).Motif is not string cue ) { Log.Warning( $"[nz-egg] step {n} has no motif" ); return; }
				Motif( cue );
				Log.Info( $"[nz-egg] {cue}" );
				return;
		}

		if ( !int.TryParse( w, out var s ) || s < 1 || s > 16 )
		{
			Log.Warning( "[nz-egg] nz_egg_fanfare [step [all] | roar 1-3 | motif step | stop | on | off]" );
			return;
		}

		if ( arg.Trim().Equals( "all", StringComparison.OrdinalIgnoreCase ) )
		{
			if ( NZGame.IsClient ) { Log.Warning( "[nz-egg] the host sends a step's fanfare — run it on the host" ); return; }
			NZNet.EggStepDone( s );
			return;
		}

		var was = Enabled;
		Enabled = true;
		Play( s, click: true, anywhere: true );
		Enabled = was;
	}
}