Zombies/SizzlerZombie.cs

A game AI component for the "Sizzler" special zombie. Controls spawn effects, ignition behavior (trail, sprint choice), attack selection, death blast damage and visuals, per-machine trail/fire effects, and debug console commands to report and retune parameters.

File Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// The Sizzler — CoD WWII's fire runner, ported 2026-10-07 from GMod nZombies' `nz_zombie_special_siz` (GhostlyMoo's), the user's
/// pick of three WWII enemies. A SPECIAL, NOT A BOSS: it comes during an ordinary round beside the walkers (`AmbientSpecials`) or
/// makes up a special round, and the round waits for it (`sizzler.zvar`, `IsBoss` false). Spec: `Docs/boss_specs/sizzler.md`.
///
///   • IT CLIMBS OUT AND WALKS (`nz_s2_core_walk_v10-12`, 46 u/s), SILENT (upstream's walk voice is `mute_00.wav`), with half a
///     walker's health (upstream's `GetZombieHealth() * 0.5`, the variant's 0.5). Every step burns, walking or lit: the napalm step
///     (the variant's) and under it a fire layer with embers at the foot (upstream's `SWTFootstepsSounds` and `bo3_napalm_fs`), on
///     every machine. ⚠️ Its WWII arrival is a burst of embers, the gib, and Dead Wire's zap for HL2's weld (in no archive).
///   • IT IGNITES, ONCE (upstream's `AI`): at its target in sight within <see cref="IgniteRange"/> (950, origin to origin), or
///     <see cref="EnrageMin"/>-<see cref="EnrageMax"/> s (2-7) after it arrives — whichever comes first, and only with a target.
///     It stops for a cry (`nz_stn_idle_react_f_v3/v4`, 1.4/1.57 s; the `convert` scream on its `generic_taunt` frame, 0.4 s in),
///     a plasma trail streams from its chest (upstream's `SpriteTrail` on `chest_fx_tag`, (255, 45, 0), 0.75 s — here
///     `BossShotLook`, unlit, a flame at its heart), and it SPRINTS FOR GOOD, shrieking: one of upstream's eight sprints — the slow
///     four (245.9 u/s) or the fast four (321.4), a coin flip (<see cref="FastChance"/>).
///     ⚠️ Upstream's third cry, `nz_l4d_violentalert_f`, is an L4D clip left off the model; the cries' 14-30 u of creep is root
///     motion the port strips.
///   • ITS SWING, a walker's 50 (`DamageMultiplier` 1), and uncapped like a walker's: upstream's super-sprint lunges, or the six
///     standing swings at a target all but still within <see cref="StandRange"/> (upstream's base `Attack`; `TeslaBoss`'s copy).
///   • IT BLOWS UP WHEN IT DIES — lit or not, however it dies (shot, knifed, nuked, still climbing out), but never when it is only
///     removed (upstream's `PostDeath` → `Explode(25, true)`). From its middle, every player up within <see cref="BlastRadius"/>
///     (200) in the open takes <see cref="BlastWeight"/> of its swing at the middle (upstream's 25 of 50), falling to nothing at the
///     rim, ⛔ never more than `BossBase.MaxHit`; every other zombie in it takes <see cref="ZombieBlast"/> (25, flat, upstream's).
///     A fireball (`BlastEffect`), HL2's explosion, a shake out to 400 (upstream's).
///     ⚠️ UPSTREAM'S BLAST REACHED ITS OWN TARGET ONLY: it named that player the attacker, so for them it was their own blast (PhD
///     Flopper spared them) and for everyone else friendly fire (refused). Here, anyone up, PhD or not — the Panzerhund's death blast.
///     ⚠️ ITS KILLS PAY NOBODY: upstream credited them to that player; here a blast with no owner, as the Napalm Zombie's.
///   • ITS WINDOWS AND LEDGES: its own traverse at its pace — `nz_base_zombie_walk_win_trav_m_01` walking, `run_` lit slow, `sprint_`
///     lit fast — through `ZombieAI.VaultClipOverride` (added with it: the window vault read only the walker's mantles) and
///     `ClimbClipOverride`. ⚠️ Upstream used the sprint one at every pace.
///   • LEFT OUT: the electrocution set (`nz_death_elec_1-4`, the electrobolt stuns, the `trans_shriek` voice — this game has no
///     electric damage to route them by, `WalkerDeaths.Electric`), the Turned sprints (no Turned here), the stand idles (the AI has
///     no idle clip), and the map option that lets its blast set walkers alight (upstream's `burningspread`).
///
/// ⚠️ A SPECIAL ON `BossBase` FOR ITS HELPERS — beats, shared clips, parts, the capped hit, `Say`. Nothing in it is a boss's: no bar,
/// no music, no round rule; `Health` asks it for a damage scale and gets 1.
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the ignite travels as a part (`SetPart( LitPart )` → <see cref="ShowPart"/>): every machine
/// lights its own trail, and a watching machine's puppet takes the sprint's legs (the host's speed does not travel — the Panzerhund's
/// note). The blast is the host's; its fireball, sound and shake are shared. The fire steps are each machine's, off the clip it shows.
/// </summary>
public sealed class SizzlerZombie : BossBase
{
	protected override string Tag => "[nz-siz]";

	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1.

