Buyables/PerkMachineManager.cs

Manager component that spawns Perk vending machine game objects from a saved map config. It maps perk ids to model paths, builds/destroys machines, sets model renderer and colliders, and orients machines to the floor surface.

File AccessNative Interop
using Sandbox;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// PERK MACHINES — one machine per perk, each selling only the perk it advertises.
///
/// ⚠️ Deliberately the same shape as WunderfizzManager / PackAPunchManager / 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.
/// </summary>
public sealed class PerkMachineManager : Component
{
	public static PerkMachineManager Instance { get; private set; }

	/// <summary>
	/// Perk id -> machine model, all ported from GMod's `sh_perks.lua` DEFAULT `model` field.
	///
	/// ⛔ THE MAP LIVES HERE, NOT IN THE CONFIG. A spot stores only its PerkId, so re-pointing a
	/// machine's mesh — or adding the `model_classic` / `model_cw` variants as a style selector
	/// later — is a change in one place instead of a re-edit of every saved config.
	///
	/// ⚠️ Fifteen come from Moo's nZombies Content Pack and two (dtap, vigor) from the
	/// Rezzurrection pack, which is why the two path shapes differ. See
	/// Tools/PERK_MACHINE_IMPORT.md.
	///
	/// ⚠️ `tortoise`'s path really does contain `models/` twice — that is how the pack ships it.
	/// </summary>
	static readonly Dictionary<string, string> Models = new()
	{
		["jugg"]       = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_jugg/moo_codz_p7_zm_vending_jugg.vmdl",
		["speed"]      = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_sleight/moo_codz_p7_zm_vending_sleight.vmdl",
		["revive"]     = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_revive/moo_codz_p7_zm_vending_revive.vmdl",
		["staminup"]   = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_marathon/moo_codz_p7_zm_vending_marathon.vmdl",
		["dtap"]       = "models/nzr/2022/machines/dtap/vending_dtap.vmdl",
		["deadshot"]   = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_deadshot/moo_codz_p7_zm_vending_deadshot.vmdl",
		["phd"]        = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_flopper/moo_codz_p7_zm_vending_flopper.vmdl",
		["mulekick"]   = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_three_gun/moo_codz_p7_zm_vending_three_gun.vmdl",
		["pop"]        = "models/moo/_codz_ports_props/t10/jup_zm_machine_elemental_pop/moo_codz_jup_zm_machine_elemental_pop.vmdl",
		["vulture"]    = "models/kate/_codz_ports_props/t7/zm/p7_zm_vending_vulture_aid/p7_zm_vending_vulture.vmdl",
		["widowswine"] = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_widows_wine/moo_codz_p7_zm_vending_widows_wine.vmdl",
		["death"]      = "models/moo/_codz_ports_props/t10/t10_zm_machine_death_perception/moo_codz_t10_zm_machine_death_perception.vmdl",
		["tortoise"]   = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_victor/models/kate_codz_p7_zm_vending_victor.vmdl",
		["time"]       = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_timeslip/moo_codz_p7_zm_vending_timeslip.vmdl",
		["banana"]     = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_banana_colada/moo_codz_p7_zm_vending_banana_colada.vmdl",
		["fire"]       = "models/moo/_codz_ports_props/t7/zm/p7_zm_vending_stopping_power/moo_codz_p7_zm_vending_stopping_power.vmdl",
		["vigor"]      = "models/nzr/2022/perks/vending_vigor.vmdl",
	};

	/// <summary>The model for a perk id, or "" when that perk has no machine.</summary>
	public static string ModelFor( string perkId )
		=> perkId is not null && Models.TryGetValue( perkId, out var m ) ? m : "";

	/// <summary>Perk ids that HAVE a machine model, in PerkRegistry's menu order.</summary>
	public static string[] Placeable
		=> PerkRegistry.All.Where( p => Models.ContainsKey( p.Id ) ).Select( p => p.Id ).ToArray();

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

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

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

	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()
	{
		// ⚠️ THE LOOSE CHANGE COMES BACK WITH THE MACHINES. This runs when they are laid out for
		// a new game, which is exactly when every machine's coin should be findable again — and it
		// is one place to remember instead of a second hook in `NZGame`.
		LooseChange.ResetForNewGame();

		foreach ( var g in _built ) g?.Destroy();
		_built.Clear();

		var list = ActiveConfig.Current?.PerkMachines;
		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} perk machine(s) built" );
	}

	void Build( PerkMachineSpot spot )
	{
		var perk = PerkRegistry.Find( spot.PerkId );
		if ( perk is null )
		{
			Log.Warning( $"[nz] perk machine has unknown perk id '{spot.PerkId}' — not built" );
			return;
		}

		var path = ModelFor( spot.PerkId );
		if ( string.IsNullOrEmpty( path ) )
		{
			Log.Warning( $"[nz] no machine model for perk '{spot.PerkId}' ({perk.Name}) — not built" );
			return;
		}

		var model = Model.Load( path );

		// ⚠️ 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.
		if ( model is null || model.IsError )
		{
			Log.Warning( $"[nz] {perk.Name} machine model {path} "
				+ $"({(model is null ? "null" : "error model")}) — recompile it" );
			return;
		}

		var go = Scene.CreateObject();
		go.Name = $"Perk Machine — {perk.Name}";
		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 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 machine = go.Components.Create<PerkMachine>();
		machine.Spot = spot;

		_built.Add( go );
	}

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