Zombies/PanzermorderBoss.cs

Boss component for the Panzermorder enemy in the NZombies gamemode. Implements health bars/lives, knock-down and spend logic, roar and ground-slam attacks, arrival/idle behaviours, gore effects and console commands for diagnostics and live tuning.

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

namespace NZombies;

/// <summary>
/// The Panzermorder — CoD WWII's brute, a flower of meat for a chest, ported 2026-10-07 from GMod nZombies'
/// `nz_zombie_boss_meatflower` (Laby and GhostlyMoo; the boss round's "Panzermorder"), the third batch. Spec:
/// `Sbox nzombies/Docs/boss_specs/meatflower.md`. ⚠️ NOT upstream's `nz_zombie_boss_panzermorder`: the same creature written
/// earlier, on a model nobody has.
///
///   • IT ARRIVES GETTING UP OFF THE FLOOR (`s2_zom_brt_stun_up_v1`, the variant's entrance) in a ring of dust, the ground
///     shaking.
///   • IT WALKS (101.1 u/s) — AND RUNS (325.9) ON ITS LAST BAR. Every step shakes the ground (the variant's step shake).
///     ⚠️ One sound and one shake a footfall: upstream's right foot had its handler twice.
///   • FOUR BARS OF HEALTH, THREE KNOCK-DOWNS (`meatflower.zvar`'s pool in quarters): the hit that would empty a bar puts it
///     down instead — untouchable while it falls (2.77 s) and lies (4.2 s), touchable again a frame into getting up (5.1 s),
///     7 s after the hit. The first hit a player lands while it lies there (a later one than the blast that put it down) spends
///     a life, and it gets up on the next bar; no hit, and it gets up on the same bar, whole again. With no life left the last
///     bar is its last: emptied, it bursts.
///     ⚠️ UPSTREAM'S RULE NEVER RAN: the gamemode refuses every hit on an invulnerable boss before its `OnInjured` sees one, so
///     no life was ever spent and it got up whole forever (it died only to a hit that emptied a bar EXACTLY). Here, its intent.
///     ⚠️ UPSTREAM SPENDS A LIFE ON A SHOCK (`DMG_SHOCK`); HERE ANY PLAYER'S HIT DOES — this game's only shock is Dead Wire's,
///     and without it the boss could not die. <see cref="ShockOnly"/> (`nz_morder_set shockonly 1`) asks for a hit while it
///     wears the `shock` status, upstream's rule.
///   • THE ROAR, while its target is out of its reach (beyond <see cref="RoarMinRange"/>): each of upstream's path time-outs
///     (0.5-1 s near, 1.5-15 s far) rolls <see cref="RoarChance"/>, and a roar waits 1-<see cref="RoarGapMax"/> s for the next.
///     2.4 s into it everyone up within <see cref="RoarRadius"/> it can see takes x<see cref="RoarWeight"/> of its swing
///     (upstream's 25 against 65), and everyone in it is dazed <see cref="Daze"/> s (upstream's `NZSonicBlind(3)`).
///     ⚠️ Its reach is drawn on the floor while it builds (`NZNet.BossTelegraph`), which upstream does not; and the daze, a
///     burst, also finds whoever stands behind a wall inside it — the damage does not.
///   • THE GROUND SLAM, one walking swing in three (`walk_attack_ground_v2`, at its `zmb_brute_slam`): everyone up within
///     <see cref="SlamRadius"/>, walls or not, takes x<see cref="SlamWeight"/> of its swing (upstream's 75 against 65), the
///     ground shaking. ⚠️ Upstream's hit every NPC and nextbot round it as well: the horde is spared here.
///   • ITS SWING, 65 against a walker's 50 (the variant's multiplier). ⚠️ Upstream's swings are homing lunges of 530-700 u
///     (root motion, facing you every tick); the port strips the travel and the moving attack closes on you instead.
///   • IT BURSTS WHEN IT DIES — no clip, the body gone at once in a spray of blood (upstream's `nbnz_gib_explosion`) — and
///     every player up gets a perk they lack (upstream's Perk Bottle, the Sawrunner's way). ⚠️ Upstream's death skipped the
///     boss kill's rewards (it never went through `OnKilled`); here it is an ordinary death.
///   • ITS OWN IDLE when nobody is left to chase. ⚠️ Upstream asks for `idle`, which the model lacks: its reference pose.
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the knock-down's clips are `PlayShared`/`SwapClip`, the roar's blast is
/// `NZNet.BossBurstFx` (whose daze each machine puts on its own player), the dust `ShockRing.FireShared`, the burst `Died`'s on
/// every machine. ⛔ NO HIT OF ITS DOWNS ANYONE FROM FULL HEALTH (`BossBase.MaxHit`), Oberon's rule — the roar and slam too.
/// </summary>
public sealed class PanzermorderBoss : BossBase
{
	protected override string Tag => "[nz-morder]";