	static float? _range, _enrageMin, _enrageMax, _fastChance, _standRange, _standSpeed;
	/// <summary>It ignites at its target in sight this near: 950, origin to origin (upstream's `TargetInRange(950)`).</summary>
	public static float IgniteRange { get => _range ?? 950f; set => _range = value; }
	/// <summary>Or this long after it arrives, target in sight or not: 2-7 s (upstream's `CurTime() + math.random(2,7)`).</summary>
	public static float EnrageMin { get => _enrageMin ?? 2f; set => _enrageMin = value; }
	public static float EnrageMax { get => _enrageMax ?? 7f; set => _enrageMax = value; }
	/// <summary>The chance it sprints with the fast four: 0.5 (upstream picks one of its eight sprints, four of each).</summary>
	public static float FastChance { get => _fastChance ?? 0.5f; set => _fastChance = value; }
	/// <summary>It plants for a standing swing at a target this near and this still: 65, and 3.9 u/s (upstream's `speed² &lt; 15`).</summary>
	public static float StandRange { get => _standRange ?? 65f; set => _standRange = value; }
	public static float StandSpeed { get => _standSpeed ?? 3.9f; set => _standSpeed = value; }

	static float? _blastRadius, _blastWeight, _zombieBlast;
	/// <summary>Its death blast's reach from its middle: 200 (upstream's `FindInSphere( WorldSpaceCenter(), 200 )`).</summary>
	public static float BlastRadius { get => _blastRadius ?? 200f; set => _blastRadius = value; }
	/// <summary>
	/// What the blast takes from a player at its middle, against its swing: 0.5 — upstream's 25 against 50, linear to nothing at the
	/// rim. 15 at round 6, 25 at 12, 37.5 at 20, 45 at 31; ⛔ never more than `MaxHit`.
	/// </summary>
	public static float BlastWeight { get => _blastWeight ?? 0.5f; set => _blastWeight = value; }
	/// <summary>What the blast takes from another zombie at its middle: 25, flat, whatever the round (upstream's `Explode(25)`). 0, none.</summary>
	public static float ZombieBlast { get => _zombieBlast ?? 25f; set => _zombieBlast = value; }

	static float? _trailWidth, _trailSeconds, _core;
	/// <summary>
	/// Its trail, lit: 24 wide at its chest, thinning over 0.75 s of its path (upstream's plasma trail, 45 → 20 over 0.75 s — a soft
	/// texture; a solid line at 45 is a wall). A flame 10 across at its heart. Each machine reads these when it lights.
	/// </summary>
	public static float TrailWidth { get => _trailWidth ?? 24f; set => _trailWidth = value; }
	public static float TrailSeconds { get => _trailSeconds ?? 0.75f; set => _trailSeconds = value; }
	public static float CoreSize { get => _core ?? 10f; set => _core = value; }

