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