Asset class defining zombie variant data for the game, including appearance, animation speed tiers, tuning (health, speed, scale, damage), body hitbox overrides, sounds, and special-case gameplay multipliers. It provides helpers to pick cue strings and to select an animation tier by speed, plus small supporting types and an enum.
using Sandbox;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// ZOMBIE/VARIANT — everything that distinguishes one walker from another.
///
/// The research is unambiguous that this belongs in data, not code: of 132
/// walker files in the GMod gamemode, 40 define ZERO functions and the average
/// is 1.9 — and 136 of the 257 total functions are two boilerplate functions
/// pasted verbatim 68 times. The base walker itself is 1,282 lines defining
/// three functions, one of which is dead.
///
/// So ~92% of walker variants are pure data. Get this asset right and the other
/// 131 walkers cost almost nothing.
///
/// Do NOT extend this to bosses/specials — those average 9-12 real functions
/// each with no empty files. They're genuine per-entity mechanics.
///
/// See Docs/NZOMBIES_REFERENCE.md §9.6.
/// </summary>
// TODO: GameResource is obsolete — s&box wants [AssetType] now. Left as-is
// deliberately: it's a warning, not an error, and I don't know AssetType's
// parameter shape well enough to change it without risking a build break.
// Swap it when we can verify the signature.
#pragma warning disable CS0618
[GameResource( "Zombie Variant", "zvar", "A walker variant — models, animations and tuning." )]
#pragma warning restore CS0618
public class ZombieVariant : GameResource
{
// ── VARIANT/APPEARANCE ───────────────────────────────────────────────────
// In the original a variant IS its model list — UpdateModel() picks a random
// {Model, Skin} and randomises every bodygroup (moo:175-184).
[Property] public List<ZombieModelEntry> Models { get; set; } = new();
// ── VARIANT/ANIMATION TIERS ──────────────────────────────────────────────
// The cleverest structure in the original (walker:301, resolved moo:6359).
// Animation set is chosen by the zombie's SPEED, not by round directly:
//
// walk 0 · run 36 · sprint 71 · supersprint 155
//
// Chained with the round speed curve (ZombieStats.SpeedForRound) plus per-zombie jitter
// rand(0,35) — and that jitter width exactly matches the first tier gap of
// 36, so tiers smear across rounds instead of the whole horde switching
// animation on one round boundary.
//
// R1 walk · R15 run · R29 sprint · R60 supersprint (the whole horde; 2026-10-05)
// R1 walk · R10 run · R19 sprint · R40 supersprint (the original's round*4-4, before)
//
// Selection is a ONE-SHOT FREEZE: each list collapses to a single random
// sequence cached for the zombie's lifetime, not re-rolled per frame.
[Property] public List<ZombieSpeedTier> SpeedTiers { get; set; } = new();
// ── VARIANT/TUNING ───────────────────────────────────────────────────────
// Multipliers over the round curves in ZombieStats.
/// <summary>
/// Multiplies the round's normal-zombie health. A boss is expressed here and nowhere else.
///
/// ⚠️ RANGE WIDENED TO 50 — it was 0.1-10, and Brutus is 15. A `Range` attribute clamps the
/// inspector slider, so a boss authored past the old ceiling would have been silently
/// unadjustable in the editor while the `.zvar` said otherwise.
/// ⚠️ AND TO 500 (2026-10-07): Shrek is 150, the Sawrunner 100, the Panzermorder 323 — the same trap again.
/// </summary>
[Property, Range( 0.1f, 500f )] public float HealthMultiplier { get; set; } = 1f;
[Property, Range( 0.1f, 5f )] public float SpeedMultiplier { get; set; } = 1f;
/// <summary>
/// An ABSOLUTE ground speed in units/sec, ignoring the round curve entirely. 0 = use the curve.
///
/// ⛔ A BOSS MUST NOT SPEED UP WITH THE ROUND, WHICH `SpeedMultiplier` CANNOT EXPRESS. That field
/// scales the walker clip's own ground speed, which is chosen from the round's speed rating - so
/// the same multiplier means a different speed every few rounds. A boss is a fixed quantity: he
/// moves the same on round 11 and round 71, and only his own state changes it.
///
/// ⚠️ IT BYPASSES `MinMoveSpeed` TOO, deliberately. That floor exists so an ordinary zombie is
/// never slower than its walk cycle looks; a boss is allowed to be slower than a walker, which is
/// most of what makes him feel heavy.
///
/// ⛔ BUT IT CANNOT GO BELOW ROUGHLY 42. `ZombieAI.AgentSpeed` clamps any non-zero speed up to
/// `MinAgentSpeed`, because the navmesh agent does not move AT ALL below about 35 u/s - measured,
/// not assumed. Upstream Brutus walks at 36, which is inside that dead band, so his authored
/// number cannot be used here. See `BrutusHelmet.HelmetOnSpeed`.
/// </summary>
[Property] public float FixedSpeed { get; set; } = 0f;
/// <summary>
/// Uniform scale for the rendered body. 1 = the model at its authored size.
///
/// ⛔ UPSTREAM HAS NO EQUIVALENT AND THIS IS A DELIBERATE DEPARTURE. `nz_zombie_boss_brutus.lua`
/// never calls `SetModelScale` — Brutus is simply a taller model, 80 units against a walker's 72,
/// which is 11% and does not read as "boss" next to a 72-unit player. Asked for, not assumed.
///
/// ⚠️ IT SCALES `HitRadius`, `BodyHeight` AND `AttackRange` WITH THE BODY, because all three
/// describe the visible creature. A boss twice the size with a walker's hitbox is one you shoot
/// past, and one with a walker's reach swings from a body-length away.
///
/// ⛔ IT DOES **NOT** SCALE `BodyRadius`, AND THAT EXCLUSION IS THE WHOLE REASON THIS IS SAFE.
/// `BodyRadius` is the NAV AGENT's radius, not a body dimension — a Brutus at 22 could not path at
/// all and stood still while the speed chain read perfectly correct. Scaling it would walk
/// straight back into that bug, silently, the moment someone raised this number.
/// </summary>
[Property, Range( 0.25f, 4f )] public float ModelScale { get; set; } = 1f;
[Property, Range( 0.1f, 5f )] public float DamageMultiplier { get; set; } = 1f;
/// <summary>Per-zombie speed jitter. 35 is the original's value and is
/// deliberately sized to the first tier gap — see SpeedTiers.</summary>
[Property] public int SpeedJitter { get; set; } = 35;
// ── VARIANT/DEATH ────────────────────────────────────────────────────────
[Property] public List<string> DeathSequences { get; set; } = new();
[Property] public bool CanGib { get; set; } = true;
[Property] public bool CanBecomeCrawler { get; set; } = true;
// ── VARIANT/BEHAVIOUR ────────────────────────────────────────────────────
// Only a handful of walkers need real code — 6 of 132 have a leap attack,
// and ~8 override a small hook. Everything else is covered by the data
// above. Keep this enum tiny; if it grows past a few entries, that's a
// signal the thing being added is really a special, not a walker.
[Property] public ZombieBehaviour Behaviour { get; set; } = ZombieBehaviour.Standard;
/// <summary>Skip barricades entirely and head straight for a player. The
/// original's dogs spawn INSIDE the play space from their own effect rather
/// than walking in from outside, so they never meet a window (dog logic in
/// nz_zombiebase_moo). A hound stopping to tear planks reads as a reskinned
/// walker more than anything else does.</summary>
[Property] public bool IgnoresBarricades { get; set; } = false;
/// <summary>
/// Is this a BOSS. Death Perception's M1 and M4 key off it. Off.
///
/// ⛔ AN EXPLICIT FLAG, BECAUSE `SpecialEnemies.IsSpecial` TOLD ME TO MAKE ONE. That method
/// derives specialness from the asset FILENAME and its own note says: "Drop rates are the only
/// caller today. If anything gameplay-critical ever depends on this, give ZombieVariant an
/// explicit flag instead - deriving specialness from an asset filename is fine for a loot roll
/// and too fragile for a rule." Death Perception M1 is a x3 damage multiplier. That is a rule.
///
/// ⚠️ BOSS AND SPECIAL ARE NOT THE SAME THING. A hellhound is a special and not a boss; it
/// should keep its loot rates and grant no Bounty Hunter points. Anything that wants "unusual
/// enemy" for a DROP still asks `SpecialEnemies.IsSpecial`; anything that wants "boss" for a
/// RULE asks this.
/// </summary>
[Property] public bool IsBoss { get; set; } = false;
// ── VARIANT/BODY ─────────────────────────────────────────────────────────
// ⛔ WITHOUT THESE, A QUADRUPED GETS A STANDING HUMAN'S CAPSULE — 72 tall and
// 9 wide around a body that is 23 x 71 x 60. Shots pass through the visible
// dog and hit nothing, which is exactly what "cannot be hit" looked like.
// Null means "leave the walker default", so no existing variant changes.
/// <summary>Yaw correction, degrees, when the art is not authored facing +X.
///
/// ⛔ MEASURE THIS, NEVER EYEBALL IT. The hound's run clip translates its root
/// by dx=0.00, dy=-735.87 — pure -Y — so the model faces -Y and needs +90 to
/// line up with forward. Its bounds say the same thing: 23 x 71 x 60, long
/// axis on Y. Both come free from files already on disk, and either one beats
/// nudging the number until a screenshot looks right.</summary>
[Property] public float ModelYawOffset { get; set; } = 0f;
/// <summary>
/// The same correction about the other two axes, for a rig that is not merely turned.
/// </summary>
///
/// ⚠️ ALMOST ALWAYS ZERO, AND THAT IS NOT AN OVERSIGHT. A model authored facing the wrong way
/// needs a yaw and nothing else; pitch and roll exist for a rig exported lying down or rolled
/// onto its side, which is a conversion fault worth fixing in the DMX rather than here. They are
/// on the variant so a boss can be dialled in live with `nz_model_turn` and the answer written
/// down — not so that every model carries three numbers where one would do.
[Property] public float ModelPitchOffset { get; set; } = 0f;
/// <summary>See <see cref="ModelPitchOffset"/>.</summary>
[Property] public float ModelRollOffset { get; set; } = 0f;
/// <summary>Floor under the round's speed roll, so a variant can be pinned to
/// a tier regardless of round. Null leaves the round curve alone.
///
/// Hellhounds ALWAYS run in this version of the gamemode — they do not have a
/// walk to scale up from, so letting round 1 pick the walk tier gives you a
/// plodding dog rather than an early-round one.</summary>
[Property] public int? MinSpeedRating { get; set; } = null;
/// <summary>Capsule height. Walker default 72.</summary>
[Property] public float? BodyHeight { get; set; } = null;
/// <summary>Capsule radius. Walker default 9.</summary>
[Property] public float? BodyRadius { get; set; } = null;
/// <summary>Melee reach. Walker default 40.</summary>
[Property] public float? AttackRange { get; set; } = null;
/// <summary>Sphere used for bullet hits when a model has no hitboxes.
/// Walker default 16.</summary>
[Property] public float? HitRadius { get; set; } = null;
// ── VARIANT/SOUND ────────────────────────────────────────────────────────
// ⛔ THE SEVEN CUES IN ZombieAI WERE HARDCODED TO `nz.zombie.*`. A variant
// could change the body, the clips, the speed and the health and STILL sound
// exactly like a walker — user, on the first hellhound: "its using normal
// zombie AI and sounds and hitbox".
// Empty means "use the walker cue", so no existing variant changes.
/// <summary>Ambient voice while moving.</summary>
[Property] public string IdleSound { get; set; } = "";
/// <summary>Ambient voice while at the fastest tier. Falls back to
/// <see cref="IdleSound"/> when blank.</summary>
[Property] public string SprintSound { get; set; } = "";
/// <summary>Ambient voice within <see cref="CloseSoundRange"/> of its target.
/// Blank disables the close/far split entirely.</summary>
[Property] public string CloseSound { get; set; } = "";
/// <summary>Range at which <see cref="CloseSound"/> takes over.</summary>
[Property] public float CloseSoundRange { get; set; } = 400f;
/// <summary>The swing/lunge, played whether or not it connects.</summary>
[Property] public string AttackSound { get; set; } = "";
/// <summary>The blow LANDING on a player.</summary>
[Property] public string HitSound { get; set; } = "";
[Property] public string DeathSound { get; set; } = "";
[Property] public string SpawnSound { get; set; } = "";
/// <summary>Footfall at walking tiers.</summary>
[Property] public string StepSound { get; set; } = "";
/// <summary>Footfall at running tiers. Falls back to
/// <see cref="StepSound"/> when blank.</summary>
[Property] public string StepRunSound { get; set; } = "";
/// <summary>
/// Trauma added to the camera each time this zombie's foot lands. 0 = none, which is every
/// ordinary zombie.
///
/// ⛔ OPT-IN, AND IT HAS TO BE. Footsteps fire for the whole horde — twenty walkers stepping
/// would be a permanent tremor, and a shake that never stops is just a broken camera. Weight is
/// the point: only something heavy enough to feel should register.
///
/// ⚠️ IT FALLS OFF WITH THE SQUARE OF DISTANCE over <see cref="StepShakeRange"/>, so this is
/// the value at zero distance and nothing like the average.
/// </summary>
/// <summary>
/// Seconds between idle/sprint voice cues for this variant. 0 = the shared default.
///
/// ⛔ THE SHARED DEFAULT IS SPEED-BASED AND A BOSS BREAKS IT. It picks 1.2-2.6s above 180 u/s and
/// 2.5-6s below, which suits a walker whose voice is a one-second groan. Brutus is above that
/// threshold at BOTH his speeds (110 and 220), so he was taunting every ~2s in full sentences.
///
/// ⚠️ UPSTREAM HAS NO SPEED SPLIT AT ALL — `nz_zombiebase_moo.lua:861` is a flat
/// `SoundDelayMin 5` / `SoundDelayMax 6` for everything. Ours is a departure that works for the
/// horde and not for a boss, so this is the per-variant escape hatch rather than a change to the
/// walker cadence, which is tuned and play-tested.
/// </summary>
[Property] public float VoiceIntervalMin { get; set; } = 0f;
/// <summary>Upper end of the gap. See <see cref="VoiceIntervalMin"/>.</summary>
[Property] public float VoiceIntervalMax { get; set; } = 0f;
[Property] public float StepShake { get; set; } = 0f;
/// <summary>How far a step can be felt. Ignored when <see cref="StepShake"/> is 0.</summary>
[Property] public float StepShakeRange { get; set; } = 600f;
/// <summary>
/// The Prisma build part the FIRST of these killed drops. 0 = none, which is every variant
/// that has not been told otherwise.
/// </summary>
///
/// ⛔ THE FIRST ONE ONLY, AND "FIRST" MEANS THE PART IS NOT ALREADY OUT THERE. The check is
/// `BuildPartManager.AlreadyAwarded`, which covers both a part lying uncollected on the floor
/// and one the team has already taken — a counter of napalms killed would drop a second piece
/// to a squad that walked past the first.
///
/// ⚠️ ON THE VARIANT RATHER THAN HARDCODED IN THE DEATH PATH, so which enemy owes which piece
/// is a line in a `.zvar` and not a recompile. `napalm.zvar` carries 2.
///
/// ⚠️ IT DOES NOT MAKE THE PART OBTAINABLE BY ITSELF. If this variant never spawns on a map,
/// the piece is unreachable there — `nz_buildpart_list` says which of the three have a source
/// and which have none.
[Property] public int DropsBuildPart { get; set; } = 0;
/// <summary>
/// How fast this variant travels as a multiple of a NORMAL zombie on the same round.
/// 1 = the horde's pace. Pests and shriekers run 1.5.
/// </summary>
///
/// ⛔ THIS IS NOT `SpeedMultiplier`, AND USING THAT ONE FOR THIS WOULD MAKE THEM SKATE.
/// `SpeedMultiplier` scales `_clipGroundSpeed` — it says "this variant's animation travels
/// further per cycle than the table thinks", which is a CORRECTION to a measurement. The
/// playback rate is `velocity / _clipGroundSpeed`, so scaling both sides together leaves that
/// ratio at 1.0: the zombie crosses the room faster with its legs cycling at the authored pace.
/// This one scales the MOVE speed alone, so the legs speed up to match — exactly what
/// `TierSpeedScale` does, and it is applied in the same place for the same reason.
///
/// ⛔ AND IT IS APPLIED *AFTER* `MinMoveSpeed`, WHICH IS THE WHOLE DIFFERENCE BETWEEN "1.5x" AND
/// "1.5x except when it matters". That floor decides the speed for most of the early game — 14
/// of the walk tier's 15 clips are floored up to it — so a multiplier applied before it is
/// swallowed whole, and a pest on round 3 would move at exactly a walker's pace. After the
/// floor, "1.5x a normal zombie on this round" is true on every round, including the ones where
/// the floor is what a normal zombie is doing.
///
/// ⚠️ IT DOES NOT TOUCH A BOSS. `FixedSpeed`/`SpeedOverride` short-circuit the whole chain by
/// design — a Brutus moves at his authored pace on round 11 and on round 71 — so this sits in
/// the non-fixed branch with the tier scale.
///
/// ⚠️ THE ANIMATION CEILING IS `MaxAnimRate` (2.5) AND 1.5 ONLY JUST FITS UNDER IT. The top
/// tier already runs at 1.6x, so 1.6 x 1.5 = 2.4 — past the clamp the legs stop keeping up and
/// the thing skates after all. Anything above about 1.55 on a SuperSprint-tier variant needs
/// `MaxAnimRate` raised with it.
[Property] public float MoveSpeedScale { get; set; } = 1f;
/// <summary>
/// The share of this variant's MAX health a NUKE takes off. 1 = killed outright, which is
/// every variant that has not been told otherwise. 0 = the nuke does nothing to it at all.
/// </summary>
///
/// ⛔ A SHARE OF MAX HEALTH, NOT A DAMAGE FIGURE, because what it has to survive scales with
/// the round and a flat number does not. A napalm at 0.3 is four nukes on round 11 and four
/// nukes on round 51; the same rule written as "900 damage" is an instant kill early and a
/// scratch late, and nothing in the file would say which round it stopped making sense on.
///
/// ⛔ AND IT IS DATA BECAUSE THE SET CUTS ACROSS EVERY FLAG WE ALREADY HAVE. Asked for as
/// *"nukes do not insta kill napalms, shriekers, brutus and oberon / instead they deal 30% of
/// max hp, and do nothing to oberon"* — and `IsBoss` is false on the shrieker while
/// `SpecialEnemies.IsSpecial` is true of the pest and the hellhound, both of which still die.
/// Either test would have been right about three enemies and wrong about three others; the
/// list of names it would have taken instead belongs in the `.zvar`s it describes.
///
/// ⚠️ IT IS NOT A DAMAGE RESISTANCE. Nothing else reads it — bullets, fire and the shrieker's
/// own death are unchanged — so a value here cannot make anything tougher by accident.
[Property, Range( 0f, 1f )] public float NukeDamageFraction { get; set; } = 1f;
/// <summary>
/// What INSTA-KILL does to this variant. Empty = killed by any hit, which is every variant that
/// has not been told otherwise. A number = it is NOT killed, and every hit lands that many times
/// harder while the powerup runs.
/// </summary>
///
/// ⛔ ASKED FOR AS *"insta kill should not insta kill napalm, brutus, shriekers and oberon / with
/// insta kill the player should deal 3x damage to them"* — the same four the nuke now spares,
/// and for the same reason the nuke's rule is data rather than a list of names:
/// `NukeDamageFraction` records why no flag the project already had could express the set.
///
/// ⚠️ NULLABLE, AND THE NULL IS THE MEANING. "Killed outright" is not a multiplier — there is no
/// number that means it — so the default is the absence of one rather than a sentinel like 0 or
/// -1 that a later edit could mistake for a real value. The variant already speaks this way for
/// `BodyHeight` and `AttackRange`: empty means "the ordinary rule".
///
/// ⚠️ IT MULTIPLIES THE HIT, NOT THE HEALTH BAR, so everything else still applies on top: a
/// helmeted Brutus still takes his helmet's cut, and a headshot still pays its 2.5. Insta-Kill
/// makes the player three times as dangerous to him; it does not change how he is fought.
[Property] public float? InstaKillMultiplier { get; set; } = null;
/// <summary>
/// What the Prisma's RESONANCE does to this variant. Empty = the ordinary fuse, 9% of max health
/// every quarter second. A number = that share of the WEAPON'S damage instead, once a second, for
/// as long as the fuse burns.
/// </summary>
///
/// ⛔ ASKED FOR AS *"the wonder weapon DoT should not affect napalms, shriekers, brutus and oberon
/// the same way, instead it deals 10% of the weapon's damage once per second"* — the same four the
/// nuke and Insta-Kill spare, and data for the reason `NukeDamageFraction` gives.
///
/// ⚠️ A SHARE OF THE WEAPON, NOT OF THE VICTIM, and that is the whole change. The ordinary fuse is
/// proportional, so it killed a Brutus in the same three seconds it kills a walker. This scales
/// with the gun instead — 400 a second from an unpacked Prisma, 1,080 from MK1 — and leaves his
/// health to decide how long he lasts.
///
/// ⚠️ ONLY THE TICK CHANGES. The fuse still lights, still glows and still bursts when he dies, so
/// a special caught in a crowd passes the chain on like anything else. See `PrismaChain.Infect`.
[Property] public float? ResonanceShare { get; set; } = null;
/// <summary>Pick a variant cue, or the walker's when the variant is silent on
/// it. Static and null-tolerant so ZombieAI can call it with no variant
/// assigned at all — that is the common case, and it must stay free.</summary>
public static string Cue( string variantCue, string fallback )
=> string.IsNullOrWhiteSpace( variantCue ) ? fallback : variantCue;
/// <summary>Resolve the animation tier for a given speed. Highest threshold
/// that the speed meets or exceeds wins.</summary>
public ZombieSpeedTier TierForSpeed( float speed )
{
ZombieSpeedTier best = null;
foreach ( var tier in SpeedTiers )
{
if ( speed < tier.MinSpeed ) continue;
if ( best is null || tier.MinSpeed > best.MinSpeed ) best = tier;
}
return best;
}
}
public enum ZombieBehaviour
{
Standard,
LeapAttack, // the 6 exo/greenflu/headcrab walkers
}
public class ZombieModelEntry
{
[Property] public Model Model { get; set; }
[Property] public int Skin { get; set; } = 0;
/// <summary>Original randomises every bodygroup on spawn (moo:181-183).</summary>
[Property] public bool RandomiseBodygroups { get; set; } = true;
}
/// <summary>
/// VARIANT/TIER — one animation set, selected by speed.
/// Thresholds from walker:302/492/685/854.
/// </summary>
public class ZombieSpeedTier
{
[Property] public string Name { get; set; } = "walk";
/// <summary>Speed at or above which this tier applies. 0 / 36 / 71 / 155.</summary>
[Property] public float MinSpeed { get; set; } = 0f;
/// <summary>Ground speed the movement clips were AUTHORED at, u/s. 0 means
/// "look it up in the walker table", which is right for walkers and useless
/// for anything else.
///
/// ⛔ THE WALKER TABLE IS KEYED BY CLIP NAME AND KNOWS NOTHING ABOUT
/// `a_nz_dog_run`. It returned 0, so MoveSpeed fell to the MinMoveSpeed floor
/// (55) and every hound plodded — while PlaybackRate, being speed/authored,
/// collapsed to its 0.05 clamp and the legs barely moved. One missing number
/// produced both "it walks slowly" and "the animation is frozen".
///
/// Measure it, don't guess: the root bone's travel over the clip in the
/// decompiled SMD. For this dog set that is run 367.9, trot 138.3, walk 53.8.</summary>
[Property] public float GroundSpeed { get; set; } = 0f;
[Property] public List<string> MovementSequences { get; set; } = new();
[Property] public List<string> AttackSequences { get; set; } = new();
[Property] public List<string> SpawnSequences { get; set; } = new();
[Property] public List<string> PassiveSounds { get; set; } = new();
}