Static helper that implements a custom map loading/preview screen timer and progress logic. It tracks start, length and preview mode, computes a non-linear progress curve from a steps table, exposes title and background lookup from map/config, and provides console command to show/hide a preview.
using Sandbox;
using System;
using System.Linq;
namespace NZombies;
/// <summary>
/// THE LOADING SCREEN — ⛔ NAMED `MapLoading`, NOT `LoadingScreen`: the engine has a `Sandbox.LoadingScreen`, and every
/// file that uses both namespaces read the name as ambiguous (20 errors, 2026-09-28 00:14). Between everyone being ready and the spawn-in: the map's loading image (its lobby image when it has none), its name and a bar filling, for the
/// config's `Gameplay.LoadingSeconds` (10 by default, 0 for none), while the lobby's music plays on. Asked for as *"after the
/// players are ready and before the players spawn in, theres 10 seconds of loading screen that has the lobby image, the music …
/// keeps playing, and we see a load bar and the map name"* (2026-09-28), on every map. `LoadingScreenHud` draws it.
///
/// ⛔ ON EVERY MACHINE FROM THE HOST'S WORD. `NZNet.GameStarting` used to start the game at once; now it starts this, and each
/// machine starts its own game when its own screen is done (`LobbyMenu.OnUpdate`) — the host its round loop, a client its mode.
///
/// ⚠️ NOTHING IS LOADED IN IT. The map is in memory already; the seconds are the mood asked for. The bar moves in uneven steps, as a
/// real one does, and is full a moment before the end, so it never reads as a timer.
/// </summary>
public static class MapLoading
{
static float _start;
static float _length;
static bool _preview;
/// <summary>Is it on screen?</summary>
public static bool Active => _length > 0f;
/// <summary>On screen for a look only (`nz_loading`) — no game starts when it ends.</summary>
public static bool IsPreview => Active && _preview;
/// <summary>Seconds since it began, in real time — as the screen fade counts.</summary>
public static float Elapsed => Active ? RealTime.Now - _start : 0f;
/// <summary>Seconds to go.</summary>
public static float Left => Active ? MathF.Max( 0f, _length - Elapsed ) : 0f;
/// <summary>Over: time for the game.</summary>
public static bool Done => Active && Elapsed >= _length;
/// <summary>⚠️ A PROPERTY THAT BUILDS THE TABLE, not a static array (INSTRUCTIONS §1): the bar's steps, share of the time to share of the bar.</summary>
static (float T, float P)[] Steps => new[]
{
(0f, 0f), (0.08f, 0.12f), (0.2f, 0.18f), (0.33f, 0.41f), (0.47f, 0.46f),
(0.6f, 0.63f), (0.75f, 0.7f), (0.88f, 0.93f), (0.96f, 1f),
};
/// <summary>0 to 1 across the bar — fast, stuck, fast — full a moment before the end.</summary>
public static float Progress
{
get
{
if ( !Active ) return 0f;
var t = Math.Clamp( Elapsed / _length, 0f, 1f );
var steps = Steps;
for ( var i = 1; i < steps.Length; i++ )
{
if ( t > steps[i].T ) continue;
var a = steps[i - 1];
var b = steps[i];
var u = (t - a.T) / (b.T - a.T);
u = u * u * (3f - 2f * u);
return a.P + (b.P - a.P) * u;
}
return 1f;
}
}
/// <summary>Put it on screen for these seconds.</summary>
public static void Begin( float seconds, bool preview = false )
{
_start = RealTime.Now;
_length = MathF.Max( 0.5f, seconds );
_preview = preview;
Log.Info( $"[nz-loading] {(preview ? "preview" : "loading screen")}: '{Title}' for {_length:0.#}s" );
}
/// <summary>Off the screen.</summary>
public static void End()
{
_length = 0f;
_preview = false;
}
/// <summary>How long this map's lasts: its config's `Gameplay.LoadingSeconds`; 0, none.</summary>
public static float SecondsFor() => ActiveConfig.Current?.Gameplay?.LoadingSeconds ?? 10f;
static float? _told;
/// <summary>The host's length for the one about to begin (`NZNet.GameStarting`) — see <see cref="TakeSeconds"/>.</summary>
public static void Told( float seconds ) => _told = seconds;
/// <summary>
/// How long the one beginning now lasts: the host's word, else this map's own (`SecondsFor`). ⚠️ THE HOST'S ON EVERY MACHINE (the
/// co-op pass, 2026-09-28): each read its own config, which a settings change on the host never reaches, so the screens could end at
/// different times — and a joiner is told only how long is left.
/// </summary>
public static float TakeSeconds()
{
var s = _told ?? SecondsFor();
_told = null;
return s;
}
/// <summary>The name it shows: the config's own title (basalt's "Ignis Aeternus"), else the map's name from its manifest.</summary>
public static string Title
{
get
{
var title = ActiveConfig.Current?.Title?.Trim();
if ( !string.IsNullOrEmpty( title ) ) return title;
var key = NZMap.Current;
var map = MapLibrary.Originals.FirstOrDefault( m => NZMap.KeyFor( m.MapName ) == key );
return map?.Name ?? key ?? "";
}
}
/// <summary>What a map with neither image of its own gets — the lobby's own.</summary>
public const string DefaultBackground = "ui/lobby_bg.png";
/// <summary>
/// The image it shows: this map's own loading image (`MapLibrary.LoadingFor`, the manifest's `loading`), else its lobby
/// image (`MapLibrary.BackgroundFor`), else the lobby's default. Basalt has its own since 2026-09-28.
/// </summary>
public static string Background
{
get
{
var own = MapLibrary.LoadingFor( NZMap.Current );
if ( string.IsNullOrWhiteSpace( own ) ) own = MapLibrary.BackgroundFor( NZMap.Current );
return string.IsNullOrWhiteSpace( own ) ? DefaultBackground : own;
}
}
/// <summary>
/// `nz_loading [seconds]` — put the loading screen up now, to look at it, for these seconds or the map's own; it starts nothing.
/// `nz_loading 0` takes it down.
/// </summary>
[ConCmd( "nz_loading" )]
public static void Cmd( float seconds = -1f )
{
if ( seconds == 0f )
{
End();
Log.Info( "[nz-loading] down" );
return;
}
var length = seconds > 0f ? seconds : SecondsFor();
if ( length <= 0f ) length = 10f;
// ⚠️ THE MANIFEST RE-READ, so an image just added to it shows in the preview without a restart
MapLibrary.Reload();
// ⚠️ NOT `LoadingScreenHud.EnsureHost()` FROM HERE — a razor type is not reachable from a plain .cs file (ConfigBrowserState's
// note); the lobby's update hosts it on the next frame, whatever the mode
Begin( length, preview: true );
Log.Info( $"[nz-loading] '{Title}' over {Background} · this map's own: {SecondsFor():0.#}s"
+ " (Settings → Gameplay → Loading screen)" );
}
}