Manager component that spawns and tracks Arsenal vending machines from a configuration. It ensures a single Instance, can create itself into a Scene, rebuilds all configured Arsenal spots, loads the model, creates renderer/collider/Arsenal component, and computes a floor-aligned rotation using a model yaw correction.
using Sandbox;
using System.Linq;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// THE ARSENAL — armor tiers today, weapon tech and ammo mods later.
///
/// ⚠️ Deliberately the same shape as WunderfizzManager / PackAPunchManager /
/// MysteryBoxManager: 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
/// (INSTRUCTIONS.md §13).
/// </summary>
public sealed class ArsenalManager : Component
{
public static ArsenalManager Instance { get; private set; }
/// <summary>The BO6 weapon machine (`bo6_arsenal/shared.lua:54`).</summary>
public const string ModelPath = "models/nz/arsenal/arsenal.vmdl";
/// <summary>
/// Yaw correction for the model's own forward axis, in degrees.
///
/// ⛔ THE MODEL DOES NOT FACE +X. Source props face +X by convention and
/// OnFloor assumes it, but this one is a CoD port: its bounds are 58.2 wide by
/// 48.6 deep, and a vending machine is wider across its FRONT than it is deep, so
/// the front lies along ±Y. Without this the machine presents its side to the
/// player — which is what it did.
///
/// ⚠️ A CORRECTION, NOT A PREFERENCE. It belongs to the asset, not to the
/// placement: every Arsenal on every map needs the same number, and the yaw a
/// mapper chooses is stored per spot and applied on top of this.
///
/// ⚠️ Tunable live via nz_arsenal_yaw because it can only be judged by looking
/// at it. The Wunderfizz and Pack-a-Punch need no such offset, so this is not a
/// gap in OnFloor — it is one asset disagreeing with three others.
///
/// ⚠️ STATIC, SO IT SURVIVES HOTLOAD. Editing the number here will NOT change a
/// running session — INSTRUCTIONS.md §1. Use the command, which is why it exists.
/// </summary>
/// <remarks>
/// ⚠️ -90, i.e. the 90 that squared up the side plus the 180 that turned the
/// front around. Judged by eye in game, which is the only way this can be judged.
/// </remarks>
public static float ModelYaw { get; set; } = -90f;
protected override void OnAwake() => Instance = this;
protected override void OnDestroy() { if ( Instance == this ) Instance = null; }
public static ArsenalManager Ensure( Scene scene )
{
if ( Instance.IsValid() ) return Instance;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "Arsenal Manager";
go.Flags |= GameObjectFlags.NotSaved;
return go.Components.Create<ArsenalManager>();
}
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?.Arsenals;
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} Arsenal machine(s) built" );
}
void Build( ArsenalSpot spot )
{
var go = Scene.CreateObject();
go.Name = "Arsenal";
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 = OnFloor( spot );
var model = Model.Load( ModelPath );
// ⚠️ 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. Checking only for null reports success on a machine that is
// visibly wrong — the same check WunderfizzManager and ZombieAI.EnsureBody
// both document.
if ( model is null || model.IsError )
{
Log.Warning( $"[nz] Arsenal model {ModelPath} "
+ $"({(model is null ? "null" : "error model")}) — recompile it" );
go.Destroy();
return;
}
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
// ⚠️ A BOX, NOT THE PORTED HULL. The .vmdl does carry a real collision hull
// from the QC, but every other machine here is boxed and a mapper who can
// walk into one corner of the Arsenal and not the others would reasonably
// call that a bug. Swap this for the hull if the silhouette ever matters.
var box = go.Components.Create<BoxCollider>();
box.Scale = model.Bounds.Size;
box.Center = model.Bounds.Center;
var arsenal = go.Components.Create<Arsenal>( startEnabled: false );
arsenal.Spot = spot;
// ⛔ SPOT BEFORE Enabled. OnEnabled registers it in Arsenal.All, and anything
// reading Spot from there would race the assignment — the ordering trap
// INSTRUCTIONS.md §11 records twice, and the reason a hellhound once spawned
// as a walker.
arsenal.Enabled = true;
_built.Add( go );
}
/// <summary>
/// A rotation facing `yaw` but lying flat on the spot's floor.
///
/// ⛔ The SAME maths the other three managers document — the heading is projected
/// onto the surface plane before LookAt sees it, because feeding LookAt a yaw that
/// is not perpendicular to the up vector lets it renormalise however it likes and
/// twists the machine on a slope.
/// </summary>
static Rotation OnFloor( ArsenalSpot spot )
{
var up = spot.Normal.IsNearlyZero() ? Vector3.Up : spot.Normal.Normal;
// ⚠️ THE ASSET'S CORRECTION IS ADDED TO THE MAPPER'S YAW, then the pair is
// projected onto the floor together. Applying the correction after the
// projection would rotate the machine about the WORLD up rather than the
// surface normal, and it would lean on any slope.
var heading = Rotation.FromYaw( spot.Yaw + ModelYaw ).Forward;
var forward = (heading - up * heading.Dot( up )).Normal;
if ( forward.IsNearlyZero() )
return Rotation.From( 0f, spot.Yaw, 0f );
return Rotation.LookAt( forward, up );
}
}