Buyables/WunderfizzManager.cs

Manager component that spawns and tracks Wunderfizz vending machines from configuration spots. Ensures a single Instance, can be created per Scene, rebuilds machines by creating GameObjects with model, collider and Wunderfizz component, and orients them to the local floor normal.

File Access
using Sandbox;
using System.Linq;
using System.Collections.Generic;

namespace NZombies;

/// <summary>
/// DER WUNDERFIZZ — the machine that sells a RANDOM perk.
///
/// ⚠️ Deliberately the same shape as 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 WunderfizzManager : Component
{
	public static WunderfizzManager Instance { get; private set; }

	/// <summary>The BO6/Jupiter machine — the variant nZombies reaches for first
	/// (`wunderfizz_machine/shared.lua:64`).</summary>
	public const string ModelPath = "models/nz/wunderfizz/wunderfizz.vmdl";

	protected override void OnAwake() => Instance = this;
	protected override void OnDestroy() { if ( Instance == this ) Instance = null; }

	public static WunderfizzManager Ensure( Scene scene )
	{
		if ( Instance.IsValid() ) return Instance;
		if ( !scene.IsValid() ) return null;

		var go = scene.CreateObject();
		go.Name = "Wunderfizz Manager";
		go.Flags |= GameObjectFlags.NotSaved;
		return go.Components.Create<WunderfizzManager>();
	}

	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?.Wunderfizzes;
		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} Wunderfizz machine(s) built" );
	}

	void Build( WunderfizzSpot spot )
	{
		var go = Scene.CreateObject();
		go.Name = "Der Wunderfizz";
		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 ZombieAI.EnsureBody documents.
		if ( model is null || model.IsError )
		{
			Log.Warning( $"[nz] Wunderfizz model {ModelPath} "
				+ $"({(model is null ? "null" : "error model")}) — recompile it" );
			go.Destroy();
			return;
		}

		var r = go.Components.Create<ModelRenderer>();
		r.Model = model;

		// Solid, so you cannot walk through it. Static — a vending machine has no
		// reason to simulate, and one that could be shoved would end up inside a
		// wall on any map with a slope.
		var box = go.Components.Create<BoxCollider>();
		box.Scale = model.Bounds.Size;
		box.Center = model.Bounds.Center;

		var fizz = go.Components.Create<Wunderfizz>();
		fizz.Spot = spot;

		_built.Add( go );
	}

	/// <summary>
	/// A rotation facing `yaw` but lying flat on the spot's floor.
	///
	/// ⛔ The SAME maths PackAPunchManager and MysteryBoxManager 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( WunderfizzSpot 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 );
	}
}