	/// <summary>
	/// ⚠️ ITS EYES WHERE UPSTREAM'S WERE: `EyePos` ~61 off its 72-unit hull (the model has no `j_head`), so it sees what
	/// upstream's saw. On a 233-unit body its voice comes from the knees, which no ear can tell at range.
	/// </summary>
	protected override float VoiceHeight => 64f;

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

	static bool? _shockOnly;
	/// <summary>Only a hit while it wears the `shock` status spends a life (upstream's `DMG_SHOCK`); off, any player's hit does.</summary>
	public static bool ShockOnly { get => _shockOnly ?? false; set => _shockOnly = value; }

	static float? _roarChance, _roarGap, _roarMin, _roarRadius, _roarWeight, _daze;
	/// <summary>A path time-out rolls a roar at 0.1 (upstream's `math.random(10) == 1`); then 1-20 s before the next can.</summary>
	public static float RoarChance { get => _roarChance ?? 0.1f; set => _roarChance = value; }
	public static float RoarGapMax { get => _roarGap ?? 20f; set => _roarGap = value; }
	/// <summary>It roars only at a target beyond 245, upstream's attack range: a path times out only while you are out of reach.</summary>
	public static float RoarMinRange { get => _roarMin ?? 245f; set => _roarMin = value; }
	/// <summary>The roar's blast: 300 round it, x0.385 of its swing (upstream's 25 against 65), and a 3 s daze.</summary>
	public static float RoarRadius { get => _roarRadius ?? 300f; set => _roarRadius = value; }
	public static float RoarWeight { get => _roarWeight ?? 0.385f; set => _roarWeight = value; }
	public static float Daze { get => _daze ?? 3f; set => _daze = value; }

	static float? _slamRadius, _slamWeight;
	/// <summary>The ground slam: 350 round it, walls or not, x1.15 of its swing (upstream's 75 against 65).</summary>
	public static float SlamRadius { get => _slamRadius ?? 350f; set => _slamRadius = value; }
	public static float SlamWeight { get => _slamWeight ?? 1.15f; set => _slamWeight = value; }

	/// <summary>Its lives (upstream's `revives`): three knock-downs, so four bars.</summary>
	const int Lives = 3;

	// its clips (30 fps) and their events — ⛔ `const`, never `static readonly` (INSTRUCTIONS.md §1)
	const string ClipDown = "s2_zom_brt_stun_down_v1", ClipLoop = "s2_zom_brt_stun_loop_v1", ClipUp = "s2_zom_brt_stun_up_v1",
		ClipRoar = "s2_zom_brt_roar", ClipSlam = "s2_zom_brt_walk_attack_ground_v2", ClipIdle = "s2_zom_brt_idle",
		ClipTraverse = "s2_zom_brt_walk_trav_exit_v1";

	/// <summary>
	/// ⚠️ EACH CLIP OF THE KNOCK-DOWN IS SWAPPED A FRAME EARLY: a one-shot that runs out wraps to its first frame for a frame
	/// (`ZombieAI.TickCorpse`'s stand-up flash).
	/// </summary>
	const float Wrap = 0.03f;
	const float DownLen = 83f / 30f - Wrap, LoopLen = 126f / 30f, UpLen = 153f / 30f - Wrap, DownTotal = DownLen + LoopLen + UpLen,
		FallA = 46f / 30f, FallB = 52f / 30f, RiseAt = DownLen + LoopLen + 1f / 30f,
		RoarLen = 211f / 30f, RoarBlastAt = 72f / 30f,
		SlamLen = 181f / 30f, SlamEvent = 50f / 30f, SlamAt = 50f / 181f;

	/// <summary>Its clips' own speeds — `meatflower.zvar`'s tiers are picked by them.</summary>
	const float WalkSpeed = 101.1f, RunSpeed = 325.9f;
	const float IdleHold = 2f;

	/// <summary>
	/// ⚠️ A LIFE GOES TO A LATER HIT, NOT THE BLAST THAT PUT IT DOWN: a shotgun's other pellets land in the same frame, and they
	/// would spend it at once.
	/// </summary>
	const float SpendGrace = 0.25f;

