A game Component representing a map object that toggles a global "Misery" game state which removes round gaps, forces minimum spawn delay, and raises zombie speed. Tracks all instances, provides proximity lookup, handles player use and a console command, and resets state at round start.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// THE MISERY ACCELERATION DEVICE — walk up, press E, the round stops being kind.
///
/// While it is ON:
/// <list type="bullet">
/// <item>the gap between rounds is gone — the next round begins immediately</item>
/// <item>the spawn delay drops to the configured minimum</item>
/// <item>every zombie, living and future, is at the top speed tier</item>
/// </list>
///
/// ⛔ THE STATE IS ONE STATIC, READ BY THREE SYSTEMS — not three copies pushed into
/// them. `RoundManager.BeginPrep`, `RoundManager.TickSpawn` and `ZombieAI`'s spawn all
/// ASK. Pushing values in would mean each of them owning a "what was it before" to
/// restore, and turning the device off would then depend on three separate restores
/// being right — the exact shape that leaves one setting stuck on.
///
/// ⚠️ SO TURNING IT OFF NEEDS NO UNDO. The round gap and the spawn delay simply go back
/// to being computed the normal way. The only thing that cannot un-happen is the speed
/// of zombies ALREADY raised, and that is deliberate: `RaiseSpeedRating` is documented
/// as a floor that must never slow one down, and a horde that got faster staying faster
/// is the honest reading of a device you chose to switch on.
/// </summary>
public sealed class MiseryDevice : Component
{
/// <summary>Every live device, for the use trace and the prompt.</summary>
public static readonly List<MiseryDevice> All = new();
protected override void OnEnabled() { if ( !All.Contains( this ) ) All.Add( this ); }
protected override void OnDisabled() => All.Remove( this );
/// <summary>The config row this was built from.</summary>
[Property] public MiserySpot Spot { get; set; }
/// <summary>
/// Is misery running?
///
/// ⛔ STATIC, AND STARTS FALSE. It is a property of the RUN, not of the box — two
/// devices on one map are two switches for one state, which is what a player would
/// expect from a lever, and it means the round systems have one thing to ask rather
/// than a list to search.
///
/// ⚠️ RESET BY `RoundManager.StartGame`, so a new run never inherits the last one's
/// misery. A static that survives a restart is the §1 trap this project keeps hitting.
///
/// ⛔ `Running`, NOT `Active` — `Component.Active` ALREADY EXISTS and a static of that
/// name hides it (CS0108, a WARNING not an error, so it would have shipped). This is the
/// THIRD time in this project: `DamageOverlay.Enabled`, `DamageWallVolume.Enabled`, now
/// this. Any short state word on a Component is suspect.
/// </summary>
public static bool Running { get; private set; }
/// <summary>How close you must stand. Matches the ammo box and Pack-a-Punch.</summary>
public const float UseRange = 90f;
/// <summary>The nearest device, or null.</summary>
public static MiseryDevice Near( Vector3 pos )
{
MiseryDevice best = null;
var bestDist = UseRange;
foreach ( var d in All )
{
if ( !d.IsValid() ) continue;
var dist = pos.Distance( d.WorldPosition );
if ( dist > bestDist ) continue;
bestDist = dist;
best = d;
}
return best;
}
/// <summary>Why this cannot be used right now, or empty when it can.
///
/// ⚠️ ONE METHOD FOR BOTH THE PROMPT AND THE KEY — the rule `NZPlayer.TickUse` and
/// `UsePrompt.Text` are written against.</summary>
public string Unavailable( NZPlayer player )
{
if ( !player.IsValid() ) return "no player";
var round = RoundManager.Instance;
// ⚠️ NOT IN THE LOBBY OR AFTER A GAME OVER. Toggling misery with no round running
// would set a flag that the next `StartGame` immediately clears, which reads as the
// switch not working.
if ( round.IsValid() && round.State == RoundState.GameOver )
return "the run is over";
return "";
}
/// <summary>Flip it. Returns what happened, for the log and the prompt.</summary>
public string Toggle( NZPlayer player )
{
var blocked = Unavailable( player );
if ( !string.IsNullOrEmpty( blocked ) ) return blocked;
Running = !Running;
NZSound.Play( NZSound.Purchase, WorldPosition );
if ( Running )
{
// ⛔ THE ZOMBIES ALREADY OUT HAVE TO BE RAISED HERE. The spawn path only sets the
// rating for zombies created AFTER this point, so without this the horde already
// chasing you would keep shambling and the device would look like it did nothing
// until the next round.
var raised = 0;
foreach ( var z in ZombieAI.All )
if ( z.IsValid() && z.RaiseSpeedRating( WalkerAnimations.SuperSprintRating ) )
raised++;
return $"MISERY ON — no gap between rounds, minimum spawn delay, "
+ $"top speed tier ({raised} already out raised)";
}
// ⚠️ NOTHING IS PUT BACK. See the class remarks: the two round settings go back to
// being computed normally on their own, and zombies already raised stay raised.
return "MISERY OFF — rounds and spawns back to normal (zombies already sped up stay)";
}
/// <summary>Clear the flag for a new run.
///
/// ⛔ CALLED FROM `RoundManager.StartGame`. `Running` is static and would otherwise
/// survive into the next game — a run that began already miserable, with nothing on
/// screen saying why.
///
/// ⛔ `ClearForNewRun`, NOT `Reset` — `Component.Reset()` EXISTS and this would have
/// hidden it (CS0114). Same family of collision as `Running` above.</summary>
public static void ClearForNewRun() => Running = false;
/// <summary>`nz_misery [0/1]` — toggle it without walking to one.</summary>
[ConCmd( "nz_misery" )]
public static void Cmd( int on = -1 )
{
var player = NZPlayer.Local;
if ( on >= 0 )
{
var want = on != 0;
// ⚠️ ROUTED THROUGH `Toggle` WHEN A DEVICE EXISTS, so the console and the use key
// cannot diverge — the raise-the-horde step lives there and a second copy here
// would be a second definition of what "on" means.
var dev = All.FirstOrDefault( d => d.IsValid() );
if ( want != Running && dev is not null && player.IsValid() )
Log.Info( $"[nz-misery] {dev.Toggle( player )}" );
else if ( want != Running )
Running = want;
}
Log.Info( $"[nz-misery] {( Running ? "ON" : "off" )} · {All.Count} device(s) placed"
+ $" · zombies out {ZombieAI.All.Count( z => z.IsValid() )}" );
}
}