UI/TeleportOverlayState.cs

Static state for the teleport overlay UI. Tracks when a local-player teleport transit started, its length, whether the overlay is enabled, and computes which baked frame and overall opacity to draw during transit.

Reflection
using Sandbox;
using System;

namespace NZombies;

/// <summary>
/// Whether the local player is in transit, for <c>TeleportOverlay</c> to draw.
///
/// ⛔ A STATE CLASS RATHER THAN A CALL INTO THE PANEL, matching CherryShockState and HudState. A
/// generated razor class is not reachable from gameplay code, and the panel is client-side and may
/// not exist when a teleporter fires — mid-load, in the lobby, on a spectator.
///
/// ⛔ IT REUSES ELEMENTAL POP'S FRAMES. `ui/nz/cherry/shock_NN.png` is 21 frames of blue electrical
/// arcs with real alpha, already in the project. The alternative was baking Der Riese's own
/// `codtele.vtf` — 41 DXT1 frames with NO alpha channel, which would have needed alpha derived
/// from luminance and cost 4 MB (or 75 MB for all four upstream styles). Same look, no new assets.
///
/// ⚠️ SO THE FRAME COUNT IS READ FROM CherryShockState, NOT COPIED. If that bake is ever re-run at
/// a different length this follows it, instead of walking off the end of the sequence.
///
/// ⛔ AND THE SEQUENCE IS NOT STRETCHED TO FIT. CherryShockState's own comment says why: the length
/// is baked into the frames, and driving a fixed sequence from a duration knob reads as stuttering.
/// A transit is 4 seconds and the burst is 0.7, so this plays it as ATTACK / SUSTAIN / RELEASE —
/// crackle in, hold by ping-ponging the busiest frames, fade out on arrival. Measured alpha
/// coverage: frame 00 is 21%, it peaks at 07 (47%), and decays to 8% by frame 20.
///
/// ⚠️ STATICS SURVIVE A HOTLOAD (INSTRUCTIONS.md §1), so the timestamps seed to a large negative
/// rather than 0 — at 0 the overlay would consider itself mid-transit for the first frames of every
/// session, because Time.Now also starts near zero.
/// </summary>
public static class TeleportOverlayState
{
	/// <summary>Where the attack ends and the sustain band begins.</summary>
	const int AttackEnd = 7;

	/// <summary>Where the release tail begins. Frames AttackEnd..ReleaseStart ping-pong.</summary>
	const int ReleaseStart = 13;

	/// <summary>
	/// Seconds the attack and the release each take, at the bake's 30fps.
	///
	/// ⚠️ ReleaseSeconds is a PROPERTY, not a const. It is derived from CherryShockState.FrameCount
	/// so that re-baking at a different length carries through — and a const cannot be, which is
	/// the whole reason the frame count is read rather than copied.
	/// </summary>
	const float AttackSeconds = AttackEnd / 30f;
	static float ReleaseSeconds => (CherryShockFrames - ReleaseStart) / 30f;

	/// <summary>Frames the shared bake produced.</summary>
	static int CherryShockFrames => CherryShockState.FrameCount;

	/// <summary>How fast the sustain band ping-pongs, in frames per second.</summary>
	public static float SustainFps { get; set; } = 24f;

	/// <summary>Master switch — `nz_tp_hud 0`.</summary>
	public static bool ShowOverlay { get; set; } = true;

	/// <summary>When the current transit began, or long ago if none.</summary>
	public static float StartedAt { get; private set; } = -999f;

	/// <summary>How long it will last. 0 when idle.</summary>
	public static float Length { get; private set; }

	/// <summary>Is the local player in transit right now?</summary>
	public static bool Active
	{
		get
		{
			var since = Time.Now - StartedAt;
			return Length > 0f && since >= 0f && since <= Length;
		}
	}

	/// <summary>Start the overlay for a transit of this many seconds.</summary>
	public static void Begin( float seconds )
	{
		Length = MathF.Max( 0.2f, seconds );
		StartedAt = Time.Now;
	}

	/// <summary>
	/// Stop it early.
	///
	/// ⚠️ NOT called `Reset`. Component has one, and that exact collision has already cost this
	/// project twice (`DamageOverlay.Enabled`, `PackAPunch.Reset`).
	/// </summary>
	public static void ClearPending()
	{
		StartedAt = -999f;
		Length = 0f;
	}

	/// <summary>
	/// Which frame to show, or -1 when idle.
	///
	/// ⚠️ THE SUSTAIN PING-PONGS RATHER THAN LOOPING. Frame 20 is 8% covered and frame 00 is 21%,
	/// so a hard loop back to the start pops. Bouncing 07..12 has no seam at all, and on a crackle
	/// nobody can tell it is reversing.
	/// </summary>
	public static int Frame
	{
		get
		{
			if ( !Active || !ShowOverlay ) return -1;

			var t = Time.Now - StartedAt;
			var frames = CherryShockFrames;

			// ⚠️ A transit shorter than attack + release cannot hold anything, so it plays the
			// burst straight through rather than showing a sliver of each end.
			if ( Length <= AttackSeconds + ReleaseSeconds )
				return Math.Clamp( (int)(t / Length * frames), 0, frames - 1 );

			if ( t < AttackSeconds )
				return Math.Clamp( (int)(t / AttackSeconds * AttackEnd), 0, AttackEnd );

			var releaseAt = Length - ReleaseSeconds;
			if ( t >= releaseAt )
			{
				var k = (t - releaseAt) / ReleaseSeconds;
				return Math.Clamp( ReleaseStart + (int)(k * (frames - ReleaseStart)),
					ReleaseStart, frames - 1 );
			}

			// Sustain: bounce between AttackEnd and ReleaseStart-1.
			var span = ReleaseStart - AttackEnd;
			if ( span <= 1 ) return AttackEnd;

			var step = (int)((t - AttackSeconds) * SustainFps);
			var cycle = span * 2 - 2;
			var pos = cycle > 0 ? step % cycle : 0;
			if ( pos >= span ) pos = cycle - pos;

			return AttackEnd + pos;
		}
	}

	/// <summary>
	/// Overall opacity, so the effect arrives and leaves rather than snapping on.
	///
	/// ⚠️ SEPARATE FROM THE FRAME. The frames' own alpha carries the crackle; this carries the
	/// transition, and multiplying them means a short transit still fades properly even when the
	/// frame index is running the burst straight through.
	/// </summary>
	public static float Opacity
	{
		get
		{
			if ( !Active ) return 0f;

			var t = Time.Now - StartedAt;
			const float edge = 0.25f;

			var inFade = MathF.Min( 1f, t / edge );
			var outFade = MathF.Min( 1f, MathF.Max( 0f, Length - t ) / edge );

			return MathF.Min( inFade, outFade );
		}
	}

	/// <summary>`nz_tp_hud [0|1]` — turn the overlay off to see the world during transit.</summary>
	[ConCmd( "nz_tp_hud" )]
	public static void HudCmd( int on = -1 )
	{
		if ( on >= 0 ) ShowOverlay = on != 0;
		Log.Info( $"[nz-tp] overlay {(ShowOverlay ? "on" : "off")}"
			+ $"  ·  active {Active}  ·  frame {Frame}  ·  {CherryShockFrames} frames" );
	}

	/// <summary>`nz_tp_overlay <seconds>` — play it with no teleport, to look at it.</summary>
	[ConCmd( "nz_tp_overlay" )]
	public static void PlayCmd( float seconds = 4f )
	{
		Begin( seconds );
		Log.Info( $"[nz-tp] overlay playing for {Length:0.##}s" );
	}
}