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.
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 );
}
}