Component that builds and manages mystery box game objects and their marker platforms from the active config. It picks a single active box spot (host decides), spawns the box and per-spot platforms, handles moving the box, and manages temporary 'fire sale' extra boxes.
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Builds the config's mystery boxes into the world.
///
/// ⚠️ The same shape as DebrisManager / BarricadeManager — Ensure creates it on
/// demand, NotSaved keeps it out of the map, Rebuild is the single entry point.
/// Copying that shape is deliberate: a manager refreshed differently from its
/// siblings is one more thing to remember at every call site that puts a config
/// into the world.
/// </summary>
public sealed class MysteryBoxManager : Component
{
public static MysteryBoxManager Instance { get; private set; }
protected override void OnAwake() => Instance = this;
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
public static MysteryBoxManager Ensure( Scene scene )
{
if ( Instance.IsValid() ) return Instance;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Mystery Box Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<MysteryBoxManager>();
}
/// <summary>Destroy what is standing and build the config again.</summary>
public void Rebuild()
{
_hostMove = false;
foreach ( var b in MysteryBox.All.ToList() )
b?.GameObject?.Destroy();
MysteryBox.All.Clear();
// ⚠️ Platforms are tracked separately because they are NOT MysteryBox
// components — they outnumber the box and exist at spots that have none.
foreach ( var p in Platforms.ToList() )
p?.Destroy();
Platforms.Clear();
var list = ActiveConfig.Current?.Boxes;
if ( list is null || list.Count == 0 ) return;
// ⛔ ONE BOX, NOT ONE PER SPOT. The list is where the box CAN be — the
// original's `random_box_spawns` — and only one exists at a time. Building
// them all would turn every candidate location into a working box and
// remove the entire reason the teddy bear exists.
//
// ⚠️ Chosen from spots flagged CanStart. If a mapper has turned them all
// off, fall back to the whole list rather than spawning no box at all: an
// unreachable box is a bug, a box in an unintended spot is a note.
var starts = list.Where( s => s.CanStart ).ToList();
if ( starts.Count == 0 )
{
starts = list;
Log.Warning( "[nz] no box spot is flagged as a start — using any" );
}
// ⛔ EACH SPOT IS EITHER THE BOX OR THE MARKER, NEVER BOTH. They are the
// two states of one location: the crate is where the box IS, and the
// footlockers-and-teddy is what a location the box has LEFT looks like.
// Building both at every spot stacked them on top of each other, which
// read as one cluttered object rather than two states.
// ⛔ EVERY MACHINE ROLLED ITS OWN SPOT, SO EVERY PLAYER HAD A DIFFERENT BOX. Reported as
// *"a caixa não está no mesmo sítio para os dois"*. `NZGame` rebuilds the whole map on each
// machine from the shared config — which is right for scenery, because scenery is a pure
// function of the config. The box is not: it is one CHOICE out of seven, and a choice has
// to be made once and told to everyone.
//
// ⚠️ THE HOST DECIDES AND ANNOUNCES; a client uses what it was told. `_netSpot` survives
// between rebuilds precisely because the order is not guaranteed — a client that builds
// its map before the announcement arrives has nothing to go on, so it rolls a placeholder
// and `PlaceAt` corrects it the moment the message lands.
var chosen = !Networking.IsActive || NZGame.IsHost
? Game.Random.FromList( starts )
: (_netSpot >= 0 && _netSpot < list.Count ? list[_netSpot] : Game.Random.FromList( starts ));
Current = chosen;
if ( !Networking.IsActive || NZGame.IsHost )
NZNet.BoxSpot( list.IndexOf( chosen ) );
foreach ( var spot in list )
{
if ( spot == chosen ) BuildBox( spot );
else BuildPlatform( spot );
}
Log.Info( $"[nz] box placed — 1 of {list.Count} spot(s), "
+ $"{starts.Count} eligible as a start" );
}
/// <summary>Which spot currently holds the box. Every other spot shows a marker.</summary>
public MysteryBoxSpot Current { get; private set; }
/// <summary>
/// The spot the host last announced, as an index into the config's `Boxes`.
///
/// ⚠️ STATIC, AND THAT IS DELIBERATE. The manager component is destroyed and recreated by
/// map loads; the host's decision outlives that, and a client that rebuilt before the
/// announcement arrived must still be able to use it on the next rebuild.
///
/// ⚠️ -1 MEANS "NOT TOLD YET", not "spot zero". A client that has heard nothing rolls a
/// placeholder rather than silently defaulting to the first spot, which would be wrong in
/// six cases out of seven and look deliberate.
/// </summary>
static int _netSpot = -1;
/// <summary>
/// Has the host's word on a bear's move come, and is it still to be spent? Set by `PlaceAt` while the box is leaving, spent by
/// `MoveBox`. ⚠️ STATIC, WITH `_netSpot`, for its reason.
/// </summary>
static bool _hostMove;
/// <summary>May a leaving box pick its next spot now? On the host always (it picks); on a client once the host's word has come.</summary>
public bool HostMoveReady => !Networking.IsActive || NZGame.IsHost || _hostMove;
/// <summary>
/// Put the box at the spot the host chose. Called from `NZNet.BoxSpot` on every client.
///
/// ⛔ BY CONFIG INDEX, NOT BY POSITION OR NAME. `ActiveConfig.Current.Boxes` is the same
/// list on every machine — the host sends the whole config before a map starts — so the index
/// is stable in a way a world position is not: a position would have to survive float
/// round-tripping through an RPC and then be matched back to a spot by distance.
///
/// ⚠️ IT MOVES AN EXISTING BOX RATHER THAN REBUILDING, for the reason `MoveBox`'s own header
/// gives: `Rebuild` destroys and recreates, which drops the lid state machine mid-sequence.
/// A correction arriving while a client watches the box open must not delete the box.
/// </summary>
public void PlaceAt( int index )
{
var list = ActiveConfig.Current?.Boxes;
if ( list is null || index < 0 || index >= list.Count ) return;
_netSpot = index;
var target = list[index];
if ( target == Current ) return;
// the real box is the one standing where we currently think it is
var from = Current;
// A FIRE SALE PUTS A BOX AT EVERY SPOT, so "the first valid MysteryBox" is not
// necessarily the real one -- `_saleBoxes` exists because the scene cannot tell them
// apart. Picking the one nearest where the box WAS is the question that has a right
// answer during a sale as well as outside one.
var box = from is null ? null : MysteryBox.All
.Where( b => b.IsValid() )
.OrderBy( b => b.WorldPosition.Distance( from.Position ) )
.FirstOrDefault();
// ⛔ A BOX IN THE MIDDLE OF LEAVING IS NOT MOVED UNDER ITSELF (the co-op pass, 2026-09-28). The host's word lands while this
// machine's own bear still laughs or its crate still sinks; moved then, its own `MoveBox` rolled again when the sequence got
// there and sent it somewhere else. The word is kept, and the box's `Relocate` takes it (`HostMoveReady`).
if ( box.IsValid() && box.IsLeaving )
{
_hostMove = true;
Log.Info( $"[nz-box] host says spot {index} — held until the box has left" );
return;
}
Current = target;
if ( box.IsValid() )
{
box.WorldPosition = target.Position;
box.WorldRotation = OnFloor( target, 180f );
box.Cost = target.Cost;
}
RebuildPlatforms();
Log.Info( $"[nz-box] host says the box is at spot {index} {target.Position}" );
}
/// <summary>Remember the host's choice even when no manager exists yet.</summary>
public static void Remember( int index ) => _netSpot = index;
/// <summary>
/// Send the box to a different spot, in place.
///
/// ⛔ MOVES THE EXISTING COMPONENT, it does not rebuild. Rebuild() destroys and
/// recreates every box, which would drop the state machine mid-sequence — and
/// the machine is precisely what has to survive, because it still owes the crate
/// an `arrive` animation at the far end.
///
/// ⚠️ The marker swap is the other half of the move and is easy to forget: the
/// spot being LEFT has to gain a platform and the spot being taken has to lose
/// one, or the box arrives standing on a pile of footlockers while its old home
/// shows bare ground.
/// </summary>
/// <returns>false when there is nowhere else to go.</returns>
public bool MoveBox( MysteryBox box )
{
if ( !box.IsValid() ) return false;
var list = ActiveConfig.Current?.Boxes;
if ( list is null || list.Count <= 1 ) return false;
// ⚠️ Anywhere BUT here. CanStart gates where the box may BEGIN, not where it
// may move to — the original's non-start spots exist precisely so the box
// can wander somewhere it never starts.
var elsewhere = list.Where( s => s != Current ).ToList();
if ( elsewhere.Count == 0 ) return false;
// ⚠️ THE MOVE DESYNCS THE SAME WAY THE PLACEMENT DID, and it is the easier one to miss
// because it only happens after a teddy bear. Same rule: the host picks and announces,
// and a client that has been told where to go uses that instead of rolling.
var target = !Networking.IsActive || NZGame.IsHost
? Game.Random.FromList( elsewhere )
: (_netSpot >= 0 && _netSpot < list.Count && list[_netSpot] != Current
? list[_netSpot] : Game.Random.FromList( elsewhere ));
// ⚠️ THE HOST'S WORD IS SPENT (`PlaceAt`)
_hostMove = false;
if ( !Networking.IsActive || NZGame.IsHost )
NZNet.BoxSpot( list.IndexOf( target ) );
var from = Current;
Current = target;
box.WorldPosition = target.Position;
box.WorldRotation = OnFloor( target, 180f );
box.Cost = target.Cost;
// The vacated spot gets its marker back; the new one loses its own.
RebuildPlatforms();
Log.Info( $"[nz] the box moved to {target.Position} "
+ $"(from {from?.Position.ToString() ?? "nowhere"})" );
return true;
}
/// <summary>Markers on every spot except the one the box is standing on.</summary>
void RebuildPlatforms()
{
foreach ( var p in Platforms.ToList() )
p?.Destroy();
Platforms.Clear();
var list = ActiveConfig.Current?.Boxes;
if ( list is null ) return;
foreach ( var spot in list )
if ( spot != Current ) BuildPlatform( spot );
}
/// <summary>
/// A rotation that faces `yaw` but lies FLAT on the spot's floor.
///
/// ⛔ `Rotation.From(0, yaw, 0)` is level with the WORLD, not with the ground
/// — on a ramp that leaves the box floating at one end and buried at the
/// other. This builds the same heading against the surface normal instead.
///
/// ⚠️ The forward vector is PROJECTED ONTO THE SURFACE PLANE before being
/// used. Feeding LookAt a yaw direction that is not perpendicular to the up
/// vector makes it renormalise however it likes, which twists the box on
/// steep ground — the heading has to be expressed in the plane it is going to
/// live in.
///
/// ⚠️ ONE HELPER FOR BOTH MODELS. The marker takes the same floor with a +90
/// offset; two copies of this maths would eventually disagree about slopes.
/// </summary>
static Rotation OnFloor( MysteryBoxSpot spot, float yawOffset = 0f )
{
var up = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;
var heading = Rotation.FromYaw( spot.Yaw + yawOffset ).Forward;
// Drop the component pointing into the floor, leaving the heading as it
// reads ALONG the slope.
var forward = (heading - up * heading.Dot( up )).Normal;
// Straight-down-the-normal heading has nothing left to project; fall back
// to level rather than emitting a zero-length forward.
if ( forward.IsNearlyZero() )
return Rotation.From( 0f, spot.Yaw + yawOffset, 0f );
return Rotation.LookAt( forward, up );
}
/// <summary>The box itself, at the one spot that currently holds it.</summary>
GameObject BuildBox( MysteryBoxSpot spot )
{
var go = Scene.CreateObject();
go.Name = "Mystery Box";
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;
// ⚠️ +180 — THE MODEL FACES BACKWARDS ON ITS OWN AXIS. The crate was
// authored with its lid hinge on what the engine calls forward, so a spot's
// yaw pointed the open side away from whoever placed it. Corrected here
// rather than in MysteryBoxSpot.Yaw, for the same reason the platform's +90
// is: that value is the SPOT's heading and is shared by both models, so
// baking one model's authoring quirk into it moves the other one too.
go.WorldRotation = OnFloor( spot, 180f );
var box = go.Components.Create<MysteryBox>();
box.Cost = spot.Cost;
// ⚠️ RETURNS THE OBJECT NOW, so the fire sale can track the extras it creates and take
// exactly those away again. It used to return void, which meant the only way to find a box
// afterwards was to search the scene — and that cannot tell a sale box apart from the real
// one.
return go;
}
// ── fire sale ────────────────────────────────────────────────────────────
/// <summary>
/// Boxes conjured for the duration of a fire sale. NOT the real one.
///
/// ⛔ TRACKED SEPARATELY FROM `Current` BECAUSE ONLY THESE GET REMOVED. The box at `Current` is
/// the game's actual box and survives the sale; every other spot gets a temporary one. Searching
/// the scene for MysteryBox components at the end would find all of them and could not say which
/// was which.
/// </summary>
readonly List<GameObject> _saleBoxes = new();
bool _saleOn;
/// <summary>
/// Open or close the extra box locations as a fire sale starts and ends.
///
/// ⛔ POLLED RATHER THAN EVENT-DRIVEN, deliberately. ActivePowerups expires things on a timer and
/// there is no "powerup ended" hook to subscribe to; a poll cannot miss an expiry, cannot fire
/// twice, and cannot be left subscribed by a hotload. It costs one bool comparison per frame.
/// </summary>
protected override void OnUpdate()
{
var on = PowerupEffects.FireSale;
if ( on != _saleOn )
{
_saleOn = on;
if ( on ) OpenAllSpots();
}
// ⚠️ CLOSING IS ATTEMPTED EVERY FRAME WHILE OFF, not once on the transition, because a box
// mid-spin refuses to be removed — see CloseSaleSpots. One shot at it would strand that box
// in the world for the rest of the game.
if ( !on && _saleBoxes.Count > 0 ) CloseSaleSpots();
}
/// <summary>
/// A box at every spot that has not got one.
///
/// ⚠️ THE PLATFORMS GO, because a platform is what marks a spot the box is NOT at — leaving them
/// under the sale boxes would show every location as both occupied and empty at once.
/// </summary>
void OpenAllSpots()
{
var list = ActiveConfig.Current?.Boxes;
if ( list is null || list.Count <= 1 ) return;
foreach ( var p in Platforms.ToList() ) p?.Destroy();
Platforms.Clear();
foreach ( var spot in list )
{
if ( spot == Current ) continue;
_saleBoxes.Add( BuildBox( spot ) );
}
Log.Info( $"[nz] FIRE SALE — {_saleBoxes.Count + 1} box location(s) open" );
}
/// <summary>
/// Take the extra boxes away again, once each is safe to remove.
///
/// ⛔ A BOX MID-SPIN IS LEFT ALONE UNTIL IT CLOSES. Destroying one while its lid is open takes a
/// weapon out of a player's hands mid-offer — they paid for it, and the sale ending is not their
/// doing. LidState.Closed is the only state with nothing in flight.
/// </summary>
void CloseSaleSpots()
{
for ( int i = _saleBoxes.Count - 1; i >= 0; i-- )
{
var go = _saleBoxes[i];
if ( !go.IsValid() ) { _saleBoxes.RemoveAt( i ); continue; }
var box = go.Components.Get<MysteryBox>();
if ( box.IsValid() && box.Lid != MysteryBox.LidState.Closed ) continue;
go.Destroy();
_saleBoxes.RemoveAt( i );
}
// Only once the last one is gone, or a platform would appear under a box still standing.
if ( _saleBoxes.Count == 0 )
{
RebuildPlatforms();
Log.Info( "[nz] fire sale over — the box is back to one location" );
}
}
/// <summary>
/// Every platform in the world, so Rebuild can clear them.
///
/// ⚠️ Tracked separately rather than through MysteryBox.All: platforms are not
/// box components, they outnumber the box, and they exist at spots that have
/// none.
/// </summary>
static readonly List<GameObject> Platforms = new();
/// <summary>
/// The pile the box sits on — shown at EVERY candidate location, because
/// `random_box_spawns/shared.lua:85` puts the platform on the SPAWN POINT
/// rather than on the box.
///
/// ⚠️ `magic_box_fake`, which ships beside `magic_box` in the same pack from
/// the same author. The lua's "Original" entry points at a t6 pile from a
/// DIFFERENT pack whose four materials include two stock CS:S textures this
/// project does not have; this one has two, both already converted and
/// compiled. Matched art beats a faithful path that renders half untextured.
/// </summary>
void BuildPlatform( MysteryBoxSpot spot )
{
var go = Scene.CreateObject();
go.Name = "Box Platform";
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;
// ⚠️ +90 ON THE SPOT'S YAW. The two models are authored on different axes,
// so a shared yaw points the crate correctly and the footlockers across
// the opening. Applied here rather than baked into MysteryBoxSpot.Yaw —
// that value orients the BOX, which is the one the player walks up to, and
// rotating it to suit the marker would tilt the thing that matters.
// ⚠️ THE SKIN'S OWN PLATFORM, AT ITS OWN TURN (`MysteryBoxSkins`, 2026-09-28): the original's footlockers sit across the
// opening (+90); the Origins base is authored on the crate's own axes (0) — the box brings its own base where it stands
var skin = MysteryBoxSkins.Current;
go.WorldRotation = OnFloor( spot, skin.PlatformYaw );
var r = go.Components.Create<ModelRenderer>();
r.Model = Model.Load( skin.Platform );
Platforms.Add( go );
}
}