Manager component that creates, rebuilds and tracks Soul Box game objects from configuration, routes zombie deaths to the nearest eligible box, and opens linked flags when all boxes on a link are full.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// SOUL BOXES — fill every box on a flag and that flag opens.
///
/// ⚠️ Deliberately the same shape as TeleporterManager / PerkMachineManager / DebrisManager: Ensure
/// creates on demand, NotSaved keeps it out of the map, Rebuild is the single entry point.
///
/// ⛔ AND REBUILDING IS ALSO HOW THE BOXES EMPTY. The fill lives on the SoulBox component, so
/// destroying and re-creating them is the reset — a new game gets empty boxes without needing a
/// reset path of its own. The trading table's manager records the same trick.
/// </summary>
public sealed class SoulBoxManager : Component
{
public static SoulBoxManager Instance { get; private set; }
protected override void OnAwake() => Instance = this;
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
public static SoulBoxManager Ensure( Scene scene )
{
if ( Instance.IsValid() ) return Instance;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Soul Box Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<SoulBoxManager>();
}
readonly List<GameObject> _built = new();
/// <summary>How many boxes are standing right now.</summary>
public int Built => _built.Count( g => g.IsValid() );
/// <summary>Destroy what is standing and build the config again.</summary>
public void Rebuild()
{
foreach ( var g in _built ) g?.Destroy();
_built.Clear();
// A new set of boxes: none removed yet (`SoulBox.RemovedByHost`, a joiner's catch-up).
SoulBox.RemovedByHost.Clear();
// ⛔ AND SWEEP ORPHANS. `_built` lives on THIS component, so a code hotload — which nulls
// the manager's static while leaving every box GameObject standing — means Ensure hands
// back a fresh manager with an empty list. Rebuilding then ADDS a second box beside each
// existing one, and the map quietly doubles every time you edit code. Sweeping the scene is
// the only way "what is standing" can be made to match the config.
if ( Scene.IsValid() )
foreach ( var orphan in Scene.GetAllComponents<SoulBox>().ToList() )
orphan.GameObject?.Destroy();
var list = ActiveConfig.Current?.SoulBoxes;
if ( list is null || list.Count == 0 ) return;
for ( int i = 0; i < list.Count; i++ )
Build( list[i], i );
Log.Info( $"[nz] {Built} of {list.Count} soul box(es) built" );
}
void Build( SoulBoxSpot spot, int index )
{
var authored = false;
Model mesh = null;
if ( !string.IsNullOrWhiteSpace( spot.Model ) )
{
var m = Model.Load( spot.Model );
// ⚠️ Model.Load returns null on a bad path but an ERROR MODEL on a compiled-but-broken
// one, and the error model renders happily as a checkerboard. Both fall through to the
// placeholder, SAID OUT LOUD.
if ( m is not null && !m.IsError )
{
mesh = m;
authored = true;
}
else
{
Log.Warning( $"[nz] soul box #{index}: model {spot.Model} "
+ $"({(m is null ? "not found" : "error model")}) — standing as a plain box" );
}
}
mesh ??= DebrisMesh.Build( spot.Footprint(), MathF.Max( 4f, spot.Height ),
MaterialFor( spot ) );
if ( mesh is null )
{
Log.Warning( $"[nz] soul box #{index}: its mesh could not be built — not standing" );
return;
}
var go = Scene.CreateObject();
go.Name = $"Soul Box #{index}";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
go.WorldPosition = spot.Position;
go.WorldRotation = Rotation.FromYaw( spot.Yaw );
var box = go.Components.Create<SoulBox>();
box.Spot = spot;
box.Index = index;
if ( authored )
{
// ⛔ SkinnedModelRenderer, NOT ModelRenderer. The lid is a BONE (`j_lid`) driven by the
// `open`/`close` sequences — a plain ModelRenderer shows the bind pose forever and no
// amount of setting a sequence on it does anything.
var r = go.Components.Create<SkinnedModelRenderer>();
r.Model = mesh;
// ⚠️ OFF, so a raw sequence plays. With UseAnimGraph on, the graph owns the pose and
// Sequence.Name is ignored — and this model has no graph.
r.UseAnimGraph = false;
box.Renderer = r;
// ⛔ BoxCollider FROM THE MODEL'S OWN BOUNDS. Crowbar's physics SMD is not a format
// s&box reads, and bridging it would mean a second OBJ conversion for a chest.
// PerkMachineManager and WunderfizzManager both do exactly this.
var solid = go.Components.Create<BoxCollider>();
solid.Scale = mesh.Bounds.Size;
solid.Center = mesh.Bounds.Center;
}
else
{
var r = go.Components.Create<ModelRenderer>();
r.Model = mesh;
// ⛔ THE TINT IS FOR THE PLACEHOLDER ONLY. Multiplying orange into carved stone would
// just make the real box wrong.
r.Tint = spot.Tint;
// ⛔ ModelCollider here — DebrisMesh gives the prism a convex hull per triangle, and a
// box would be a different solid from the one being drawn.
var hull = go.Components.Create<ModelCollider>();
hull.Model = mesh;
}
box.ApplyLid();
_built.Add( go );
}
static Material MaterialFor( SoulBoxSpot spot )
{
if ( !string.IsNullOrWhiteSpace( spot.Material ) )
{
var mat = Material.Load( spot.Material );
if ( mat is not null ) return mat;
}
return Material.Load( "materials/dev/gray_50.vmat" );
}
// ── collecting ───────────────────────────────────────────────────────────
/// <summary>Every live box carrying a flag, in config order.</summary>
public static List<SoulBox> OnLink( string link )
=> All().Where( b => DoorLinks.Same( b.Link, link ) ).OrderBy( b => b.Index ).ToList();
/// <summary>
/// Every live box, in config order.
///
/// ⛔ THE STATIC LIST FIRST, THE SCENE WALK ONLY AS A FALLBACK — AND THE FIRST VERSION HAD THIS
/// EXACTLY BACKWARDS. It walked the scene unconditionally to survive a code hotload orphaning
/// SoulBox.All, and its own comment justified that by noting the death hook calls this on every
/// kill. That is the reason to make it CHEAP, not the reason to make it thorough: measured in a
/// survival log, kill frames cost ~20ms of pure CPU with GPU time unchanged and allocation up
/// 2.6x, from a full GetAllComponents + Where + OrderBy + ToList per death — twice per death
/// when a box filled.
///
/// ⚠️ The fallback still does the hotload job. SoulBox.All is filled in OnEnabled, so a hotload
/// leaves it empty while the boxes stand; an empty list is exactly the signal to go and look.
/// A populated list is always right, and free.
/// </summary>
public static List<SoulBox> All()
{
if ( SoulBox.All.Count > 0 )
return SoulBox.All.Where( b => b.IsValid() && b.Spot is not null )
.OrderBy( b => b.Index ).ToList();
return Game.ActiveScene is null
? new List<SoulBox>()
: Game.ActiveScene.GetAllComponents<SoulBox>()
.Where( b => b.IsValid() && b.Spot is not null )
.OrderBy( b => b.Index ).ToList();
}
/// <summary>
/// A zombie died. Feed the nearest box that can take it.
///
/// ⛔ ONE BOX PER KILL, matching upstream's `break`. It walks the catchers and stops at the
/// first match, so a zombie killed between two boxes feeds one of them — not both. Splitting
/// the soul would make a cluster of boxes fill in a fraction of the intended kills, which is
/// the balance of the whole feature.
///
/// ⚠️ NEAREST rather than first-in-list, which is the one place this deliberately improves on
/// upstream: `ents.FindByClass` order is arbitrary, so with two overlapping boxes upstream
/// feeds whichever the engine happened to list first. Nearest is the one the player believes
/// they were fighting at.
/// </summary>
public static void OnZombieKilled( Vector3 at )
{
// ⛔ ITERATES THE RAW LIST, ALLOCATING NOTHING. This is the hottest path in the feature — one
// call per zombie death, and several zombies can die in a frame. It does not need a sorted
// copy, it needs the nearest usable box, so All()'s Where/OrderBy/ToList is pure waste here.
//
// ⚠️ AND IT DOES NOT NEED THE HOTLOAD FALLBACK EITHER. If SoulBox.All is empty because a
// hotload cleared it, the kill simply feeds nothing for the rest of that session — which is
// what nz_soul_rebuild is for. Paying a scene walk on every death to cover a dev-only case
// is the trade that produced 20ms kill frames.
SoulBox best = null;
var bestDist = float.MaxValue;
var boxes = SoulBox.All;
for ( int i = 0; i < boxes.Count; i++ )
{
var b = boxes[i];
if ( !b.IsValid() || b.Spot is null ) continue;
if ( !string.IsNullOrEmpty( b.Unavailable() ) ) continue;
if ( !b.InRange( at ) ) continue;
var d = at.Distance( b.Spot.Position );
if ( d >= bestDist ) continue;
bestDist = d;
best = b;
}
if ( best is null ) return;
if ( !best.Feed() ) return;
if ( best.IsFull ) CheckLink( best.Link );
}
/// <summary>
/// A box on this flag just filled. Open the flag only if EVERY box on it is full.
///
/// ⛔ THIS IS THE INVERTED LINK RULE, AND IT IS THE WHOLE FEATURE. Every other link user opens
/// on the FIRST event — DebrisManager.OpenAllOnLink fires the moment one barrier is bought.
/// Five soul boxes sharing a flag must all be full before it opens, so the check is an "all"
/// where the rest of the codebase has an "any". Written here rather than in SoulBox because a
/// box cannot see its siblings without asking the manager anyway.
///
/// ⚠️ A BLANK FLAG OPENS NOTHING, and that is not a failure. A box with no flag still fills and
/// still reports, which is how you test one before deciding what it should gate.
/// </summary>
public static void CheckLink( string link )
{
if ( DoorLinks.IsUnlinked( link ) ) return;
var group = OnLink( link );
if ( group.Count == 0 ) return;
var done = group.Count( b => b.IsFull );
if ( done < group.Count )
{
Log.Info( $"[nz-soul] flag {link}: {done}/{group.Count} boxes full" );
return;
}
// ⚠️ NOTED, NOT RETURNED ON — AND THAT DISTINCTION IS NEW. This was `if ( IsOpen ) return;`,
// which was right while opening a flag meant only setting a boolean: re-entry would announce
// the same unlock twice, and CheckLink runs on every completion. Now that opening also
// removes debris and regenerates nav, an already-open flag with barriers still standing is a
// broken state worth CORRECTING rather than a reason to skip. Suppress the announcement, do
// the work regardless — the sweep is idempotent, since a link with no barriers left on it
// finds nothing to destroy.
//
// ⚠️ It is also what lets a flag opened by the old broken version be repaired in place,
// instead of needing a fresh session to test the fix.
var wasOpen = DoorLinks.IsOpen( link );
// ⛔ OpenLink, NOT DoorLinks.Open — AND THIS WAS THE BUG. `DoorLinks.Open` only records that
// the flag is open. It does not remove the debris standing on it and it does not regenerate
// the navmesh where that debris was, so filling every soul box on a flag flipped a boolean
// and left the door physically shut. Everything that tests IsOpen per frame — perk machines,
// wallbuys, the teleporter — unlocked correctly, which is what made it look like the soul
// boxes had worked and the door was a separate problem.
//
// ⚠️ IF THERE IS NO DebrisManager the flag is still opened, so a map whose objective gates
// something other than debris keeps working. That is why the flag is opened by OpenLink
// rather than here.
if ( DebrisManager.Instance.IsValid() )
{
DebrisManager.Instance.OpenLink( link );
}
else
{
DoorLinks.Open( link );
NavLinkManager.Instance?.Rebuild();
}
if ( wasOpen ) return;
Log.Info( $"[nz-soul] flag {link} OPEN — all {group.Count} soul box(es) full" );
// ⚠️ REUSED, NOT ITS OWN CUE. This is the powerup pickup sting, which already means "a good
// thing just resolved"; inventing a second sound for a moment that happens once or twice a
// map would be a new asset for no new information.
var where = group[^1].WorldPosition;
NZSound.Play( NZSound.PowerupPickup, where );
}
/// <summary>Empty every box — a console reset.</summary>
public static void ClearAll()
{
foreach ( var b in All() ) b.Clear();
}
}