Static registry for timed player powerups. Stores end times, supports pausing (hold/release), querying active/timed/remaining, listing sorted active powerups, activating/clearing them, and console commands for inspection and control.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// What is running on the player right now, and for how much longer.
///
/// ⛔ THE REGISTRY IS SEPARATE FROM THE EFFECTS, deliberately. Every timed powerup
/// needs the same three things — a duration, a countdown, and a way for the HUD to
/// ask what is active — and only the BEHAVIOUR differs. Building this first means
/// Insta-Kill and Double Points each become one method reading `IsActive`, rather
/// than each carrying its own timer to get subtly wrong.
///
/// ⚠️ A STATIC rather than a component, matching `PowerupBannerState`: razor panels
/// are generated types that plain .cs cannot reference, so the HUD needs a seam it
/// can read without a component lookup. It also means an effect can ask "is Double
/// Points on" from anywhere — the scoring code, a weapon, a zombie — without
/// threading a reference through all of them.
/// </summary>
public static class ActivePowerups
{
/// <summary>
/// How long each timed powerup lasts. The original's own durations from
/// `sh_powerups.lua` — 30s for both.
///
/// ⚠️ ONLY TIMED KINDS APPEAR HERE. Max Ammo, Bonus Points and Nuke are instant
/// (`duration = 0` in the original) and must never occupy a HUD slot; absence
/// from this table IS what marks them instant, so there is no second list to
/// disagree with it.
/// </summary>
/// <summary>
/// How long each timed powerup lasts — the original's own durations from
/// `sh_powerups.lua`, 30s for all three.
///
/// ⛔ A SWITCH, NOT A `static readonly Dictionary`. It WAS a dictionary, and Fire
/// Sale could not be added to it in a running session: **static field
/// initialisers do not re-run on hotload.** The dictionary kept the contents it
/// was built with when the class first loaded, so a newly added entry was invisible
/// to `IsTimed` no matter how many times the source was edited, recompiled or play
/// was restarted — the assembly's statics simply never rebuilt. It reported
/// "FireSale is instant" against a table that visibly contained FireSale.
///
/// A switch is evaluated per call and cannot hold a stale snapshot of itself.
///
/// ⚠️ RETURNING 0 IS WHAT MARKS A POWERUP INSTANT. Max Ammo, Bonus Points, Nuke
/// and Carpenter are absent on purpose, so there is no second list to disagree
/// with this one.
/// </summary>
public static float DurationOf( PowerupKind kind ) => kind switch
{
PowerupKind.InstaKill => 30f,
PowerupKind.DoublePoints => 30f,
PowerupKind.FireSale => 30f,
_ => 0f,
};
/// <summary>
/// kind -> the realtime at which it ends.
///
/// ⚠️ Still a static field, and that is FINE here — it is initialised EMPTY and
/// filled at runtime, so a hotload keeping the old instance loses nothing. The
/// trap above only bites a static whose initialiser carries CONTENT.
/// </summary>
static readonly Dictionary<PowerupKind, RealTimeUntil> _active = new();
/// <summary>
/// While a single-player game is paused (`GamePause`), what each running power-up had left. Its clock stands still, and every
/// read below answers from here. Null when not paused.
///
/// ⚠️ THE CLOCKS ARE REAL TIME (`RealTimeUntil`), WHICH THE PAUSE'S TIME SCALE DOES NOT STOP (2026-10-05): without this a 30 s
/// Insta-Kill ran out behind the pause menu.
/// </summary>
static Dictionary<PowerupKind, float> _held;
/// <summary>Stop every power-up clock where it stands (`GamePause`). Twice is once.</summary>
public static void Hold()
{
if ( _held is not null ) return;
_held = new Dictionary<PowerupKind, float>();
foreach ( var (kind, until) in _active )
if ( until > 0f ) _held[kind] = until;
}
/// <summary>Start them again from where they stood.</summary>
public static void Release()
{
if ( _held is null ) return;
foreach ( var (kind, left) in _held )
_active[kind] = left;
_held = null;
}
/// <summary>Is this powerup timed at all?</summary>
public static bool IsTimed( PowerupKind kind ) => DurationOf( kind ) > 0f;
/// <summary>Is it running right now?</summary>
public static bool IsActive( PowerupKind kind )
=> _held is not null
? _held.ContainsKey( kind )
: _active.TryGetValue( kind, out var until ) && until > 0f;
/// <summary>Seconds left, 0 when not running.</summary>
public static float Remaining( PowerupKind kind )
=> _held is not null
? (_held.TryGetValue( kind, out var left ) ? left : 0f)
: _active.TryGetValue( kind, out var until ) && until > 0f ? until : 0f;
/// <summary>
/// Start it, or refresh it if already running.
///
/// ⛔ REFRESHES, IT DOES NOT STACK. Collecting a second Insta-Kill while one is
/// up sets the clock back to 30 — it does not give you 60. That is the original's
/// behaviour and it is also the only version that can be shown on a HUD with one
/// slot per powerup.
/// </summary>
public static void Activate( PowerupKind kind )
{
if ( !IsTimed( kind ) ) return;
// ⚠ TIMESLIP M1 TIME BANK SCALES IT HERE, at the one place a duration is assigned.
// `TimeUntil` is an absolute deadline, so the only moment a duration can be changed is
// when it is set — there is nothing to stretch afterwards.
//
// ⚠ "TIMED POWER-UPS ONLY" NEEDS NO TEST. An instant power-up's `DurationOf` is 0 and
// `IsTimed` is literally `DurationOf( kind ) > 0`, so multiplying 0 by 2 is still 0. The
// arithmetic already says what the augment text promises.
var holder = NZPlayer.Local;
var seconds = DurationOf( kind ) * TimeAugments.PowerupDurationScale( holder );
_active[kind] = seconds;
// ⚠️ AND INTO THE HELD COPY DURING A PAUSE (the console can start one then), or the pause's end would take it back
if ( _held is not null ) _held[kind] = seconds;
Log.Info( $"[nz] {kind} active for {seconds:0}s" );
}
/// <summary>
/// Everything running, longest remaining first.
///
/// ⚠️ SORTED so the row does not reorder itself as timers pass each other. A slot
/// that jumps sideways when another expires is worse than a fixed order — the
/// original assigns each powerup a FIXED slot index for exactly this reason
/// (`powerup_poses`), which is the better long-term answer once there are more
/// than a couple.
/// </summary>
public static IEnumerable<(PowerupKind kind, float remaining)> All()
=> _held is not null
? _held
.OrderByDescending( kv => kv.Value )
.Select( kv => (kv.Key, kv.Value) )
: _active
.Where( kv => kv.Value > 0f )
.OrderByDescending( kv => (float)kv.Value )
.Select( kv => (kv.Key, (float)kv.Value) );
/// <summary>Drop everything — used on death and round reset.</summary>
public static void Clear()
{
_active.Clear();
_held?.Clear();
}
// ── commands ─────────────────────────────────────────────────────────────
/// <summary>Start one by hand: `nz_powerup_active <kind>`.</summary>
[ConCmd( "nz_powerup_active" )]
public static void Cmd( string kind = "" )
{
if ( string.IsNullOrWhiteSpace( kind ) )
{
var live = All().ToList();
if ( live.Count == 0 ) { Log.Info( "[nz] nothing active" ); return; }
foreach ( var (k, r) in live )
Log.Info( $"[nz] {k,-14} {r:0.0}s left" );
return;
}
if ( !Powerup.TryParseKind( kind, out var k2 ) )
{
Log.Warning( $"[nz] no powerup called '{kind}'. Try: {Powerup.KindNames()}" );
return;
}
if ( !IsTimed( k2 ) )
{
Log.Info( $"[nz] {k2} is instant — it has no timer" );
return;
}
Activate( k2 );
}
/// <summary>Stop everything: `nz_powerup_clear`.</summary>
[ConCmd( "nz_powerup_clear" )]
public static void ClearCmd()
{
Clear();
Log.Info( "[nz] cleared active powerups" );
}
}