Component that manages spawning ammo box game objects from the active config. It ensures a singleton, creates non-saved, non-networked ammo box GameObjects at configured spots, loads a model, and orients boxes to lie flat on the floor while preserving yaw.
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// AMMO BOXES — builds the config's boxes into the world.
///
/// ⚠️ Deliberately the same shape as WunderfizzManager / PackAPunchManager / MysteryBoxManager /
/// DebrisManager: Ensure creates on demand, NotSaved keeps it out of the map, Rebuild is the single
/// entry point. A manager refreshed differently from its siblings is one more thing to remember at
/// every call site that puts a config into the world — and this project has already been bitten by
/// one that was.
/// </summary>
public sealed class AmmoBoxManager : Component
{
public static AmmoBoxManager Instance { get; private set; }
/// <summary>
/// The real model — the BO6 large ammo crate, upstream's own choice (`ammo_box/shared.lua:18`).
///
/// ⚠️ WAS `models/dev/box.vmdl` while the mechanic was being built. Ported via Crowbar ->
/// `qc_to_vmdl.py` -> `prop_vmt_to_vmat.py`; see
/// `Tools/PROP_IMPORT_ammobox_tradetable.md` for the exact route and what it cost.
/// </summary>
public const string ModelPath = "models/moo/_codz_ports_props/t10/t10_zm_large_ammo_crate_01/moo_codz_t10_zm_large_ammo_crate_01.vmdl";
protected override void OnAwake() => Instance = this;
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
public static AmmoBoxManager Ensure( Scene scene = null )
{
if ( Instance.IsValid() ) return Instance;
scene ??= Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Ammo Box Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<AmmoBoxManager>();
}
readonly List<GameObject> _built = new();
/// <summary>How many 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();
var list = ActiveConfig.Current?.AmmoBoxes;
if ( list is null || list.Count == 0 ) return;
foreach ( var spot in list )
Build( spot );
// ⚠️ Says how many are STANDING, not how many are configured. A model that fails to load
// leaves a spot in the config and nothing in the world, and those two numbers disagreeing is
// the cheapest way to see it.
Log.Info( $"[nz] {Built} of {list.Count} ammo box(es) built" );
}
void Build( AmmoBoxSpot spot )
{
var go = Scene.CreateObject();
go.Name = "Ammo 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;
// ⚠️ Yaw from the spot, tilt from the floor normal — so a box on a ramp sits on the ramp
// rather than through it.
go.WorldRotation = OnFloor( spot );
var r = go.Components.Create<ModelRenderer>();
r.Model = Model.Load( ModelPath );
// ⛔ NO SCALING. `BoxSize` and the bounds arithmetic here are GONE with the placeholder: a
// real prop is authored at the size it should be, and rescaling it to a number picked for a
// dev cube is how you end up with a ammo box that is subtly the wrong shape and nobody can
// say why.
if ( r.Model is null )
Log.Warning( $"[nz] ammo box model not found: {ModelPath} — nothing will draw" );
var box = go.Components.Create<AmmoBox>();
box.Spot = spot;
_built.Add( go );
}
/// <summary>
/// Face the spot's yaw while lying flat on its floor.
///
/// ⚠️ LIFTED FROM `WunderfizzManager.OnFloor`, deliberately identical. Projecting the heading
/// onto the floor plane is what stops a sloped placement from tipping the object over, and two
/// placeables aligning differently on the same ramp is a bug nobody would think to look for.
/// </summary>
static Rotation OnFloor( AmmoBoxSpot spot )
{
var up = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;
var heading = Rotation.FromYaw( spot.Yaw ).Forward;
var forward = (heading - up * heading.Dot( up )).Normal;
if ( forward.IsNearlyZero() )
return Rotation.From( 0f, spot.Yaw, 0f );
return Rotation.LookAt( forward, up );
}
}