Zombies/PanzerhundBoss.cs

A Boss entity implementation for the Panzerhund in NZombies. Controls spawning, movement (walk vs pursue), lunge special attack with flame, damage scaling, death blast, networking of visual flame and pace, 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 Panzerhund — Wolfenstein's mechanical dog, ported 2026-10-07 from GMod nZombies' `nz_zombie_boss_panzerhund` (GhostlyMoo's
/// copy of his Panzer Soldat code), the third batch.
///
///   • TWO BODIES, ONE ROLLED PER DOG (`panzerhund.zvar` lists both, upstream's 50/50): both walk the same `walk`; in a pursuit
///     the 1960 dog sprints at 185.8 u/s, the 1946 dog at 464.4 — the same motion at 24 and 60 fps. Each has its own sprint
///     tier, so its legs match the ground.
///   • IT WALKS, AND RUNS DOWN WHOEVER HURTS IT (2026-10-07, the user's review: *"panzerhund is way too fast / it should walk
///     slowly, but when attacked pursue the attacker at the current speed it has for a few seconds"*): it walks at
///     <see cref="WalkSpeed"/> (59, its `walk` clip's own pace); a player's hit turns it on that player (`ZombieAI.LockTarget`)
///     at its old sprint for <see cref="PursueSeconds"/> (4), every new hit from a player again, then it walks once more.
///     ⚠️ UPSTREAM'S DOG NEVER WALKS (its walk tier was unreachable) and has no pursuit: both are the user's.
///     ⚠️ THE PACE TRAVELS AS A PART (`SetPart( PacePart )` → <see cref="ShowPart"/>), the Thrasher's way: the host's `SetSpeed`
///     does not, and a watching machine's puppet picks its own legs.
///   • UNTOUCHABLE FOR <see cref="SpawnProtection"/> s AFTER IT ARRIVES (upstream's 5), to an alarm heard map-wide.
///   • THE FLAME LUNGE, every 4-6 s (the first 5 s after it arrives), in sight within <see cref="LungeRange"/>: `specialattack`
///     carries it <see cref="LungeDistance"/> forward in 0.83 s, through players, breathing fire from 0.23 to 0.75 s — every
///     0.105 s the nearest player in the jet takes <see cref="FlameWeight"/> of its swing (upstream's 5 against 75). ⚠️ The
///     dash is `ZombieAI.BeginArc` (the port strips the clip's root motion) and stops short of walls; the jet is aimed at you
///     (`PanzerLook.FlameDir`) and walls stop it — upstream's hull ignored them.
///   • ITS BITE, 75 against a walker's 50 (the variant's multiplier), in reach.
///   • IT DIES IN A BLAST: `walk_to_sprint`, then 0.67 s later a <see cref="BlastRadius"/> explosion (upstream's `Explode(50)`,
///     ~2/3 of its bite at the middle) and it is gone. ⚠️ Upstream's blast could only hurt the dog's own target; here, anyone.
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the jet is `BossFlame` (`NZNet.BossFlameOn`).
/// </summary>
public sealed class PanzerhundBoss : BossBase
{
	protected override string Tag => "[nz-hund]";
	protected override float VoiceHeight => 60f;

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

	static float? _lungeRange, _gapMin, _gapMax, _flameWeight, _lungeDistance, _blastRadius, _blastWeight, _protect;
	/// <summary>It lunges at a target in sight within 400 (upstream's `TargetInRange(400)`), every 4-6 s.</summary>
	public static float LungeRange { get => _lungeRange ?? 400f; set => _lungeRange = value; }
	public static float LungeGapMin { get => _gapMin ?? 4f; set => _gapMin = value; }
	public static float LungeGapMax { get => _gapMax ?? 6f; set => _gapMax = value; }
	/// <summary>How far a lunge carries it: 262, its clip's own root motion.</summary>
	public static float LungeDistance { get => _lungeDistance ?? 262f; set => _lungeDistance = value; }
	/// <summary>A tick of flame (every 0.105 s) against its swing: 0.067 — upstream's 5 against 75.</summary>
	public static float FlameWeight { get => _flameWeight ?? 0.067f; set => _flameWeight = value; }
	/// <summary>Its death blast: 200 wide (upstream's), 2/3 of its swing at the middle (50 against 75).</summary>
	public static float BlastRadius { get => _blastRadius ?? 200f; set => _blastRadius = value; }
	public static float BlastWeight { get => _blastWeight ?? 0.667f; set => _blastWeight = value; }
	/// <summary>Untouchable for this long after it arrives: 5 s, upstream's.</summary>
	public static float SpawnProtection { get => _protect ?? 5f; set => _protect = value; }

	static float? _walkSpeed, _pursueSeconds, _switchGap;
	/// <summary>
	/// Its pace when nobody has hurt it (2026-10-07, the user: *"it should walk slowly"*): 59 u/s — its `walk` clip's own ground
	/// speed (95.8 u in 1.625 s at 40 fps, the same clip on both bodies), so the legs play at x1.0. Was its sprint all the time:
	/// 185.8 (1960) or 464.4 (1946). ⚠️ UNDER 150 IT IS THE `.zvar`'S WALK TIER; from 150 the tier, and the legs, turn to the sprint.
	/// </summary>
	public static float WalkSpeed { get => _walkSpeed ?? 59f; set => _walkSpeed = value; }
	/// <summary>A player's hit sends it after that player at its sprint for this long: 4 s, again from every new hit by a player.</summary>
	public static float PursueSeconds { get => _pursueSeconds ?? 4f; set => _pursueSeconds = value; }
	/// <summary>
	/// ⚠️ ANOTHER PLAYER'S HIT TAKES THE CHASE OVER no sooner than this after the last switch: 1 s. Two players shooting it at once
	/// would otherwise turn it from one to the other every frame; until then their hits only keep the chase going.
	/// </summary>
	public static float SwitchGap { get => _switchGap ?? 1f; set => _switchGap = value; }

	const float FlameReach = 325f, FlameHalfWidth = 14f;
	const float SlowSprint = 185.8f, FastSprint = 464.4f;
	const float LungeSeconds = 50f / 60f, FlameOnAt = 0.233f, FlameOffAt = 0.75f, BurnEvery = 0.105f, BlowUpAfter = 0.667f;

	/// <summary>Not a bodygroup: the pace the host tells every machine (0 walking, 1 pursuing), taken by <see cref="ShowPart"/>.</summary>
	const string PacePart = "hund_pace";

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

	BossFlame _flame;
	bool _fast, _flaming, _blown;
	float _nextLunge, _nextBurn, _diedAt;

	// ⚠️ A HOTLOAD LEAVES THESE AT null/0/false: no chase, walking — on the host and on every puppet alike
	NZPlayer _quarry, _hitBy;
	float _pursueUntil, _switchedAt;
	/// <summary>The pace every machine was told: the host's last `SetPart`, a puppet's last <see cref="ShowPart"/>.</summary>
	bool _running;

	/// <summary>Its sprint: the speed it ran at all the time before the review, now only in a pursuit.</summary>
	float SprintSpeed => _fast ? FastSprint : SlowSprint;

	/// <summary>Chasing someone who hurt it, for a little longer. THE HOST.</summary>
	bool Pursuing => _quarry.IsValid() && Time.Now < _pursueUntil;

	enum Beat { Unprotect, FlameOn, FlameOff }

	protected override void BossStart()
	{
		// ⚠️ EVERY MACHINE: the jet out of its mouth (`dog_flame`, on `06_head`)
		_flame = Components.GetOrCreate<BossFlame>();
		_flame.Bone = "06_head";
		_flame.Offset = Vector3.Zero;
		_flame.Reach = FlameReach;
		_fast = Body.IsValid() && (Body.Model?.ResourcePath?.Contains( "panzerhund3" ) ?? false);
	}

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

		// ⛔ NO MANTLE ON EITHER MODEL (upstream's dog just moves through): its walk-to-sprint hop through a window
		Ai.ClimbClipOverride = "walk_to_sprint";

		// ⚠️ IT WALKS (2026-10-07; was its sprint) — an absolute speed, so the round's rating never moves its tier (`RefreshTier`)
		SetSpeed( WalkSpeed );
		_nextLunge = Time.Now + 5f;

		if ( Hp.IsValid() ) Hp.Invulnerable = true;
		Queue( SpawnProtection, (int)Beat.Unprotect );

		NZSound.PlayShared( "nz.hund.alarm" );
		// upstream's `panzer_spawn_tp`: a purple implosion where it arrives
		BossFx.BurstShared( WorldPosition + Vector3.Up * 40f, 140f * Size, new Color( 0.62f, 0.3f, 1f ), 10 );

		Say( $"spawned — the {(_fast ? "1946 dog" : "1960 dog")}, walks {WalkSpeed:0} u/s, pursues at {SprintSpeed:0} for {PursueSeconds:0.#}s"
			+ $" · {Hp?.Max ?? 0f:0} hp · untouchable {SpawnProtection:0.#}s · lunges within {LungeRange:0} every {LungeGapMin:0}-{LungeGapMax:0}s"
			+ $" · hitboxes {Ai.HitboxCount}" );
	}

	/// <summary>
	/// ⚠️ A WATCHING MACHINE'S LEGS: the host's `SetSpeed` does not travel (the Thrasher's note), so its puppet re-picks its clip
	/// from the pace it was told (<see cref="ShowPart"/>) — the walk tier, or its body's own sprint tier. Every machine.
	/// </summary>
	protected override void EveryFrame()
	{
		if ( !Ai.IsPuppet ) return;
		var speed = MathF.Max( 1f, (_running ? SprintSpeed : WalkSpeed) * Size );
		if ( MathF.Abs( Ai.SpeedOverride - speed ) <= 0.01f ) return;
		Ai.SpeedOverride = speed;
		Ai.RepickAnimations();
	}

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

		if ( _flaming && Time.Now >= _nextBurn )
		{
			_nextBurn = Time.Now + BurnEvery;
			BossFlame.Burn( GameObject, _flame.Nozzle, _flame.Aim, FlameReach * Size, FlameHalfWidth * Size, SwingDamage( FlameWeight ),
				"panzerhund flame" );
		}

		TickPursuit();
		TickLunge();
	}

	/// <summary>
	/// Every hit lands whole, as before (x1) — but a PLAYER'S is noted for <see cref="TickPursuit"/>, decided outside the damage
	/// call (the Director's way). "A player's": `damage.Attacker` is an `NZPlayer` — a bullet, the knife, a grenade, and the perks
	/// and ammo mods that name their player; a hit with no player behind it (a burn tick, a fire pool, a zombie) is not.
	/// ⚠️ HITS DURING ITS SPAWN PROTECTION COUNT: they deal nothing, but they are an attack. THE HOST, once per hit.
	/// </summary>
	protected override float ScaleDamage( float amount, in DamageInfo damage )
	{
		var p = damage.Attacker.IsValid() ? damage.Attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
		if ( IsUp( p ) ) _hitBy = p;
		return 1f;
	}

	/// <summary>
	/// A watching machine told the pace (`NZNet.BossPart`): <see cref="EveryFrame"/> picks the legs. ⚠️ THE HOST'S OWN ECHO IS
	/// TAKEN AND IGNORED — its `_running` is what it sent.
	/// </summary>
	public override bool ShowPart( string group, int choice )
	{
		if ( group != PacePart ) return false;
		if ( Ai.IsValid() && Ai.IsPuppet ) _running = choice != 0;
		return true;
	}

	protected override void OnBeat( int beat )
	{
		switch ( (Beat)beat )
		{
			case Beat.Unprotect:
				if ( Hp.IsValid() ) Hp.Invulnerable = false;
				break;

			case Beat.FlameOn:
				if ( Ai.State != ZombieState.Special ) break;
				_flaming = true;
				_nextBurn = Time.Now;
				NZNet.BossFlameOn( GameObject.Id, true );
				break;

			case Beat.FlameOff:
				StopFlame();
				break;
		}
	}

	protected override void Died( bool host )
	{
		_diedAt = Time.Now;
		if ( host ) StopFlame();
	}

	/// <summary>0.67 s into its death clip it blows up and is gone — hidden on every machine; the host's blast hurts.</summary>
	protected override void DeadFrame()
	{
		if ( _blown || Time.Now < _diedAt + BlowUpAfter ) return;
		_blown = true;

		MargwaFx.SetHidden( GameObject, true );
		if ( !IsHost ) return;

		var at = WorldPosition + Vector3.Up * 40f * Size;
		var radius = BlastRadius * Size;
		var damage = SwingDamage( BlastWeight );
		foreach ( var p in PlayersWithin( at, radius ).ToList() )
		{
			var d = (p.WorldPosition + Vector3.Up * 40f).Distance( at );
			HurtPlayer( p, damage * Math.Clamp( 1f - d / radius, 0.2f, 1f ), blast: true, at: at, source: "panzerhund blast" );
		}

		BlastEffect.Spawn( at, radius );
		NZSound.PlayShared( "nz.hund.explode", at );
		NZNet.ShakeAt( at, 0.7f, 1200f );
		Ai.CorpseLinger = 0.2f;
	}

	// ══ the walk and the pursuit ═════════════════════════════════════════════

	/// <summary>
	/// The chase over — its time up, or its quarry down or gone — then a player's hit this frame starts or renews one, then the
	/// pace for what it is doing. THE HOST.
	/// </summary>
	void TickPursuit()
	{
		if ( _quarry is not null && (Time.Now >= _pursueUntil || !IsUp( _quarry )) ) EndPursuit();

		var hit = _hitBy;
		_hitBy = null;
		if ( IsUp( hit ) ) Pursue( hit );

		TickPace();
	}

	/// <summary>
	/// After <paramref name="p"/> at its sprint for <see cref="PursueSeconds"/>, its target held on them (`ZombieAI.LockTarget`:
	/// the AI's own look every 3-15 s would take the nearest). A hit during a chase renews it. THE HOST.
	/// </summary>
	void Pursue( NZPlayer p )
	{
		var fresh = !Pursuing;

		// ⚠️ ANOTHER PLAYER'S HIT inside `SwitchGap` of the last switch only keeps the chase on the one it has
		if ( !fresh && _quarry != p && IsUp( _quarry ) && Time.Now < _switchedAt + SwitchGap ) p = _quarry;

		if ( fresh || _quarry != p )
		{
			_switchedAt = Time.Now;
			Say( $"PURSUING {NameOf( p )} — {SprintSpeed:0} u/s for {PursueSeconds:0.#}s" );
		}

		_quarry = p;
		_pursueUntil = Time.Now + PursueSeconds;
		Ai.LockTarget( p.GameObject, PursueSeconds );
	}

	/// <summary>Walking again; the target is the AI's to pick (the lock let go). THE HOST.</summary>
	void EndPursuit()
	{
		var why = IsUp( _quarry ) ? "time up" : "they are down";
		_quarry = null;
		_pursueUntil = 0f;
		Ai.LockTarget( null, 0f );
		Say( $"pursuit over ({why}) — walking at {WalkSpeed:0}" );
	}

	/// <summary>
	/// Its speed for what it is doing — the sprint in a chase, the walk otherwise — and every machine told on a change.
	/// ⚠️ NEVER MID-SPECIAL (Krasny's rule): a re-pick pushes the agent's speed back, where the lunge roots it by zeroing it; the
	/// chase's sprint starts as the lunge ends. THE HOST.
	/// </summary>
	void TickPace()
	{
		if ( Ai.State is ZombieState.Special or ZombieState.Spawning ) return;

		var run = Pursuing;
		var speed = run ? SprintSpeed : WalkSpeed;
		if ( MathF.Abs( Ai.SpeedOverride - MathF.Max( 1f, speed * Size ) ) > 0.01f ) SetSpeed( speed );

		if ( run == _running ) return;
		_running = run;
		SetPart( PacePart, run ? 1 : 0 );
	}

	static string NameOf( NZPlayer p )
	{
		if ( !p.IsValid() ) return "nobody";
		var name = NZPlayers.NameOf( p );
		return string.IsNullOrEmpty( name ) ? p.GameObject.Name : name;
	}

	// ══ the flame lunge ══════════════════════════════════════════════════════

	void TickLunge()
	{
		if ( Ai.State != ZombieState.Chasing || Time.Now < _nextLunge ) return;
		var p = TargetPlayer;
		if ( !IsUp( p ) || WorldPosition.Distance( p.WorldPosition ) > LungeRange * Size ) return;

		// ⚠️ A FAILED LOOK STILL WAITS a little, so a wall between does not cost a trace a frame
		if ( !CanSee( p.GameObject ) ) { _nextLunge = Time.Now + 0.5f; return; }
		Lunge( p );
	}

	/// <summary>Face them, dash 262 forward with the jet on for half of it. THE HOST.</summary>
	void Lunge( NZPlayer p )
	{
		Face( p.WorldPosition );
		if ( !PlayShared( "specialattack", LungeSeconds + 0.05f ) ) return;
		_nextLunge = Time.Now + LungeSeconds + Game.Random.Float( LungeGapMin, LungeGapMax );

		// ⚠️ THE DASH ITSELF: the clip's 262 units of root motion are stripped by the port; a low arc along its facing instead,
		// stopping short of a wall
		var from = WorldPosition + Vector3.Up * 40f * Size;
		var dir = Facing;
		var reach = LungeDistance * Size;
		var wall = Scene.Trace.Ray( from, from + dir * reach ).IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( "zombie", "player", "trigger", "ragdoll" ).Run();
		if ( wall.Hit ) reach = MathF.Max( 0f, wall.Distance - 30f * Size );
		if ( reach > 16f ) Ai.BeginArc( WorldPosition + dir * reach, LungeSeconds, 10f * Size );

		Queue( FlameOnAt, (int)Beat.FlameOn );
		Queue( FlameOffAt, (int)Beat.FlameOff );
	}

	void StopFlame()
	{
		if ( !_flaming ) return;
		_flaming = false;
		NZNet.BossFlameOn( GameObject.Id, false );
	}

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

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

	/// <summary>`nz_hund` (or `nz_panzerhund`) — every Panzerhund alive, walking or pursuing whom, and the tuning.</summary>
	[ConCmd( "nz_hund" )]
	public static void Report()
	{
		Log.Info( $"[nz-hund] lunge within {LungeRange:0}, {LungeDistance:0} far, every {LungeGapMin:0}-{LungeGapMax:0}s · flame x{FlameWeight:0.###}"
			+ $" a swing every {BurnEvery:0.###}s · death blast {BlastRadius:0}u x{BlastWeight:0.##} · untouchable {SpawnProtection:0.#}s · hit cap {MaxHit:0}" );
		Log.Info( $"[nz-hund] walks {WalkSpeed:0} u/s · a player's hit: pursued at its sprint ({SlowSprint:0} the 1960 dog, {FastSprint:0} the 1946)"
			+ $" for {PursueSeconds:0.#}s · another player takes the chase over after {SwitchGap:0.#}s" );

		var list = All.ToList();
		Log.Info( $"[nz-hund] {list.Count} alive" );
		foreach ( var h in list )
		{
			var target = h.Ai.IsValid() ? h.Ai.Target : null;
			Log.Info( $"[nz-hund]   {(h._fast ? "1946 (fast)" : "1960")} · hp {h.Hp?.Current ?? 0f:0}/{h.Hp?.Max ?? 0f:0} · state {h.Ai?.State}"
				+ $" · {h.PaceReport} · speed {h.Ai?.MoveSpeed ?? 0f:0} · target {(target.IsValid() ? target.Name : "none")}"
				+ $"{(h._flaming ? " · FLAMING" : "")} · next lunge in {MathF.Max( 0f, h._nextLunge - Time.Now ):0.#}s" );
		}
	}

	/// <summary>"walking" or "PURSUING &lt;player&gt; (Ns)" — a watching machine knows only the pace the host told it.</summary>
	string PaceReport
		=> IsHost
			? (Pursuing ? $"PURSUING {NameOf( _quarry )} ({MathF.Max( 0f, _pursueUntil - Time.Now ):0.#}s)" : "walking")
			: (_running ? "PURSUING (the host's chase)" : "walking");

	[ConCmd( "nz_panzerhund" )]
	public static void ReportAlias() => Report();

	/// <summary>`nz_panzerhund_pursue` (or `nz_hund_pursue`) — every Panzerhund chases the player nearest it now, as if they had hurt it.</summary>
	[ConCmd( "nz_panzerhund_pursue" )]
	public static void PursueCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-hund] the host decides" ); return; }
		foreach ( var h in All.Where( h => h.Ai.IsValid() && !h.Dead ).ToList() )
		{
			var p = h.NearestPlayer();
			if ( IsUp( p ) ) h.Pursue( p );
			else Log.Info( "[nz-hund] nobody up to chase" );
		}
	}

	[ConCmd( "nz_hund_pursue" )]
	public static void PursueAlias() => PursueCmd();

	/// <summary>`nz_hund_lunge` — every Panzerhund lunges at its target now, whatever the range.</summary>
	[ConCmd( "nz_hund_lunge" )]
	public static void LungeCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-hund] the host decides" ); return; }
		foreach ( var h in All.Where( h => h.Ai.IsValid() && !h.Dead ).ToList() )
		{
			var p = h.TargetPlayer;
			if ( IsUp( p ) ) h.Lunge( p );
		}
	}

	/// <summary>
	/// `nz_hund_set &lt;key&gt; &lt;value&gt;` (or `nz_panzerhund_set`) — retune it live. `walkspeed` reaches a live dog on its next frame
	/// (`TickPace`); `pursue` from the next hit.
	/// </summary>
	[ConCmd( "nz_hund_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "range": LungeRange = value; break;
			case "gapmin": LungeGapMin = value; break;
			case "gapmax": LungeGapMax = value; break;
			case "distance": LungeDistance = value; break;
			case "flame": FlameWeight = value; break;
			case "blast": BlastRadius = value; break;
			case "blastweight": BlastWeight = value; break;
			case "protect": SpawnProtection = value; break;
			case "walkspeed": WalkSpeed = value; break;
			case "pursue": PursueSeconds = value; break;
			case "switch": SwitchGap = value; break;
			default:
				Log.Info( "[nz-hund] nz_hund_set <range|gapmin|gapmax|distance|flame|blast|blastweight|protect|walkspeed|pursue|switch> <value>" );
				return;
		}
		Log.Info( $"[nz-hund] {key} = {value:0.###}" );
		Report();
	}

	[ConCmd( "nz_panzerhund_set" )]
	public static void SetAlias( string key = "", float value = 0f ) => SetCmd( key, value );
}