	static Color Dust => new( 0.7f, 0.62f, 0.5f );
	static Color Flesh => new( 0.55f, 0.06f, 0.05f );
	static Color Sonic => new( 1f, 0.55f, 0.4f );

	// ══ state ════════════════════════════════════════════════════════════════

	int _lives = Lives, _downs;
	bool _down, _spent, _sprint, _slammed;
	float _downAt, _nextThink, _nextRoar, _nextIdle, _clipT;
	string _clip;

	enum Beat { Fall, Loop, Up, Rise, Stand, RoarBlast, Slam }

	public bool Down => _down;
	public int LivesLeft => _lives;

	/// <summary>One bar: the pool in quarters.</summary>
	float Bar => Hp.IsValid() ? Hp.Max / (Lives + 1) : 0f;

	/// <summary>The line under the bar it is on — emptied to here, it goes down. 0 on its last bar.</summary>
	float Floor => _lives * Bar;

	protected override void HostStart()
	{
		Ai.MaxHitDamage = MaxHit;

		// ⛔ NONE OF THE WALKER'S CLIMB CLIPS ON ITS MODEL: its own traversal (upstream's `JumpSequences`)
		Ai.ClimbClipOverride = ClipTraverse;

		SetSpeed( WalkSpeed );
		_nextThink = Time.Now + 1f;

		// upstream's arrival: `bo3_panzer_landing`'s dust round it, and the shake (amplitude 5 within 2048)
		ShockRing.FireShared( WorldPosition + Vector3.Up * 4f, 200f * Size, Dust );
		NZNet.ShakeAt( WorldPosition, 0.5f, 2048f );

		Say( $"spawned — {Hp?.Max ?? 0f:0} hp in {Lives + 1} bars of {Bar:0} · {(ShockOnly ? "a shocked hit" : "any player's hit")} while"
			+ $" down spends a life · roars beyond {RoarMinRange:0} · slam {SlamRadius:0}u · hitboxes {Ai.HitboxCount}" );
	}

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

		// ⚠️ A BAR EMPTIED BY ANYTHING BUT A HIT — a burn's tick, a bleed, a nuke's share: `Health.Apply`, never `ScaleDamage` —
		// puts it down too, the pool under the line taking whatever went past it
		if ( !_down && _lives > 0 && Hp.IsValid() && Hp.Current <= Floor + 0.5f ) KnockDown();
		if ( _down ) return;

