UI/IntroCard.cs

Static helper that manages and times an opening intro card HUD showing two lines (map title in caps and location). It computes typing progress, opacity over time, and provides Begin/End and a console command to show or hide the card.

using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// THE OPENING CARD — the map's name typed out in the lower left as a game fades up, as Black Ops opened its maps: basalt's
/// *"IGNIS AETERNUS / Beneath the Basalt"* (2026-09-28, "absolutely want this"). `IntroCardHud` draws it.
///
/// ⚠️ TWO LINES, EACH FROM WHERE IT ALREADY LIVES: the map's name as its loading screen shows it (`MapLoading.Title` — the
/// config's `Title`, else the manifest's name), in capitals, and the config's `Location`, a line of place (blank: no line).
/// ⚠️ NO ROUND LINE. It had a third, the round starting ("Round I"), taken off the same day — *"remove the Round 1 part leave the
/// rest"*. The HUD's own round counter says it.
///
/// ⚠️ ON THE SPAWN-IN'S BEAT, ON EVERY MACHINE, AND NOTHING SENT. `ScreenFade.Tick` starts it a moment after the tremor, as the
/// screen comes up out of the black, so every player sees it at their own fade. Survival only, never Creative.
/// ⚠️ REAL TIME, as the fade: a slowed game cannot hold it on screen.
/// </summary>
public static class IntroCard
{
	/// <summary>Letters a second as it types.</summary>
	public const float CharsPerSecond = 26f;

	/// <summary>The pause between one line finishing and the next beginning.</summary>
	public const float LineGap = 0.35f;

	/// <summary>How long the whole card holds once the last letter is down, before it fades.</summary>
	public const float Hold = 3.2f;

	/// <summary>How long it takes to fade.</summary>
	public const float Fade = 1.4f;

	static float _start;
	static string[] _lines = Array.Empty<string>();

	/// <summary>The card's lines, read once, when it began.</summary>
	public static IReadOnlyList<string> Lines => _lines;

	/// <summary>Seconds since it began, or negative while it waits for its delay.</summary>
	public static float Elapsed => _start > 0f ? RealTime.Now - _start : -1f;

	/// <summary>When each line starts typing, seconds from the card's start.</summary>
	static float LineStart( int line )
	{
		var t = 0f;
		for ( var i = 0; i < line && i < _lines.Length; i++ )
			t += _lines[i].Length / CharsPerSecond + LineGap;
		return t;
	}

	/// <summary>How long it takes to type every line.</summary>
	public static float TypingSeconds => _lines.Length == 0 ? 0f : LineStart( _lines.Length - 1 ) + _lines[^1].Length / CharsPerSecond;

	/// <summary>Its whole length, typed, held and faded.</summary>
	public static float Length => TypingSeconds + Hold + Fade;

	/// <summary>Is it on screen?</summary>
	public static bool On => _lines.Length > 0 && Elapsed >= 0f && Elapsed < Length;

	/// <summary>How many letters of a line are down by now.</summary>
	public static int Typed( int line )
	{
		if ( line < 0 || line >= _lines.Length ) return 0;
		var t = Elapsed - LineStart( line );
		if ( t <= 0f ) return 0;
		return Math.Min( _lines[line].Length, (int)(t * CharsPerSecond) );
	}

	/// <summary>The line being typed now, or -1 once all are down (for the cursor).</summary>
	public static int TypingLine
	{
		get
		{
			for ( var i = 0; i < _lines.Length; i++ )
				if ( Typed( i ) < _lines[i].Length ) return Elapsed >= LineStart( i ) ? i : -1;
			return -1;
		}
	}

	/// <summary>1 while it types and holds, easing to 0 over the fade.</summary>
	public static float Opacity
	{
		get
		{
			var left = Length - Elapsed;
			return left >= Fade ? 1f : Math.Clamp( left / Fade, 0f, 1f );
		}
	}

	/// <summary>Put the card up after <paramref name="delay"/> seconds: the lines are read now.</summary>
	public static void Begin( float delay = 0f )
	{
		var title = MapLoading.Title?.Trim() ?? "";
		var place = ActiveConfig.Current?.Location?.Trim() ?? "";

		_lines = new[] { title.ToUpperInvariant(), place }.Where( l => l.Length > 0 ).ToArray();
		_start = RealTime.Now + MathF.Max( 0f, delay );

		Log.Info( $"[nz-intro] the opening card: {string.Join( " / ", _lines )} · {Length:0.#}s" );
	}

	/// <summary>Take it down.</summary>
	public static void End()
	{
		_start = 0f;
		_lines = Array.Empty<string>();
	}

	/// <summary>`nz_intro` — the opening card now, to see it without starting a game. `nz_intro 0` takes it down.</summary>
	[ConCmd( "nz_intro" )]
	public static void Cmd( int on = 1 )
	{
		if ( on == 0 ) { End(); Log.Info( "[nz-intro] down" ); return; }

		Begin();
		if ( NZGame.Mode != GameMode.Survival || NZGame.IsCreative )
			Log.Info( "[nz-intro] ⚠ it draws in a Survival game only, not in Creative or the lobby" );
	}
}