Utility that clears runtime-spawned world objects when a round ends. It iterates the active Scene and destroys Pickups, Powerups, ZombieAI instances, and Placeable objects, optionally clearing bullet decals, and exposes a console command nz_sweep to run or toggle it.
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// WHAT A FINISHED RUN LEAVES ON THE FLOOR.
/// </summary>
///
/// ⛔ THE MAP'S OWN OBJECTS ARE NOT THIS METHOD'S JOB AND MUST NOT BECOME IT. `RoundManager.StartGame`
/// already rebuilds sixteen managers — debris, power, barricades, boxes, walls, links, tables — and
/// every one of them knows how to destroy what it built and build it again from the config. This
/// sweep is for the things that were never in a config at all: what the RUN dropped. Two systems
/// clearing the same object is how one of them ends up clearing it at the wrong moment.
///
/// ⛔ REPORTED AS *"the map is not properly cleaned up on game over, like salvage and shield drops,
/// and maybe soul boxes too"*, and all three named cases were real but for two different reasons.
/// The drops are THIS: local litter with a 30–120s lifetime that nothing shortens when the run
/// ends, so it follows you into the lobby. The soul boxes were the other kind — `SoulBoxManager`
/// was simply missing from `StartGame`'s list of sixteen, so a new run began with the last run's
/// souls still counted. That one is fixed where the other fifteen live, not here.
///
/// ⛔ ON EVERY MACHINE, WHICH IS WHY IT HANGS OFF `NZGame.SetMode` AND NOT OFF `ReturnToLobby`.
/// Pickups are `NetworkMode.Never` — each machine spawns and owns its own copy and the network
/// carries the EVENT, not the object (`Pickup.Drop`'s own note). So there is no host that can
/// destroy everybody's litter; each machine has to clear its own, and `SetMode` is the one thing
/// in the project that runs everywhere. `ReturnToLobby` is the host's alone, which is exactly the
/// bug `SetMode` already carries a comment about for the lobby menu itself.
///
/// ⚠️ IT IS NOT A LEAK FIX AND DOES NOT PRETEND TO BE. Everything here already expires on its own —
/// pickups at 30 or 120 seconds, corpses at 6. The complaint is about the seconds AFTER a run, when
/// a lobby that should be quiet is still holding a round's worth of dropped plates. For anything
/// that grows and never comes back, `nz_leak` is the instrument.
public static class WorldCleanup
{
/// <summary>Sweep at all. `nz_sweep 0` to leave the litter where it falls.</summary>
public static bool Enabled { get; set; } = true;
/// <summary>Wipe bullet holes as part of a sweep. They are capped at 30, so this is cosmetic.</summary>
public static bool ClearDecals { get; set; } = true;
/// <summary>
/// Destroy everything this run dropped. Returns how many objects went.
/// </summary>
///
/// ⚠️ MATERIALISED WITH ToList FIRST, the same reason `PowerupEffects.Nuke` gives: destroying a
/// component mutates the scene's component list, and iterating it live while it changes throws.
///
/// ⚠️ ZOMBIES INCLUDED, CORPSES AND ALL. `RoundManager.ClearZombies` already does this on the
/// host's own path and doing it twice costs nothing — but a client returning to the lobby never
/// runs that method, and a corpse is a `SkinnedModelRenderer` plus a full `ModelPhysics` group.
/// They are the dearest thing on this list by a distance.
public static int Sweep( Scene scene, string why = "" )
{
if ( !Enabled || !scene.IsValid() ) return 0;
int drops = 0, powerups = 0, bodies = 0, placed = 0;
foreach ( var p in scene.GetAllComponents<Pickup>().ToList() )
if ( p.IsValid() ) { p.GameObject.Destroy(); drops++; }
foreach ( var p in scene.GetAllComponents<Powerup>().ToList() )
if ( p.IsValid() ) { p.GameObject.Destroy(); powerups++; }
foreach ( var z in scene.GetAllComponents<ZombieAI>().ToList() )
if ( z.IsValid() ) { z.GameObject.Destroy(); bodies++; }
// ⚠️ BANANA COLADA'S FURNITURE — a slick bar, a wall, a decoy stand, a springboard. Placed
// by a player during a run and belonging to nothing that gets rebuilt, so without this they
// are the one dropped thing that survives into the NEXT game as well as into the lobby.
foreach ( var p in scene.GetAllComponents<Placeable>().ToList() )
if ( p.IsValid() ) { p.GameObject.Destroy(); placed++; }
// ⚠️ GRENADES IN FLIGHT ARE DELIBERATELY LEFT. One is a live explosive with a fuse and an
// owner; destroying it mid-air silently eats a grenade somebody threw a quarter of a second
// before the round ended. It lands, it goes off, it is gone — which is already the answer.
if ( ClearDecals ) BulletDecals.ClearCmd();
var total = drops + powerups + bodies + placed;
if ( total > 0 )
Log.Info( $"[nz] swept{(string.IsNullOrEmpty( why ) ? "" : $" ({why})")}"
+ $" — {drops} drop(s), {powerups} powerup(s), {bodies} zombie(s)/corpse(s),"
+ $" {placed} placed item(s)" );
return total;
}
/// <summary>
/// `nz_sweep [0|1]` — clear the floor now, or switch the automatic sweep off.
/// </summary>
///
/// ⚠️ THE BARE COMMAND SWEEPS. A switch that only printed its own state would need a second
/// command to do the thing, and the thing is the point — this is also how you check whether a
/// frame-rate problem is the litter or something else, by clearing it and looking.
[ConCmd( "nz_sweep" )]
public static void SweepCmd( int on = -1 )
{
if ( on >= 0 )
{
Enabled = on != 0;
Log.Info( $"[nz] automatic sweep {(Enabled ? "on" : "OFF")}" );
if ( !Enabled ) return;
}
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) { Log.Info( "[nz] no scene" ); return; }
// ⚠️ SAYS SO EVEN WHEN IT FINDS NOTHING, because "nothing happened" is the answer the
// command is usually being asked for.
if ( Sweep( scene, "by hand" ) == 0 )
Log.Info( "[nz] swept — nothing on the floor" );
}
}