Static utility that manages a full-screen fade-to-black effect for spawn/start sequences. It composes timed "legs" (start, length, from, to) to compute eased opacity, supports commanding to black, calling off, a spawn-in sequence with tremor and intro hooks, and a console command to trigger/clear the sequence.
using Sandbox;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// A full-screen fade to and from black, drawn by `ScreenFadeHud` — and THE SPAWN-IN, asked for as *"when the game starts after
/// players are ready and countdown ends, i want the screen to fade to black and then slowly fade into the game"* (2026-09-27):
/// the countdown's last <see cref="SpawnOutSeconds"/> take the screen to black, it holds while the game is set up behind it —
/// the lobby closing, everyone placed at the spawns — then the game fades up over <see cref="SpawnInSeconds"/>.
///
/// ⚠️ EACH MACHINE FADES ITS OWN SCREEN, AND NOTHING IS SENT. Every machine runs the lobby's countdown and shows the same digits
/// (`LobbyMenu.TickCountdown`), and every machine starts on the host's one word (`NZNet.GameStarting` → `LobbyMenu.StartGame`)
/// — the two moments the fade hangs on.
///
/// ⚠️ REAL TIME, NOT GAME TIME, so a slowed or paused game cannot hold the screen black.
/// </summary>
public static class ScreenFade
{
/// <summary>How long before the countdown ends the screen starts going black — so it is black as the lobby closes.</summary>
public const float SpawnOutSeconds = 0.8f;
/// <summary>How long it holds black once the game has started: the lobby gone and everyone placed, behind it.</summary>
public const float SpawnHoldSeconds = 1f;
/// <summary>How slowly the game fades up out of the black — *"slowly fade into the game"*.</summary>
public const float SpawnInSeconds = 3f;
/// <summary>One stretch of the fade: from one opacity to another over a span of real time.</summary>
readonly record struct Leg( float Start, float Length, float From, float To );
/// <summary>The fade, stretch by stretch, one after the other. Empty = nothing on screen.</summary>
static readonly List<Leg> _legs = new();
/// <summary>Is this the spawn-in — which nothing calls off — rather than a fade to black a countdown can take back?</summary>
static bool _spawning;
/// <summary>When the spawn-in's tremor is due, in real time — just before the fade-up (`SpawnTremor.Lead`) — or 0.</summary>
static float _tremorAt;
/// <summary>
/// Every frame a fade is on (`ScreenFadeHud.OnUpdate`): the spawn-in's tremor, on its beat — *"as the game fades in the
/// screen is shaking and we hear a tremmor sound and rocks moving"*.
/// </summary>
public static void Tick()
{
if ( _tremorAt <= 0f || RealTime.Now < _tremorAt ) return;
_tremorAt = 0f;
// ⚠️ ONLY ON A MAP THAT ASKS FOR IT (`Gameplay.SpawnInTremor`, basalt's) — it shook every map's start until 2026-10-01
SpawnTremor.OnSpawnIn();
// ⚠️ AND THE OPENING CARD, a moment behind the tremor, typing as the screen comes up (`IntroCard`, 2026-09-28)
IntroCard.Begin( delay: 1f );
}
/// <summary>How black the screen is now, 0 to 1 — eased, so it slides into and out of the black rather than at one rate.</summary>
public static float Opacity
{
get
{
if ( _legs.Count == 0 ) return 0f;
var now = RealTime.Now;
var last = _legs[^1];
// ⚠️ OVER, AND CLEAR: forgotten, so nothing is kept drawing a black that is not there
if ( now >= last.Start + last.Length && last.To <= 0f )
{
Clear();
return 0f;
}
for ( var i = _legs.Count - 1; i >= 0; i-- )
{
var leg = _legs[i];
if ( now < leg.Start ) continue;
if ( leg.Length <= 0f || now >= leg.Start + leg.Length ) return leg.To;
var t = (now - leg.Start) / leg.Length;
t = t * t * (3f - 2f * t);
return leg.From + (leg.To - leg.From) * t;
}
return _legs[0].From;
}
}
/// <summary>Is anything on screen — a fade under way, or held at black?</summary>
public static bool Active => Opacity > 0.001f;
/// <summary>Heading for black, or there?</summary>
static bool GoingBlack => _legs.Count > 0 && _legs[^1].To >= 0.999f;
/// <summary>
/// Take the screen to black over these seconds, from however black it is now. ⚠️ ONCE — a call while already heading there
/// is ignored, so the countdown can ask on every frame of its last moments.
/// </summary>
public static void ToBlack( float seconds )
{
if ( GoingBlack ) return;
var from = Opacity;
_legs.Clear();
_legs.Add( new Leg( RealTime.Now, System.MathF.Max( 0.05f, seconds ), from, 1f ) );
_spawning = false;
_tremorAt = 0f;
}
/// <summary>Call off a fade to black — a countdown cut short. ⚠️ NEVER THE SPAWN-IN, which ends only by fading up.</summary>
public static void CallOff( float seconds = 0.35f )
{
if ( _spawning || !GoingBlack ) return;
var from = Opacity;
_legs.Clear();
_legs.Add( new Leg( RealTime.Now, seconds, from, 0f ) );
}
/// <summary>
/// THE SPAWN-IN, as a game starts: black — already, from the countdown, or within a moment if not — held
/// <see cref="SpawnHoldSeconds"/>, then faded up over <see cref="SpawnInSeconds"/>.
/// </summary>
public static void SpawnIn( float hold = SpawnHoldSeconds, float fade = SpawnInSeconds )
{
Sequence( Opacity >= 0.999f ? 0f : 0.15f, hold, fade );
Log.Info( $"[nz-fade] spawn-in — black {hold:0.#}s, then up over {fade:0.#}s" );
}
/// <summary>To black over <paramref name="toBlack"/>, held for <paramref name="hold"/>, up over <paramref name="fade"/>.</summary>
static void Sequence( float toBlack, float hold, float fade )
{
var now = RealTime.Now;
var from = Opacity;
_legs.Clear();
_legs.Add( new Leg( now, toBlack, from, 1f ) );
_legs.Add( new Leg( now + toBlack, hold, 1f, 1f ) );
_legs.Add( new Leg( now + toBlack + hold, fade, 1f, 0f ) );
_spawning = true;
// ⚠️ THE TREMOR, just before the black lifts, so the rumble is heard in the dark and the shaking carries the fade-up
_tremorAt = System.MathF.Max( now + 0.01f, now + toBlack + hold - SpawnTremor.Lead );
}
/// <summary>Nothing on screen, now.</summary>
public static void Clear()
{
_legs.Clear();
_spawning = false;
_tremorAt = 0f;
}
/// <summary>
/// `nz_fade [hold] [fade]` — play the whole spawn-in now, to see it without starting a game: to black over
/// <see cref="SpawnOutSeconds"/>, held, then up. `nz_fade 0` clears the screen at once.
/// </summary>
[ConCmd( "nz_fade" )]
public static void Cmd( float hold = -1f, float fade = -1f )
{
if ( hold == 0f )
{
Clear();
Log.Info( "[nz-fade] cleared" );
return;
}
hold = hold < 0f ? SpawnHoldSeconds : hold;
fade = fade < 0f ? SpawnInSeconds : fade;
Sequence( SpawnOutSeconds, hold, fade );
Log.Info( $"[nz-fade] to black over {SpawnOutSeconds:0.#}s, black {hold:0.#}s, then up over {fade:0.#}s" );
}
}