	// its clips (30 fps) — ⛔ `const`, never `static readonly` (INSTRUCTIONS.md §1)
	const string ClipCry3 = "nz_stn_idle_react_f_v3", ClipCry4 = "nz_stn_idle_react_f_v4",
		ClipTravWalk = "nz_base_zombie_walk_win_trav_m_01", ClipTravRun = "nz_base_zombie_run_win_trav_m_01",
		ClipTravSprint = "nz_base_zombie_sprint_win_trav_m_01";
	const float Cry3Len = 42f / 30f, Cry4Len = 47f / 30f, CryVoxAt = 12f / 30f;

	// the standing swings (upstream's `StandAttackSequences`); the moving ones are the variant's
	const string StandL1 = "nz_iw7_cp_zom_stand_attack_l_01", StandL2 = "nz_iw7_cp_zom_stand_attack_l_02",
		StandR1 = "nz_iw7_cp_zom_stand_attack_r_01", StandR2 = "nz_iw7_cp_zom_stand_attack_r_02",
		Stand2h1 = "nz_zom_core_stand_attack_2h_01", Stand2h2 = "nz_zom_core_stand_attack_2h_02";

	/// <summary>Its clips' own speeds — `sizzler.zvar`'s tiers are picked by them (walk 0, slow sprint 71, fast sprint 280).</summary>
	const float WalkSpeed = 46.0f, SlowSprint = 245.9f, FastSprint = 321.4f;

	/// <summary>Its middle, where the blast goes off: upstream's `WorldSpaceCenter`, half its 72-tall box.</summary>
	const float Middle = 36f;

	/// <summary>Upstream's `util.ScreenShake( 20, 255, 1.5, 400 )` — the Panzerhund's strength for the same call, upstream's reach.</summary>
	const float ShakeStrength = 0.7f, ShakeRange = 400f;

	/// <summary>How often it looks for its target (upstream asks once a pass of its AI), and how near embers are worth drawing.</summary>
	const float LookEvery = 0.2f, EmberEar = 1500f;

	/// <summary>Not a bodygroup: lit or not, and which sprint (0 walking, 1 the slow four, 2 the fast four), taken by <see cref="ShowPart"/>.</summary>
	const string LitPart = "siz_lit";

	const string FlameSprite = "sprites/nz/napalm_flame.sprite";

	// upstream's trail colour (255, 45, 0); its steps' embers a little yellower
	static Color Plasma => new( 1f, 0.176f, 0f );
	static Color Ember => new( 1f, 0.45f, 0.1f );

	// ══ state ════════════════════════════════════════════════════════════════
	//
	// ⚠️ A HOTLOAD INTO A LIVE ONE LEAVES NEW FIELDS AT 0/false/null: unlit, its timer due — it lights at its next look, as upstream's
	// would have by then.

	bool _lit, _fast;
	float _igniteAt, _nextLook;
	List<string> _standing;

	// its target's ground speed, measured frame to frame (`TeslaBoss`'s); 999 until measured, so a new target never reads as still
	GameObject _tracked;
	Vector3 _trackedAt;
	float _targetSpeed = 999f;

	// every machine: the trail's anchor, and the last clip/time its fire steps were read at
	GameObject _fire;
	string _stepClip;
	float _stepT;

	enum Beat { Cry }

	public bool Lit => _lit;

	/// <summary>Its pace: its walk, or the sprint it rolled.</summary>
	float Pace => !_lit ? WalkSpeed : _fast ? FastSprint : SlowSprint;

