Manager and component for in-game collectible build parts. BuildPartManager creates, rebuilds and tracks authored and runtime-dropped Prisma parts, replicates runtime drops to joiners, hides parts when the team has found them. BuildPart is the component on each part that provides proximity lookup, availability checks (power/links) and collection logic, including host-authoritative networked take/drop calls.
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BUILD PARTS — the three pieces of the Prisma, lying about the map.
///
/// ⚠️ Same shape as `BuildTableManager` and its siblings: `Ensure` on demand, `NotSaved`, one
/// `Rebuild` entry point.
/// </summary>
public sealed class BuildPartManager : Component
{
public static BuildPartManager Instance { get; private set; }
static float? _reach;
/// <summary>
/// How close you must stand to pick one up. 70 units.
/// </summary>
///
/// ⚠️ TIGHTER THAN THE TABLE'S, and deliberately: a part is a small object on the floor and
/// you should have to walk to it. It is also what `UsePrompt` reads, so the prompt and the
/// pickup can never disagree about reach — the trap `UsePrompt`'s own header calls out.
public static float Reach { get => _reach ?? 70f; set => _reach = value; }
protected override void OnAwake() => Instance = this;
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
public static BuildPartManager Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Build Part Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<BuildPartManager>();
}
readonly List<GameObject> _built = new();
/// <summary>
/// Parts dropped into the world at runtime, as opposed to placed by the map author.
/// </summary>
///
/// ⛔ IT EXISTS SO A JOINER CAN BE TOLD ABOUT THEM. The authored parts need no replication at
/// all — every machine builds the same list from the same config — but a part dropped by a
/// soul box is in nobody's config, so a player who arrives afterwards has no way to learn it is
/// lying there. This is the record `NZNet.PushState` replays to them.
readonly List<(int Part, Vector3 Pos, float Yaw)> _drops = new();
/// <summary>Every runtime drop, for the network layer to replay to a joiner.</summary>
public IReadOnlyList<(int Part, Vector3 Pos, float Yaw)> Drops => _drops;
/// <summary>
/// Is this part already out there — dropped and waiting, or taken off the map?
/// </summary>
///
/// ⚠️ BOTH HALVES, AND NEITHER IS ENOUGH ALONE. `BuildParts.Found` only goes true when somebody
/// COLLECTS one, so a part lying on the floor is still unfound and a second killer would drop
/// another; the drop list only knows about runtime drops, so it says nothing about a part
/// collected from an authored spot.
///
/// ⚠️ IT DOES NOT KNOW ABOUT AN AUTHORED SPOT NOBODY HAS TOUCHED. A map that both places part 2
/// and asks a napalm for it has two sources by the author's own choice, and this will let both
/// exist — which is a map-config question rather than something to second-guess here.
public bool AlreadyAwarded( int part )
=> BuildParts.Found( part ) || _drops.Any( d => d.Part == part );
public int Built => _built.Count( g => g.IsValid() );
public void Rebuild()
{
foreach ( var g in _built ) g?.Destroy();
_built.Clear();
// ⛔ A NEW GAME EMPTIES THE POOL, AND THIS IS THE ONE PLACE THAT KNOWS. `Rebuild` runs from
// `NZGame` and `RoundManager` when a game starts, and nowhere per round — so it is exactly
// the moment the team should stop holding last game's parts and the map should be whole
// again. The editor and `nz_buildpart_rebuild` also land here, which is correct: they are
// re-laying the parts.
BuildParts.Reset();
_drops.Clear();
var list = ActiveConfig.Current?.BuildParts;
if ( list is null || list.Count == 0 ) return;
foreach ( var spot in list )
Build( spot );
Log.Info( $"[nz-build] {Built} of {list.Count} build part(s) placed" );
}
void Build( BuildPartSpot spot )
{
var path = BuildParts.Model( spot.Part );
if ( string.IsNullOrEmpty( path ) )
{
Log.Warning( $"[nz-build] spot has no such part: {spot.Part}" );
return;
}
var go = Scene.CreateObject();
go.Name = $"Build Part — {BuildParts.Name( spot.Part )}";
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.From( 0f, spot.Yaw, 0f );
var r = go.Components.Create<ModelRenderer>();
r.Model = Model.Load( path );
if ( r.Model is null )
Log.Warning( $"[nz-build] part model not found: {path} — nothing will draw" );
var part = go.Components.Create<BuildPart>();
part.Spot = spot;
_built.Add( go );
}
/// <summary>
/// Put a part into the world at runtime — a reward, rather than a spot the map author placed.
/// </summary>
///
/// ⛔ IT GOES INTO `_built` DELIBERATELY, AND THAT IS NOT A LEAK. That list is what `OnUpdate`
/// walks to hide parts the team has already taken, so a dropped part left out of it would stay
/// visible after somebody collected it. It is also what `Rebuild` clears, which is right in the
/// other direction: a part earned last game must not still be lying there in the next one.
///
/// ⚠️ A SYNTHETIC SPOT WITH NO LINK AND NO POWER REQUIREMENT. Those gates exist so an authored
/// part can sit behind a door that is not open yet; a part that has just been EARNED has passed
/// whatever gate it had, and re-applying one would drop a piece nobody is allowed to pick up.
/// ⛔ THE HOST DECIDES, AND THIS IS A REVERSAL OF THE FIRST VERSION. That one let every machine
/// build its own copy off the back of the soul box's replayed fill, which works right up until
/// somebody joins — the drop is in no config, so a joiner has nothing to build it from and the
/// part is invisible to them forever. One authority, one broadcast, one record to replay.
public BuildPart Drop( int part, Vector3 position, float yaw = 0f )
{
if ( !BuildParts.Valid( part ) ) return null;
if ( Networking.IsActive && !NZGame.IsHost ) return null;
var made = ApplyDrop( part, position, yaw );
if ( Networking.IsActive )
NZNet.BuildPartDropped( part, position, yaw );
return made;
}
/// <summary>
/// Put a dropped part here on THIS machine. What the broadcast and the join replay land on.
/// </summary>
///
/// ⚠️ IDEMPOTENT BY POSITION, because it is called three ways that overlap: the host applies it
/// before broadcasting, the broadcast comes back to the host too, and `PushState` replays every
/// drop to everyone rather than only to the joiner. Without the guard one soul box would leave
/// a stack of claws growing by one each time somebody connected.
public BuildPart ApplyDrop( int part, Vector3 position, float yaw )
{
if ( !BuildParts.Valid( part ) ) return null;
if ( _drops.Any( d => d.Part == part && d.Pos.Distance( position ) < 1f ) )
return null;
_drops.Add( (part, position, yaw) );
Build( new BuildPartSpot { Part = part, Position = position, Yaw = yaw } );
return _built.LastOrDefault()?.Components.Get<BuildPart>();
}
/// <summary>Hide the ones the team has taken, show the rest.</summary>
///
/// ⛔ STILL VISIBILITY, NEVER A DESTROY, EVEN NOW THAT PARTS ARE SHARED. Hiding is reversible
/// and a destroy is not: `Rebuild` lays the parts out again for a new game, and an object torn
/// down on pickup would need respawning instead of re-enabling. It also costs nothing — the
/// list is three objects.
///
/// ⚠️ KEYED ON `Found`, NOT ON `Has`. The team stops HOLDING the parts the moment the weapon is
/// built; they have still been taken. Keying on `Has` would lay all three back out on the map
/// at the exact moment the hunt finished.
protected override void OnUpdate()
{
foreach ( var g in _built )
{
if ( !g.IsValid() ) continue;
var part = g.Components.Get<BuildPart>();
if ( part?.Spot is null ) continue;
g.Enabled = !BuildParts.Found( part.Spot.Part );
}
}
}
/// <summary>One piece of the Prisma, on the floor, waiting to be picked up.</summary>
public sealed class BuildPart : Component
{
public BuildPartSpot Spot { get; set; }
/// <summary>
/// The nearest part this player could still pick up, or null.
/// </summary>
///
/// ⚠️ IT SKIPS PARTS THEY ALREADY CARRY, which is what stops a collected piece holding the
/// prompt open over a part they can still use standing behind it.
public static BuildPart Near( NZPlayer player )
{
if ( !player.IsValid() ) return null;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var reach = BuildPartManager.Reach;
return scene.GetAllComponents<BuildPart>()
.Where( b => b.IsValid() && b.Spot is not null )
.Where( b => !BuildParts.Found( b.Spot.Part ) )
.Where( b => b.WorldPosition.Distance( player.WorldPosition ) <= reach )
.OrderBy( b => b.WorldPosition.Distance( player.WorldPosition ) )
.FirstOrDefault();
}
/// <summary>Why this player cannot take it, or empty.</summary>
public string Unavailable( NZPlayer player )
{
if ( Spot is null ) return "";
if ( Spot.RequiresPower && !Power.IsOn )
return "Needs power";
if ( !string.IsNullOrEmpty( Spot.Link ) && !DoorLinks.IsOpen( Spot.Link ) )
return "Locked";
return "";
}
/// <summary>Take it. Returns what to say, or empty if nothing happened.</summary>
public string Collect( NZPlayer player )
{
if ( Spot is null || !player.IsValid() ) return "";
var blocked = Unavailable( player );
if ( !string.IsNullOrEmpty( blocked ) ) return blocked;
// ⛔ A CLIENT ASKS THE HOST, WHICH COLLECTS IT FOR THEM WITH THIS SAME METHOD (the co-op audit, 2026-09-27). The part
// goes from every screen when the host's answer arrives; two players reaching for one part get it once.
if ( Networking.IsActive && !NZGame.IsHost )
{
NZNet.BuildPartTakeAsk( Spot.Part, WorldPosition );
return "";
}
if ( !BuildParts.Take( Spot.Part ) ) return "";
NZSound.PlayShared( NZSound.PickupSalvage, WorldPosition );
var held = BuildParts.Held();
return held >= BuildParts.Count
? $"Picked up the {BuildParts.Name( Spot.Part )} — that is all three"
: $"Picked up the {BuildParts.Name( Spot.Part )} ({held}/{BuildParts.Count})";
}
}