Zombies/WalkerSkins.cs

Static helper for zombie walker skins. It defines constant ids for available skins, maps a skin id to a .zvar asset path, loads a ZombieVariant from the ResourceLibrary, and exposes the currently requested skin variant for the active map config.

File AccessNetworking
using Sandbox;

namespace NZombies;

/// <summary>
/// WALKER/SKINS — which body the ORDINARY horde wears on this map.
///
/// A skin is not a new enemy. `ZombieVariant` already carries a MODEL SET and `ZombieAI` already
/// rolls one entry per zombie, so a second walker look is an asset plus a name — no new AI, no new
/// animations, no new spawner. That is the whole reason the roster in `Docs/WALKER_ROSTER.md` is
/// 132 entries deep in the original: upstream walkers differ only in the `.mdl` files they draw.
///
/// ⛔ RESOLVED IN `ZombieCommands.SpawnAt`, NOT AT THE FIVE CALL SITES. The round spawner, the dev
/// commands and the horde test all reach the walker through that one function, and a skin applied
/// per-caller would mean `nz_horde` testing a different body from the one a real round spawns —
/// which is exactly the class of bug that makes a visual change untestable.
///
/// ⚠️ IT ONLY FILLS IN A **NULL** VARIANT. Hellhounds, Brutus and every future special arrive with
/// their variant already chosen; a skin that overwrote those would reskin the bosses too.
/// </summary>
public static class WalkerSkins
{
	/// <summary>Empty means the stock walker — `ZombieAI.DefaultBodyModel`, no variant at all.
	///
	/// ⚠️ THE ABSENCE OF A VARIANT IS A REAL STATE, not a variant that happens to be the walker.
	/// A `.zvar` also carries speed tiers, death sequences and tuning, and the stock walker's come
	/// from `WalkerAnimations` and `ZombieStats` rather than from an asset. Expressing "no skin" as
	/// an empty string keeps that path untouched instead of reimplementing it in data.</summary>
	public const string Stock = "";

	// ── SKINS/ROSTER ─────────────────────────────────────────────────────────
	// Registered skins go here, one const + one `Names` entry + one `.zvar`.

	/// <summary>
	/// The Origins templar knights — BO2 `t6/tomb`, upstream's `nz_zombie_walker_origins_templar_classic`.
	///
	/// ⚠️ TWO BODIES, AND THAT IS THE POINT OF A MODEL LIST. Upstream's entity names
	/// `moo_codz_t6_tomb_knight` and `..._templar` together, and `ZombieAI` rolls one per zombie —
	/// so the horde is mixed rather than thirty copies of one body, which is more variety than the
	/// stock walker has.
	///
	/// ⛔ THE STRING IS PERSISTED IN EVERY MAP CONFIG THAT USES IT. Same rule as
	/// <see cref="SpecialEnemies"/>: add ids freely, change existing ones never.
	/// </summary>
	public const string OriginsTemplar = "origins_templar";

	// ── THE PER-MAP SET (the user, 2026-10-05) ──────────────────────────────────────────────────────────────────────
	// One GMod walker set per map, ported by `Tools/walker_skin_port.py` from `Docs/walker_skins_manifest.json`; the table
	// of which map wears which is `Docs/WALKER_SKINS_PER_MAP.md`. Each id is the GMod entity's name without
	// `nz_zombie_walker_`, and like every id here it is persisted in map configs: add freely, never rename.
	public const string FiveClassic = "five_classic";            // Canyon Labs — BO1 Pentagon
	public const string Mannequin = "mannequin";                // Topdown — BO4 Nuketown mannequins
	public const string Clown = "clown";                        // Defocus — IW Spaceland clowns
	public const string ExoBrg = "exo_brg";                     // City Uprising — AW civilians (exo_brg + exo_wage)
	public const string Ix = "ix";                              // Fortaleza — BO4 IX
	public const string DieRise = "dierise";                    // Grid — BO2 Die Rise
	public const string Buried = "buried";                      // Laketown — BO2 Buried
	public const string Nuketown = "nuketown";                  // Island — BO2 Nuketown
	public const string GorodKrovi = "gorodkrovi";              // Skyscraper — BO3 Gorod Krovi
	public const string Ascension = "ascension";                // M3 Facility — BO3 Ascension
	public const string GreenRun = "greenrun";                  // Countdown, Port Klax — BO2 TranZit
	public const string AscensionClassic = "ascension_classic"; // Power — BO1 Ascension
	public const string Moon = "moon";                          // Warhammer 40K — BO3 Moon (+ its guards and techs)
	public const string QuartzLabHazmat = "quartz_lab_hazmat";  // Chaser — BO6 lab subjects + hazmat
	public const string Titanic = "titanic";                    // Cruise — BO4 Voyage of Despair, crew + first class
	public const string Mansion = "mansion";                    // Office, Casino — Voyage's first class (titanic's own models)
	public const string Park = "park";                          // Replica — IW Spaceland visitors

	/// <summary>Every registered skin, in menu order. `Stock` is first and is always valid.</summary>
	public static string[] Names => new[]
	{
		Stock, OriginsTemplar, FiveClassic, Mannequin, Clown, ExoBrg, Ix, DieRise, Buried, Nuketown, GorodKrovi, Ascension,
		GreenRun, AscensionClassic, Moon, QuartzLabHazmat, Titanic, Mansion, Park,
	};

	/// <summary>
	/// Asset path for a skin id.
	///
	/// ⚠️ ANY `.zvar` IS ACCEPTED, NOT ONLY A REGISTERED NAME. That is deliberate and it is what
	/// makes the mechanism testable before a single skin asset exists: `nz_walker_skin brutus`
	/// turns the whole horde into Brutus, which proves the resolution, the spawn path and the
	/// per-zombie model roll end to end using assets already in the project.
	///
	/// ⛔ SO AN UNKNOWN NAME IS A MISSING ASSET, NOT A REJECTED ID, and `VariantFor` says so out
	/// loud rather than quietly spawning stock walkers.
	/// </summary>
	public static string PathFor( string name )
		=> string.IsNullOrWhiteSpace( name ) ? null : $"zombies/{name.Trim().ToLowerInvariant()}.zvar";

	/// <summary>
	/// Load a skin's variant, or null for the stock walker.
	///
	/// ⚠️ NOT CACHED, for the reason written at the top of <see cref="SpecialEnemies"/>: the
	/// resource library already caches the asset, and a static here would hold a stale handle
	/// across a hotload for no gain.
	/// </summary>
	public static ZombieVariant VariantFor( string name )
	{
		var path = PathFor( name );
		if ( path is null ) return null;

		var variant = ResourceLibrary.Get<ZombieVariant>( path );

		// ⛔ WARN, DO NOT FALL SILENT. A mis-typed skin name and a skin whose model failed to
		// import look identical in game — ordinary zombies — and without this line the only
		// symptom is "the skin I set did nothing".
		if ( variant is null )
			Log.Warning( $"[nz-skin] walker skin '{name}' → '{path}' did not load; using the stock walker" );

		return variant;
	}

	/// <summary>The skin this map asks for, already resolved. Null = stock walker.</summary>
	public static ZombieVariant Current => VariantFor( ActiveConfig.Current?.Zombies?.WalkerSkin );
}