Buyables/ArsenalManager.cs

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.

File Access
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 );
	}
}