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.
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" );
}
}