		TickSlam();
		TickRoar();
		TickIdle();
	}

	/// <summary>
	/// Its table: nothing lands while it is down — and the first player's hit then spends a life — and the hit that would empty
	/// a bar puts it down instead (during its entrance, that hit takes it to the line and no further; it goes down when the
	/// entrance is over). THE HOST, once a hit.
	/// </summary>
	protected override float ScaleDamage( float amount, in DamageInfo damage )
	{
		if ( _down )
		{
			if ( !_spent && Time.Now >= _downAt + SpendGrace && FromPlayer( damage )
				&& (!ShockOnly || StatusEffects.Has( GameObject, "shock" )) ) Spend();
			return 0f;
		}

		if ( _lives <= 0 || !Hp.IsValid() || amount <= 0f ) return 1f;
		var left = Hp.Current - Floor;
		if ( amount < left ) return 1f;

		if ( Ai.State == ZombieState.Spawning ) return MathF.Max( 0f, left ) / amount;
		KnockDown();
		return 0f;
	}

	static bool FromPlayer( in DamageInfo damage )
		=> damage.Attacker.IsValid() && damage.Attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ).IsValid();

	protected override void OnBeat( int beat )
	{
		switch ( (Beat)beat )
		{
			case Beat.Fall:
				// `bodyfall_large`, twice as it lands: the slam's thud, dust and shake — no damage (upstream's)
				NZSound.PlayShared( "nz.margwa.slam.far", WorldPosition );
				ShockRing.FireShared( WorldPosition + Vector3.Up * 4f, 220f * Size, Dust );
				NZNet.ShakeAt( WorldPosition, 0.8f, 2000f );
				break;

			case Beat.Loop:
				SwapClip( ClipLoop );
				break;

			case Beat.Up:
				SwapClip( ClipUp );
				break;

			case Beat.Rise:
				// `ps s2_zom_brt_stun_up_v1`: touchable again — on the next bar if a life went, else on the same one, whole
				_down = false;
				if ( Hp.IsValid() )
				{
					Hp.Invulnerable = false;
					Hp.SetCurrent( (_lives + 1) * Bar );
				}
				Say( _spent ? $"up — bar {Lives + 1 - _lives} of {Lives + 1}" : "up WHOLE — nobody hit it while it lay there" );
				break;

			case Beat.Stand:
				if ( _sprint ) SetSpeed( RunSpeed );
				break;

			case Beat.RoarBlast:
				RoarBlast();
				break;

			case Beat.Slam:
				Slam();
				break;
		}
	}

	/// <summary>
	/// It bursts: on every machine the body gone at once and blood thrown up and out of it (upstream's `nbnz_gib_explosion`);
	/// on the host the gore's sounds, and a perk for every player up (upstream's Perk Bottle — on their own machines, where
	/// perks live).
	/// </summary>
	protected override void Died( bool host )
	{
		Splatter();
		MargwaFx.SetHidden( GameObject, true );
		if ( !host ) return;

		Ai.CorpseLinger = 0.2f;
		NZSound.PlayShared( "nz.morder.gore", Voice );
		NZSound.PlayShared( "nz.morder.merge", Voice );

		var n = 0;
		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !IsUp( p ) ) continue;
			NZNet.DirectorPerk( p.GameObject.Id );
			n++;
		}
		Say( $"killed — it burst after {_downs} knock-down(s); a perk for each of {n} player(s)" );
	}

	// ══ the knock-downs ══════════════════════════════════════════════════════

	/// <summary>
	/// Down: untouchable, on the line under its bar, its three clips one hold (down, lying, up), two thuds as it lands.
	/// ⚠️ GOING DOWN WITH ITS LAST LIFE, IT RUNS WHEN IT IS UP (upstream's `SetRunSpeed(230)` at the third down). THE HOST.
	/// </summary>
	void KnockDown()
	{
		if ( _down || _lives <= 0 || !Hp.IsValid() ) return;
		_down = true;
		_spent = false;
		_downAt = Time.Now;
		_downs++;
		Hp.Invulnerable = true;
		Hp.SetCurrent( Floor );
		ClearBeats();

		// ⚠️ MID-ROAR OR MID-IDLE IT IS IN A SPECIAL ALREADY: the clip is swapped and the hold re-armed
		if ( Ai.State == ZombieState.Special )
		{
			SwapClip( ClipDown );
			Ai.HoldSpecialFor( DownTotal );
		}
		else PlayShared( ClipDown, DownTotal );

		if ( _lives < 2 ) _sprint = true;

		Queue( FallA, (int)Beat.Fall );
		Queue( FallB, (int)Beat.Fall );
		Queue( DownLen, (int)Beat.Loop );
		Queue( DownLen + LoopLen, (int)Beat.Up );
		Queue( RiseAt, (int)Beat.Rise );
		Queue( DownTotal + 0.1f, (int)Beat.Stand );

		NZSound.PlayShared( "nz.morder.merge", Voice );
		NZSound.PlayShared( "nz.morder.down", Voice );
		Say( $"DOWN ({_downs}) — {_lives} life/lives left; {(ShockOnly ? "a shocked hit" : "a hit")} while it lies there spends one" );
	}

	/// <summary>A life spent while it lies there: its death voice (upstream's, on the shock), and it gets up on the next bar. THE HOST.</summary>
	void Spend()
	{
		_spent = true;
		_lives--;
		NZSound.PlayShared( "nz.morder.death", Voice );
		BossFx.BurstShared( WorldPosition + Vector3.Up * 30f * Size, 70f * Size, Flesh, 10 );
		Say( $"a life spent — {_lives} left{(_lives == 0 ? ": its last bar" : "")}" );
	}

	// ══ the roar ═════════════════════════════════════════════════════════════

	/// <summary>
	/// Upstream's `OnPathTimeOut`: while it chases a target out of its reach, each path time-out (`d²/295²` held to 0.5-1 s
	/// within 850, `d²/1000²` held to 1.5-15 s beyond) rolls <see cref="RoarChance"/> once the last roar's gap is out. THE HOST.
	/// </summary>
	void TickRoar()
	{
		if ( Ai.State != ZombieState.Chasing || Time.Now < _nextThink ) return;
		var p = TargetPlayer;
		if ( !IsUp( p ) ) { _nextThink = Time.Now + 0.5f; return; }

		var d = WorldPosition.Distance( p.WorldPosition );
		_nextThink = Time.Now + (d > 850f ? Math.Clamp( d * d / (1000f * 1000f), 1.5f, 15f ) : Math.Clamp( d * d / (295f * 295f), 0.5f, 1f ));
		if ( d <= RoarMinRange * Size || Time.Now < _nextRoar || Game.Random.Float( 0f, 1f ) >= RoarChance ) return;
		Roar();
	}

	void Roar()
	{
		if ( !PlayShared( ClipRoar, RoarLen ) ) return;

		// upstream's `NextAction = CurTime() + math.random(20)`, from the roar's start
		_nextRoar = Time.Now + Game.Random.Int( 1, Math.Max( 1, (int)RoarGapMax ) );
		NZSound.PlayShared( "nz.morder.roar", Voice );
		NZNet.BossTelegraph( WorldPosition, RoarRadius * Size, RoarBlastAt, 1f, 0.55f, 0.4f );
		Queue( RoarBlastAt, (int)Beat.RoarBlast );
	}

	/// <summary>
	/// `ps s2_zom_brt_roar`: everyone up within <see cref="RoarRadius"/> it can see takes x<see cref="RoarWeight"/> of its swing,
	/// and everyone inside is dazed (each machine its own player, `BossFx.Burst`). THE HOST.
	/// </summary>
	void RoarBlast()
	{
		var at = WorldPosition + Vector3.Up * 40f * Size;
		var radius = RoarRadius * Size;
		var damage = SwingDamage( RoarWeight );
		foreach ( var p in PlayersWithin( at, radius ).ToList() )
			if ( CanSee( p.GameObject ) ) HurtPlayer( p, damage, blast: true, at: at, source: "panzermorder roar" );

		BossFx.BurstShared( at, radius, Sonic, 12, Daze );
		NZNet.ShakeAt( WorldPosition, 0.35f, 2048f );
	}

	// ══ the ground slam ══════════════════════════════════════════════════════

	/// <summary>
	/// The slam lands at `zmb_brute_slam`, 50 of the swing's 181 frames in — read off the clip as it plays, so the swing's
	/// speed (`ZombieAI.ScaledAttackSpeed`) carries it with the arm. The same swing twice in a row restarts the clip. THE HOST.
	/// </summary>
	void TickSlam()
	{
		var seq = Body.IsValid() ? Body.Sequence : null;
		if ( seq is null ) return;
		var clip = seq.Name;
		var t = seq.TimeNormalized;
		if ( clip != _clip || t < _clipT - 0.25f ) _slammed = false;
		_clip = clip;
		_clipT = t;

		if ( _slammed || clip != ClipSlam || Ai.State != ZombieState.Attacking || t < SlamAt ) return;
		_slammed = true;
		Slam();
	}

	/// <summary>
	/// `zmb_brute_slam`: everyone up within <see cref="SlamRadius"/> — walls or not, upstream's sphere — takes x<see
	/// cref="SlamWeight"/> of its swing; the thud heard far, dust round it and where its hand came down, the ground shaking.
	/// THE HOST.
	/// </summary>
	void Slam()
	{
		var at = WorldPosition;
		var radius = SlamRadius * Size;
		var damage = SwingDamage( SlamWeight );
		foreach ( var p in PlayersWithin( at + Vector3.Up * 40f, radius ).ToList() )
			HurtPlayer( p, damage, blast: true, at: at, source: "panzermorder slam" );

		NZSound.PlayShared( "nz.margwa.slam.far", at );
		ShockRing.FireShared( at + Vector3.Up * 4f, radius, Dust );
		if ( Body.IsValid() && Body.TryGetBoneTransform( "j_wrist_ri", out var hand ) )
			ShockRing.FireShared( hand.Position.WithZ( at.z + 4f ), 90f * Size, Dust );
		NZNet.ShakeAt( at, 0.9f, 2000f );
	}

	// ══ the rest ═════════════════════════════════════════════════════════════

	/// <summary>Its own idle after a second with nobody to chase (Oberon's way). THE HOST.</summary>
	void TickIdle()
	{
		if ( Ai.Target.IsValid() ) { _nextIdle = MathF.Max( _nextIdle, Time.Now + 1f ); return; }
		if ( Time.Now < _nextIdle || Ai.State is not (ZombieState.Idle or ZombieState.Chasing) ) return;
		_nextIdle = Time.Now + IdleHold + 0.15f;
		PlayShared( ClipIdle, IdleHold );
	}

	/// <summary>
	/// The burst, on THIS machine: a ring of blood on the floor and the spray out of its chest.
	/// ⚠️ `ZombieAI.GoreBurst` IS PRIVATE TODAY: this asks for it to be made public (notes.md).
	/// </summary>
	void Splatter()
	{
		var chest = Body.IsValid() && Body.TryGetBoneTransform( "j_spine4", out var t )
			? t.Position
			: WorldPosition + Vector3.Up * 150f * Size;
		ShockRing.Fire( WorldPosition + Vector3.Up * 4f, 180f * Size, Flesh );
		if ( !ZombieAI.GoreEnabled ) return;

		ZombieAI.GoreBurst( chest, Vector3.Up, 70, 320f, 1.2f );
		ZombieAI.GoreBurst( chest, Vector3.Up, 40, 160f, 1.8f );
		ZombieAI.GoreBurst( WorldPosition + Vector3.Up * 60f * Size, Vector3.Up, 30, 200f, 1.5f );
	}

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

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

	/// <summary>`nz_morder` — every Panzermorder alive, and the tuning.</summary>
	[ConCmd( "nz_morder" )]
	public static void Report()
	{
		Log.Info( $"[nz-morder] {Lives + 1} bars, {Lives} lives · {(ShockOnly ? "a hit while SHOCKED" : "any player's hit")} while down spends one"
			+ $" · roar beyond {RoarMinRange:0}: {RoarChance * 100f:0.#}% a time-out, then 1-{RoarGapMax:0}s, {RoarRadius:0}u x{RoarWeight:0.###}"
			+ $" + {Daze:0.#}s daze · slam {SlamRadius:0}u x{SlamWeight:0.##} · hit cap {MaxHit:0}" );

		var list = All.ToList();
		Log.Info( $"[nz-morder] {list.Count} alive" );
		foreach ( var m in list )
			Log.Info( $"[nz-morder]   hp {m.Hp?.Current ?? 0f:0}/{m.Hp?.Max ?? 0f:0} · bar {Lives + 1 - m._lives} of {Lives + 1} ({m.Bar:0} each)"
				+ $" · lives {m._lives} · state {m.Ai?.State} · speed {m.Ai?.MoveSpeed ?? 0f:0}"
				+ $"{(m._down ? (m._spent ? " · DOWN (a life spent)" : " · DOWN") : "")}{(m._sprint ? " · RUNNING" : "")}"
				+ $" · next roar in {MathF.Max( 0f, m._nextRoar - Time.Now ):0.#}s · hitboxes {m.Ai?.HitboxCount ?? 0}" );
	}

	/// <summary>`nz_morder_roar` — every Panzermorder roars now; `nz_morder_slam` — slams now; `nz_morder_down` — its bar emptied now.</summary>
	[ConCmd( "nz_morder_roar" )]
	public static void RoarCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-morder] the host decides" ); return; }
		foreach ( var m in All.Where( m => m.Ai.IsValid() && !m.Dead && !m._down ).ToList() ) m.Roar();
	}

	[ConCmd( "nz_morder_slam" )]
	public static void SlamCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-morder] the host decides" ); return; }
		foreach ( var m in All.Where( m => m.Ai.IsValid() && !m.Dead && !m._down ).ToList() )
			if ( m.PlayShared( ClipSlam, SlamLen ) ) m.Queue( SlamEvent, (int)Beat.Slam );
	}

	[ConCmd( "nz_morder_down" )]
	public static void DownCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-morder] the host decides" ); return; }
		foreach ( var m in All.Where( m => m.Ai.IsValid() && !m.Dead && !m._down ).ToList() )
		{
			if ( m._lives <= 0 ) { Log.Info( "[nz-morder] on its last bar — no knock-down left: empty it and it bursts" ); continue; }
			m.KnockDown();
		}
	}

	/// <summary>`nz_morder_set &lt;key&gt; &lt;value&gt;` — retune it live.</summary>
	[ConCmd( "nz_morder_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "shockonly": ShockOnly = value != 0f; break;
			case "roarchance": RoarChance = value; break;
			case "roargap": RoarGapMax = value; break;
			case "roarrange": RoarMinRange = value; break;
			case "roarradius": RoarRadius = value; break;
			case "roar": RoarWeight = value; break;
			case "daze": Daze = value; break;
			case "slamradius": SlamRadius = value; break;
			case "slam": SlamWeight = value; break;
			default:
				Log.Info( "[nz-morder] nz_morder_set <shockonly|roarchance|roargap|roarrange|roarradius|roar|daze|slamradius|slam> <value>" );
				return;
		}
		Log.Info( $"[nz-morder] {key} = {value:0.###}" );
		Report();
	}
}