UI/ScreenFade.cs

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.

External DownloadHttp CallsNetworkingProcess ExecutionFile AccessReflectionNative InteropObfuscated CodeEncoded DataSelf Modifying CodeCredential Access
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" );
	}
}