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