	protected override void HostStart()
	{
		// ⛔ NONE OF THE WALKER'S MANTLES ON ITS MODEL: its own traverse at its pace, through a window and over a ledge alike
		SetTraverse();
		_igniteAt = Time.Now + Game.Random.Float( EnrageMin, MathF.Max( EnrageMin, EnrageMax ) );

		// upstream's WWII arrival (`OnSpawn`): sparks and embers, the gib, the weld — HL2's `ambient/energy/weld1/2` is in no archive,
		// Dead Wire's zap stands in (the Tesla Zombie's stand-in for HL2's zaps)
		var at = WorldPosition;
		BossFx.BurstShared( at + Vector3.Up * 12f * Size, 56f * Size, Ember, 10 );
		NZSound.PlayShared( "nz.sizzler.gib", at + Vector3.Up * 30f * Size );
		NZSound.PlayShared( "nz.pop.deadwire.shock", at + Vector3.Up * 30f * Size );

		Say( $"spawned — {Hp?.Max ?? 0f:0} hp · walks until its target is in sight within {IgniteRange:0}, or for {_igniteAt - Time.Now:0.#}s"
			+ $" · blast {BlastRadius:0}u · hitboxes {Ai.HitboxCount}" );
	}

	/// <summary>Its look, on every machine: a watching machine's legs, the trail, the fire under its steps.</summary>
	protected override void EveryFrame()
	{
		TickPuppetPace();
		TickTrail();
		TickFireSteps();
	}

	protected override void HostFrame()
	{
		if ( Ai.State == ZombieState.Spawning ) return;

		TrackTarget();
		PickAttackSet();
		if ( !_lit ) TickIgnite();
	}

	protected override void OnBeat( int beat )
	{
		// the cry's `generic_taunt` frame: upstream's `TauntSounds`
		if ( beat == (int)Beat.Cry ) NZSound.PlayShared( "nz.sizzler.convert", Voice );
	}

	protected override void Died( bool host )
	{
		Douse();
		if ( host ) Blast( "died" );
	}

	/// <summary>
	/// A watching machine told it lit, and with which sprint (`NZNet.BossPart`): its trail, and its legs (<see cref="EveryFrame"/>).
	/// ⚠️ THE HOST'S OWN ECHO LIGHTS THE HOST'S TRAIL; its `_lit` and `_fast` are what it sent.
	/// </summary>
	public override bool ShowPart( string group, int choice )
	{
		if ( group != LitPart ) return false;
		if ( Ai.IsValid() && Ai.IsPuppet )
		{
			_lit = choice != 0;
			_fast = choice == 2;
		}

		if ( _lit ) Light();
		else Douse();
		return true;
	}

	// ══ the host ═════════════════════════════════════════════════════════════

	/// <summary>Carried by something the AI drives — a nav link, a window (held at it, or climbing through), an arc (the Meúchler's test).</summary>
	bool Busy => Ai.CrossingLink || Ai.OnLink || Ai.ParkedAt is not null || Ai.Arcing
		|| (Ai.CurrentClip ?? "").Contains( "_win_trav_" );

	/// <summary>Free to light: walking at its target or idle, and not carried by anything.</summary>
	bool Free => (Ai.State is ZombieState.Chasing or ZombieState.Idle) && !Busy;

	/// <summary>
	/// Upstream's `AI`: with a target, light once its time is up, or once that target is in its sight within <see cref="IgniteRange"/>
	/// (a target that is not a player is never "out of sight", upstream's `IsAttackBlocked`). THE HOST.
	/// </summary>
	void TickIgnite()
	{
		if ( !Free || Time.Now < _nextLook ) return;
		_nextLook = Time.Now + LookEvery;

		// ⚠️ NO TARGET, NO FIRE — NOT EVEN THE TIMER'S (upstream's `if IsValid(tar)` holds both)
		var t = Ai.Target;
		if ( !t.IsValid() ) return;

		if ( Time.Now >= _igniteAt )
		{
			Ignite( "its time was up" );
			return;
		}

		var d = WorldPosition.Distance( t.WorldPosition );
		if ( d >= IgniteRange * Size ) return;
		var player = t.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors );
		if ( player.IsValid() && !CanSee( t ) ) return;
		Ignite( $"its target in sight, {d:0} away" );
	}

	/// <summary>
	/// The ignite: the cry (its scream 0.4 s in), the trail on every machine, the sprint for good. False when it cannot now — lit
	/// already, dead, still climbing out, or held in another move. THE HOST.
	/// </summary>
	bool Ignite( string why )
	{
		if ( _lit || !Ai.IsValid() || Ai.State is ZombieState.Dead or ZombieState.Spawning or ZombieState.Special ) return false;
		_lit = true;
		_fast = Game.Random.Float( 0f, 1f ) < FastChance;

		// ⚠️ THE CRY FIRST, THEN THE SPEED, AND NO RE-PICK BETWEEN (the Krasny's rule): a re-pick mid-special hands the agent its speed
		// back where the special roots it at 0, and the cry would slide. The special's own end re-picks from `SpeedOverride`
		// (`ZombieAI.TickSpecial`). A cry that cannot play (no clip) re-picks now.
		var four = Game.Random.Int( 0, 1 ) == 1;
		if ( PlayShared( four ? ClipCry4 : ClipCry3, four ? Cry4Len : Cry3Len ) ) Ai.SpeedOverride = MathF.Max( 1f, Pace * Size );
		else SetSpeed( Pace );

		Queue( CryVoxAt, (int)Beat.Cry );
		SetPart( LitPart, _fast ? 2 : 1 );
		SetTraverse();
		Say( $"IGNITED ({why}) — sprinting at {Pace:0} u/s ({(_fast ? "the fast four" : "the slow four")})" );
		return true;
	}

	/// <summary>Its traverse for a window or a ledge, at its pace: walk, run (the slow sprint), sprint (the fast). THE HOST.</summary>
	void SetTraverse()
	{
		var clip = !_lit ? ClipTravWalk : _fast ? ClipTravSprint : ClipTravRun;
		Ai.ClimbClipOverride = clip;
		Ai.VaultClipOverride = clip;
	}

	/// <summary>Its target's ground speed, frame to frame, smoothed — what decides a standing swing (`TeslaBoss`'s). THE HOST.</summary>
	void TrackTarget()
	{
		var t = Ai.Target;
		if ( !t.IsValid() ) { _tracked = null; _targetSpeed = 999f; return; }

		var at = t.WorldPosition;
		if ( t != _tracked )
		{
			_tracked = t;
			_trackedAt = at;
			_targetSpeed = 999f;
			return;
		}

		var dt = MathF.Max( 0.001f, Time.Delta );
		var v = (at - _trackedAt).WithZ( 0 ).Length / dt;
		_trackedAt = at;
		if ( v > 1200f ) return;   // a teleport or a respawn, not a run
		_targetSpeed += (v - _targetSpeed) * (1f - MathF.Exp( -10f * dt ));
	}

	/// <summary>
	/// Which swings its next attack draws from — upstream's base `Attack` (M L4586): the standing set at a target all but still within
	/// <see cref="StandRange"/>, else the variant's lunges. The AI reads it as each swing starts. THE HOST.
	/// </summary>
	void PickAttackSet()
	{
		var t = Ai.Target;
		var stand = t.IsValid() && _targetSpeed < StandSpeed && WorldPosition.Distance( t.WorldPosition ) <= StandRange * Size;
		Ai.AttackOverride = stand ? (_standing ??= new List<string> { StandL1, StandL2, StandR1, StandR2, Stand2h1, Stand2h2 }) : null;
	}

	/// <summary>
	/// The death blast (upstream's `Explode(25)`): players up in the open within <see cref="BlastRadius"/> of its middle take
	/// <see cref="BlastWeight"/> of its swing, falling to nothing at the rim, capped at `MaxHit` (`HurtPlayer`); every other zombie
	/// there <see cref="ZombieBlast"/>, the same falloff, from nobody. The fireball, the bang and the shake on every machine. THE HOST.
	/// </summary>
	void Blast( string why )
	{
		var at = WorldPosition + Vector3.Up * Middle * Size;
		var radius = BlastRadius * Size;
		var swing = SwingDamage( BlastWeight );
		int players = 0, zombies = 0;

		foreach ( var p in PlayersWithin( at, radius ).ToList() )
		{
			var chest = p.WorldPosition + Vector3.Up * 40f;
			if ( !InTheOpen( at, chest ) ) continue;
			var share = 1f - Math.Clamp( chest.Distance( at ) / radius, 0f, 1f );
			if ( share > 0f && HurtPlayer( p, swing * share, blast: true, at: at, source: "sizzler blast" ) ) players++;
		}

		// ⚠️ THE OTHER ZOMBIES, upstream's too (its loop takes every nextbot): flat, so it matters early and not late — and another
		// Sizzler it finishes off blows up in turn, as upstream's did
		if ( ZombieBlast > 0f )
		{
			foreach ( var z in ZombieAI.All.ToList() )
			{
				if ( !z.IsValid() || z == Ai || z.State == ZombieState.Dead ) continue;
				var middle = z.WorldPosition + Vector3.Up * Middle * MathF.Max( 0.1f, z.WorldScale.x );
				var d = middle.Distance( at );
				if ( d > radius || !InTheOpen( at, middle ) ) continue;
				var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
				if ( !hp.IsValid() || hp.IsDead ) continue;
				hp.OnDamage( new DamageInfo { Damage = ZombieBlast * (1f - d / radius), Position = middle } );
				zombies++;
			}
		}

		BlastEffect.Spawn( at, radius );
		NZSound.PlayShared( "nz.sizzler.explode", at );
		NZNet.ShakeAt( at, ShakeStrength, ShakeRange * Size );
		Say( $"BLAST ({why}) — {players} player(s) hurt (up to {MathF.Min( swing, MaxHit ):0} each), {zombies} zombie(s)"
			+ $" (up to {ZombieBlast:0}) within {radius:0}" );
	}

	/// <summary>Nothing of the world between (upstream's `if tr1.HitWorld then continue`); bodies and triggers do not count.</summary>
	bool InTheOpen( Vector3 from, Vector3 to )
	{
		var tr = Scene.Trace.Ray( from, to ).IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( "zombie", "player", "trigger", "ragdoll" ).Run();
		return !tr.Hit || tr.HitPosition.Distance( to ) < 24f;
	}

	// ══ every machine ════════════════════════════════════════════════════════

	/// <summary>
	/// ⚠️ A WATCHING MACHINE'S LEGS: the host's speed does not travel, so its puppet re-picks its clip from the pace it was told
	/// (<see cref="ShowPart"/>) — the walk tier, or its sprint's (the Panzerhund's way).
	/// </summary>
	void TickPuppetPace()
	{
		if ( !Ai.IsPuppet ) return;
		var speed = MathF.Max( 1f, Pace * Size );
		if ( MathF.Abs( Ai.SpeedOverride - speed ) <= 0.01f ) return;
		Ai.SpeedOverride = speed;
		Ai.RepickAnimations();
	}

	/// <summary>Its trail follows its chest while it is lit — lit again if a hotload or a late message lost it.</summary>
	void TickTrail()
	{
		if ( !_lit ) return;
		if ( !_fire.IsValid() ) Light();
		if ( _fire.IsValid() ) _fire.WorldPosition = Chest;
	}

	/// <summary>
	/// Its trail on THIS machine: an anchor at its chest that only follows it (never networked), with `BossShotLook`'s head and trail
	/// on it — a flame at its heart and upstream's orange-red streak. Unlit: nothing lights the room (the tracers' rule).
	/// </summary>
	void Light()
	{
		if ( _fire.IsValid() || !Body.IsValid() ) return;

		_fire = new GameObject { Parent = GameObject, Name = "nz_siz_fire" };
		_fire.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_fire.NetworkMode = NetworkMode.Never;
		_fire.WorldPosition = Chest;
		BossShotLook.On( _fire, Plasma, CoreSize, TrailWidth, TrailSeconds, 0f, 0f, 2.5f, FlameSprite );
	}

	void Douse()
	{
		if ( _fire.IsValid() ) _fire.Destroy();
		_fire = null;
	}

	/// <summary>Upstream's `chest_fx_tag` (attachment 9): `j_spineupper`.</summary>
	Vector3 Chest => Body.IsValid() && Body.TryGetBoneTransform( "j_spineupper", out var t )
		? t.Position
		: WorldPosition + Vector3.Up * 50f * Size;

	/// <summary>
	/// The fire under its steps: each step of the clip it shows (`WalkerFootsteps`, the same table and times `ZombieAI` steps by —
	/// it plays the napalm step), the fire layer at the planted foot and, near enough to see, embers (upstream's `bo3_napalm_fs`).
	/// </summary>
	void TickFireSteps()
	{
		if ( !Body.IsValid() ) return;
		var seq = Body.Sequence;
		if ( seq is null ) return;

		var clip = seq.Name;
		var t = seq.TimeNormalized;
		if ( clip != _stepClip )
		{
			_stepClip = clip;
			_stepT = t;
			return;
		}

		if ( string.IsNullOrEmpty( clip ) || !WalkerFootsteps.Has( clip ) ) { _stepT = t; return; }

		// a loop wraps past 1 back to 0: two spans, as `ZombieAI.TickFootsteps` reads them
		var steps = WalkerFootsteps.For( clip );
		var n = t >= _stepT ? Crossed( steps, _stepT, t ) : Crossed( steps, _stepT, 1f ) + Crossed( steps, -0.001f, t );
		_stepT = t;
		if ( n > 0 ) FireStep();
	}

	static int Crossed( WalkerFootsteps.Step[] steps, float from, float to )
	{
		var n = 0;
		foreach ( var s in steps )
			if ( s.Time > from && s.Time <= to ) n++;
		return n;
	}

	void FireStep()
	{
		var foot = Foot;
		NZSound.PlayAmbient( "nz.sizzler.step.fire", foot, SoundGate.Step );
		if ( NZSound.Ear.Distance( foot ) <= EmberEar ) BossFx.Sparks( foot + Vector3.Up * 3f, Ember, 2, 10f * Size );
	}

	/// <summary>The planted foot — the lower of its two (upstream's `lfoot_fx_tag` / `rfoot_fx_tag`, `j_ball_le` / `j_ball_ri`).</summary>
	Vector3 Foot
	{
		get
		{
			var left = Body.TryGetBoneTransform( "j_ball_le", out var l ) ? l.Position : WorldPosition;
			var right = Body.TryGetBoneTransform( "j_ball_ri", out var r ) ? r.Position : WorldPosition;
			return left.z <= right.z ? left : right;
		}
	}

	// ══ diagnostics ══════════════════════════════════════════════════════════

	static IEnumerable<SizzlerZombie> All
		=> Game.ActiveScene?.GetAllComponents<SizzlerZombie>() ?? Enumerable.Empty<SizzlerZombie>();

	/// <summary>`nz_siz` — every Sizzler alive, and the tuning.</summary>
	[ConCmd( "nz_siz" )]
	public static void Report()
	{
		Log.Info( $"[nz-siz] ignites at its target in sight within {IgniteRange:0}, or {EnrageMin:0.#}-{EnrageMax:0.#}s after it arrives"
			+ $" · then sprints {SlowSprint:0} or {FastSprint:0} u/s ({FastChance * 100f:0}% the fast) · standing swing within"
			+ $" {StandRange:0} under {StandSpeed:0.#} u/s" );
		Log.Info( $"[nz-siz] death blast {BlastRadius:0}u: x{BlastWeight:0.##} of its swing to players at the middle (cap {MaxHit:0}),"
			+ $" {ZombieBlast:0} to zombies · trail {TrailWidth:0}u x {TrailSeconds:0.##}s, flame {CoreSize:0}" );

		var list = All.ToList();
		Log.Info( $"[nz-siz] {list.Count} alive" );
		foreach ( var s in list )
		{
			var target = s.Ai.IsValid() ? s.Ai.Target : null;
			Log.Info( $"[nz-siz]   hp {s.Hp?.Current ?? 0f:0}/{s.Hp?.Max ?? 0f:0} · state {s.Ai?.State} · speed {s.Ai?.MoveSpeed ?? 0f:0}"
				+ $" · {(s._lit ? $"LIT ({(s._fast ? "fast" : "slow")})" : s.IsHost ? $"walking (lights in {MathF.Max( 0f, s._igniteAt - Time.Now ):0.#}s)" : "walking")}"
				+ $" · target {(target.IsValid() ? $"{target.Name} {target.WorldPosition.Distance( s.WorldPosition ):0} away" : "none")}"
				+ $"{(s.Ai?.AttackOverride is not null ? " (standing swing)" : "")}"
				+ $"{(s.Ai.IsValid() && !s.Dead ? $" · blast now {MathF.Min( s.SwingDamage( BlastWeight ), MaxHit ):0}" : "")}" );
		}
	}

	/// <summary>`nz_siz_ignite` — every Sizzler not yet lit ignites now, target or not (one still climbing out cannot).</summary>
	[ConCmd( "nz_siz_ignite" )]
	public static void IgniteCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-siz] the host decides" ); return; }
		var n = 0;
		foreach ( var s in All.Where( s => s.Ai.IsValid() && !s.Dead && !s._lit ).ToList() )
			if ( s.Ignite( "nz_siz_ignite" ) ) n++;
		Log.Info( $"[nz-siz] {n} ignited · one climbing out or mid-move cannot (run it again)" );
	}

	/// <summary>`nz_siz_blast` — every Sizzler's death blast where it stands, now; it lives on (to see and feel the blast).</summary>
	[ConCmd( "nz_siz_blast" )]
	public static void BlastCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-siz] the host decides" ); return; }
		var n = 0;
		foreach ( var s in All.Where( s => s.Ai.IsValid() && !s.Dead ).ToList() )
		{
			s.Blast( "nz_siz_blast" );
			n++;
		}
		Log.Info( $"[nz-siz] {n} blast(s)" );
	}

	/// <summary>`nz_siz_set &lt;key&gt; &lt;value&gt;` — retune it live. The trail's keys reach the next one to light.</summary>
	[ConCmd( "nz_siz_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "range": IgniteRange = value; break;
			case "enragemin": EnrageMin = value; break;
			case "enragemax": EnrageMax = value; break;
			case "fast": FastChance = value; break;
			case "standrange": StandRange = value; break;
			case "standspeed": StandSpeed = value; break;
			case "radius": BlastRadius = value; break;
			case "weight": BlastWeight = value; break;
			case "zombies": ZombieBlast = value; break;
			case "trailwidth": TrailWidth = value; break;
			case "trailseconds": TrailSeconds = value; break;
			case "flame": CoreSize = value; break;
			default:
				Log.Info( "[nz-siz] nz_siz_set <range|enragemin|enragemax|fast|standrange|standspeed|radius|weight|zombies|trailwidth"
					+ "|trailseconds|flame> <value>" );
				return;
		}
		Log.Info( $"[nz-siz] {key} = {value:0.###}" );
		Report();
	}
}