Zombies/PanzerBoss.cs

Component for the PanzerBoss zombie and its PanzerClaw projectile. Implements boss behavior: armour pieces with special damage scaling, flamethrower, claw or taser attacks, arrival and death effects, networking of animations and VFX, and the claw flight/hold/reel logic.

NetworkingFile AccessNative Interop
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// The Panzer Soldat — Origins' mech, ported 2026-10-06 from GMod nZombies' `nz_zombie_boss_panzer` (GhostlyMoo's port).
/// The user picked him first of the four, with Avogadro, the Astronaut and the Director.
///
/// HIS ARMOUR IS THE FIGHT (upstream's `OnTakeDamage`, distances to `j_faceplate` and `tag_powersupply`, not hit groups):
///
/// | while it is on | shooting it | everything else |
/// |---|---|---|
/// | **faceplate** (HP = half his max, raw) | the plate takes the hit; he takes ×0.015 | ×0.75 |
/// | **power core's cap** (a quarter of his max) | the cap takes it; he takes ×0.015 | ×0.25 |
///
/// …and every hit ×0.95 on top: ×0.178 with both on, ×0.95 with both off. A piece coming off pays its shooter 90, staggers him
/// (`pain_faceplate` / `powercore_pain`), and THE FACEPLATE OFF MAKES HIM ANGRY: `berserk_1`, then he sprints (204 u/s), and
/// his headlamp is gone with the plate.
///
/// HIS MOVES (host, while he walks at you):
///   • FLAMETHROWER at 115-300: he keeps coming with the jet on (`ft_run`) — never a standing burst since 2026-10-06 (the user:
///     *"it can keep moving while using the flamethrower"*). A 325-long box from the nozzle toward you (`PanzerLook.FlameDir`),
///     every 0.125 s; it burns your screen (the napalm zombie's overlay). ⚠️ Upstream's box is aligned to the world, not the nozzle, and ignores walls; here it points where he
///     does and walls stop it.
///   • His arm is a CLAW or a TASER (rolled per Panzer, upstream's `math.random(1, 2)`):
///     – CLAW at 350-850, in sight, every 5-12 s: he aims, fires it (1000 u/s, back after 0.95 s if it misses). ⛔ IT CATCHES YOU
///       AND DOES NOT LET GO (the user, 2026-10-06): no legs of your own, dragged along the floor to an arm's length in front of
///       him (<see cref="HoldPoint"/>) and held there while he beats you — until you KNIFE your way out (<see cref="KnifeMin"/>-
///       <see cref="KnifeMax"/> swings, rolled per catch, hit or miss; shooting does nothing), you go down, or he dies. He stands
///       frozen only while he reels you in.
///     – TASER at 350-1250, facing you, every 5-12 s: three bolts of lightning. ⚠️ Upstream's `nz_glowstick` exists nowhere in
///       the install; these are Avogadro's bolts, smaller (40 u, a 1 s daze, half a swipe each).
/// HE ARRIVES from the sky, boosters burning (his `arrive_fast` drops him 3,900 units; under a roof `arrive_tomb_short`), to an
/// alarm. HE DIES and blows up a few seconds later (upstream's ragdoll explosion).
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: `NZNet.Panzer*`, `PanzerFx`, `PanzerLook`; his special clips are relayed.
/// ⛔ NO ATTACK OF HIS DOWNS ANYONE FROM FULL HEALTH (<see cref="HitCap"/>), Oberon's rule.
/// </summary>
public sealed class PanzerBoss : Component
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1.

	static float? _plateShare, _coreShare, _plateReach, _coreReach, _pieceScale, _allScale, _plateScale, _coreScale;
	static int? _piecePoints;
	/// <summary>The faceplate's own health, a share of his max: 0.5; the core's 0.25 — upstream's.</summary>
	public static float PlateShare { get => _plateShare ?? 0.5f; set => _plateShare = value; }
	public static float CoreShare { get => _coreShare ?? 0.25f; set => _coreShare = value; }
	/// <summary>How near a hit must land to count on a piece, at size 1: 20 for the plate, 15 for the core — upstream's.</summary>
	public static float PlateReach { get => _plateReach ?? 20f; set => _plateReach = value; }
	public static float CoreReach { get => _coreReach ?? 15f; set => _coreReach = value; }
	/// <summary>What he takes from a hit on a piece: ×0.015; on everything: ×0.95, ×0.75 more with the plate on, ×0.25 with the core.</summary>
	public static float PieceScale { get => _pieceScale ?? 0.015f; set => _pieceScale = value; }
	public static float AllScale { get => _allScale ?? 0.95f; set => _allScale = value; }
	public static float PlateScale { get => _plateScale ?? 0.75f; set => _plateScale = value; }
	public static float CoreScale { get => _coreScale ?? 0.25f; set => _coreScale = value; }
	/// <summary>Points for shooting a piece off: 90, upstream's.</summary>
	public static int PiecePoints { get => _piecePoints ?? 90; set => _piecePoints = value; }

	static float? _runSpeed, _sprintSpeed, _hitCap;
	/// <summary>His run (162.8, `nz_soldat_run`) and his angry sprint (204.4, `nz_soldat_sprint`), measured.</summary>
	public static float RunSpeed { get => _runSpeed ?? 162.8f; set => _runSpeed = value; }
	public static float SprintSpeed { get => _sprintSpeed ?? 204.4f; set => _sprintSpeed = value; }
	/// <summary>The most one hit of his takes, as a share of the match's base health. Two-thirds, Oberon's.</summary>
	public static float HitCap { get => _hitCap ?? (2f / 3f); set => _hitCap = value; }
	/// <summary>The speed that picks the flame walk's tier (a hair over the run's, `panzer.zvar`).</summary>
	const float FlameSpeed = 162.9f;

	static float? _flameMin, _flameMax, _flameStand, _flameCancel, _flameReach, _flameHalfWidth, _flameWeight;
	/// <summary>He lights up for a target 115-300 away (upstream's), walking. `FlameStand`/`FlameCancel` were the standing burst's
	/// (beyond 200, stopping past 400), unused since he never stands to flame (2026-10-06).</summary>
	public static float FlameMin { get => _flameMin ?? 115f; set => _flameMin = value; }
	public static float FlameMax { get => _flameMax ?? 300f; set => _flameMax = value; }
	public static float FlameStand { get => _flameStand ?? 200f; set => _flameStand = value; }
	public static float FlameCancel { get => _flameCancel ?? 400f; set => _flameCancel = value; }
	/// <summary>The jet's reach and half-width: 325 and 20, upstream's box.</summary>
	public static float FlameReach { get => _flameReach ?? 325f; set => _flameReach = value; }
	public static float FlameHalfWidth { get => _flameHalfWidth ?? 20f; set => _flameHalfWidth = value; }
	/// <summary>A tick of flame (every 0.125 s) against one zombie's hit: 0.12 — upstream's 6 against 50, about one hit a second.</summary>
	public static float FlameWeight { get => _flameWeight ?? 0.12f; set => _flameWeight = value; }
	const float FlameTick = 0.125f;

	static float? _clawMin, _clawMax, _clawSpeed, _clawOut, _clawHold, _gapMin, _gapMax;
	/// <summary>The claw: a target 350-850 away, 1000 u/s, back after 0.95 s — upstream's. `ClawHold` (3 s) is now only how long
	/// he stands frozen reeling a catch in before he walks to them instead; the catch itself has no time limit (2026-10-06).</summary>
	public static float ClawMin { get => _clawMin ?? 350f; set => _clawMin = value; }
	public static float ClawMax { get => _clawMax ?? 850f; set => _clawMax = value; }
	public static float ClawSpeed { get => _clawSpeed ?? 1000f; set => _clawSpeed = value; }
	public static float ClawOut { get => _clawOut ?? 0.95f; set => _clawOut = value; }
	public static float ClawHold { get => _clawHold ?? 3f; set => _clawHold = value; }

	static int? _knifeMin, _knifeMax;
	/// <summary>Knife swings that cut the one he holds free: 3-5, rolled per catch (the user, 2026-10-06) — swings, hit or miss.</summary>
	public static int KnifeMin { get => _knifeMin ?? 3; set => _knifeMin = value; }
	public static int KnifeMax { get => _knifeMax ?? 5; set => _knifeMax = value; }
	/// <summary>Seconds between claws, or taser volleys: 5-12, upstream's.</summary>
	public static float WeaponGapMin { get => _gapMin ?? 5f; set => _gapMin = value; }
	public static float WeaponGapMax { get => _gapMax ?? 12f; set => _gapMax = value; }

	static float? _taserMin, _taserMax, _taserWeight, _taserDaze, _taserRadius;
	/// <summary>The taser: a target 350-1250 away, three bolts of half a swipe, 40 u, a 1 s daze.</summary>
	public static float TaserMin { get => _taserMin ?? 350f; set => _taserMin = value; }
	public static float TaserMax { get => _taserMax ?? 1250f; set => _taserMax = value; }
	public static float TaserWeight { get => _taserWeight ?? 0.5f; set => _taserWeight = value; }
	public static float TaserDaze { get => _taserDaze ?? 1f; set => _taserDaze = value; }
	public static float TaserRadius { get => _taserRadius ?? 40f; set => _taserRadius = value; }

	public static float MaxHit => MathF.Max( 1f, Difficulty.MaxHealth * Math.Clamp( HitCap, 0.1f, 0.95f ) );

	/// <summary>`tag_powersupply` in `j_spinelower`'s frame; `tag_claw` and the taser's muzzle in `tag_gun_spin`'s — from the QC.</summary>
	static Vector3 CoreOffset => new( 3f, 9f, 7f );
	static Vector3 ClawOffset => new( -4f, 0f, -1.5f );
	static Vector3 MuzzleOffset => new( 19f, -1.6f, 1.2f );

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

	ZombieAI _ai;
	Health _hp;
	SkinnedModelRenderer _body;
	PanzerLook _look;

	bool _armed, _plateOn = true, _coreOn = true, _angry, _shielded, _deadHandled, _blownUp, _hasClaw, _clawHolding;
	float _plateHp, _coreHp, _nextSpark, _nextClaw, _nextTaser, _deathAt;
	bool _flaming, _flameStanding;
	float _flameStandUntil, _nextFlameTick;
	PanzerClaw _claw;

	enum Beat { Land, Berserk, Angry, ClawFire, TaserShot }
	readonly List<(float At, Beat What)> _pending = new();

	public bool Angry => _angry;
	public bool PlateOn => _plateOn;
	public bool CoreOn => _coreOn;

	Vector3 Facing => _ai.IsValid() ? (WorldRotation * _ai.ModelTurn.Inverse).Forward.WithZ( 0 ).Normal : WorldRotation.Forward;
	float Size => MathF.Max( 0.1f, WorldScale.x );
	Vector3 Voice => WorldPosition + Vector3.Up * 70f * Size;

	protected override void OnStart()
	{
		_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		_hp = Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
		_body = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
		_look = Components.GetOrCreate<PanzerLook>();

		if ( NZGame.IsClient || !_ai.IsValid() ) return;

		_hasClaw = _look.HasClaw;
		var now = Time.Now;
		_nextClaw = now + Game.Random.Float( 5f, 25f );   // upstream meant 5-25 s after he arrives
		_nextTaser = now + 3f;
		_ai.MaxHitDamage = MaxHit;

		// ⛔ NONE OF THE WALKER'S CLIMB CLIPS ON HIS MODEL (Oberon's note): his own mantle, in place
		_ai.ClimbClipOverride = "nz_soldat_mantle_48";

		NZSound.PlayShared( "nz.panzer.alarm" );

		// ⚠️ A ROOF ABOVE: his drop from 3,900 units would come through it — upstream's short arrival instead (a tall hull up 750)
		var roof = Scene.Trace.Ray( WorldPosition + Vector3.Up * 16f, WorldPosition + Vector3.Up * 750f )
			.WithoutTags( "zombie", "player", "trigger" ).Run();
		var landAt = 28f / 30f;
		if ( roof.Hit && _body.IsValid() )
		{
			_body.Sequence.Name = "nz_soldat_arrive_tomb_short";
			_body.Sequence.Time = 0f;
			if ( Networking.IsActive ) NZNet.ZombieClip( GameObject.Id, "nz_soldat_arrive_tomb_short", 1f );
			AvogadroFx.Flash( WorldPosition + Vector3.Up * 80f, 1.2f );
			landAt = 5f / 30f;
		}
		Queue( landAt, Beat.Land );

		Log.Info( $"[nz-panzer] spawned — {_hp?.Max ?? 0f:0} hp · {(_hasClaw ? "CLAW" : "TASER")} · {(roof.Hit ? "short arrival (roof)" : "drop")}"
			+ $" · hitboxes {_ai.HitboxCount}" );
	}

	protected override void OnUpdate()
	{
		if ( !_ai.IsValid() ) return;

		if ( _ai.State == ZombieState.Dead )
		{
			OnDeath();
			TickBlowUp();
			return;
		}

		if ( _ai.IsPuppet ) return;

		_ai.MaxHitDamage = MaxHit;
		Arm();
		TickShield();
		TickPending();
		TickFlame();
		TickWeapon();
	}

	/// <summary>The pieces' health, once his own is known (`ZombieAI` sizes it after this component exists).</summary>
	void Arm()
	{
		if ( _armed || !_hp.IsValid() || _hp.Max <= 1f ) return;
		_armed = true;
		_plateHp = _hp.Max * PlateShare;
		_coreHp = _hp.Max * CoreShare;
	}

	/// <summary>Untouchable while he drops in, upstream's.</summary>
	void TickShield()
	{
		var want = _ai.State == ZombieState.Spawning;
		if ( want == _shielded || !_hp.IsValid() ) return;
		_shielded = want;
		_hp.Invulnerable = want;
	}

	// ══ the armour ═══════════════════════════════════════════════════════════

	/// <summary>
	/// The multiplier for one hit, and the piece it chips or takes off. THE HOST (`Health.OnDamage`).
	///
	/// ⛔ IT MUTATES, SO EXACTLY ONCE PER HIT, the helmet's rule. And it is handed the hit's amount BEFORE this scale, as the
	/// helmet is: a piece takes the raw hit.
	/// </summary>
	public float ScaleFor( float amount, Vector3 hitPos, GameObject attacker )
	{
		if ( _deadHandled ) return 1f;
		if ( _shielded ) return 0f;

		var scale = 1f;
		if ( _armed && !hitPos.IsNearlyZero() && _body.IsValid() )
		{
			if ( _plateOn && _body.TryGetBoneTransform( "j_faceplate", out var plate )
				&& hitPos.Distance( plate.Position ) <= PlateReach * Size )
			{
				_plateHp -= MathF.Max( 0f, amount );
				ArmourHit( hitPos );
				scale = PieceScale;
				if ( _plateHp <= 0f ) BreakPiece( 0, attacker );
			}
			else if ( _coreOn && _body.TryGetBoneTransform( "j_spinelower", out var spine )
				&& hitPos.Distance( spine.PointToWorld( CoreOffset ) ) <= CoreReach * Size )
			{
				_coreHp -= MathF.Max( 0f, amount );
				ArmourHit( hitPos );
				scale = PieceScale;
				if ( _coreHp <= 0f ) BreakPiece( 1, attacker );
			}
		}

		return scale * AllScale * (_plateOn ? PlateScale : 1f) * (_coreOn ? CoreScale : 1f);
	}

	/// <summary>The scale for a hit on this object: the Panzer's table, or 1 when it is not him (`Health.OnDamage`).</summary>
	public static float ScaleOn( GameObject victim, float amount, Vector3 hitPos, GameObject attacker )
	{
		if ( !victim.IsValid() ) return 1f;
		var p = victim.Components.Get<PanzerBoss>( FindMode.EverythingInSelfAndAncestors );
		return p.IsValid() ? p.ScaleFor( amount, hitPos, attacker ) : 1f;
	}

	/// <summary>A clank and sparks off a piece, throttled per bullet (upstream's metal impact and `npcarmor_hit`).</summary>
	void ArmourHit( Vector3 at )
	{
		if ( Time.Now < _nextSpark ) return;
		_nextSpark = Time.Now + 0.08f;
		NZSound.PlayShared( "nz.panzer.armor.hit", at );
		NZNet.PanzerSparks( at );
	}

	/// <summary>A piece off: everyone sees it go, the shooter gets 90, he staggers, and the faceplate makes him angry. THE HOST.</summary>
	void BreakPiece( int piece, GameObject attacker )
	{
		if ( piece == 0 ) { if ( !_plateOn ) return; _plateOn = false; }
		else { if ( !_coreOn ) return; _coreOn = false; }

		NZNet.PanzerArmor( GameObject.Id, piece );
		NZSound.PlayShared( "nz.panzer.armor.break", Voice );
		NZSound.PlayShared( "nz.panzer.pain", Voice );

		// ⚠️ THE PAY GOES THROUGH `AddPoints`, which relays to a client's own machine
		var shooter = attacker.IsValid() ? attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
		if ( shooter.IsValid() && PiecePoints > 0 ) shooter.AddPoints( PiecePoints );

		StopFlame();
		var clip = piece == 0 ? "nz_soldat_pain_faceplate" : "nz_soldat_powercore_pain";
		var len = piece == 0 ? 60f / 30f : 66f / 30f;
		var staggered = PlayShared( clip, len );
		if ( piece == 0 && !_angry ) Queue( staggered ? len : 0.05f, Beat.Berserk );

		Log.Info( $"[nz-panzer] {(piece == 0 ? "FACEPLATE" : "POWER CORE")} OFF"
			+ $" · {(shooter.IsValid() ? $"+{PiecePoints} to {shooter.GameObject.Name}" : "no shooter")}"
			+ $" · now x{AllScale * (_plateOn ? PlateScale : 1f) * (_coreOn ? CoreScale : 1f):0.###}" );
	}

	/// <summary>Angry: `berserk_1` and the sprint (upstream's `SetRunSpeed(71)`, once). THE HOST.</summary>
	void Berserk()
	{
		if ( _angry ) return;
		_angry = true;
		_ai.SpeedOverride = MathF.Max( 1f, SprintSpeed * Size );
		_ai.RepickAnimations();
		if ( PlayShared( "nz_soldat_berserk_1", 42f / 30f ) ) Queue( 21f / 30f, Beat.Angry );
		else NZSound.PlayShared( "nz.panzer.angry", Voice );
		Log.Info( $"[nz-panzer] ANGRY — sprinting at {_ai.SpeedOverride:0} u/s" );
	}

	// ══ the flamethrower ═════════════════════════════════════════════════════

	void TickFlame()
	{
		var target = _ai.Target;
		var p = target.IsValid() ? target.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
		var dist = p.IsValid() ? WorldPosition.Distance( p.WorldPosition ) : float.MaxValue;

		if ( !_flaming )
		{
			if ( _ai.State != ZombieState.Chasing || !p.IsValid() || (p.IsDown || p.DownedNet) || _claw.IsValid() ) return;
			if ( dist <= FlameMin * Size || dist >= FlameMax * Size || !CanSee( target ) ) return;

			// ⚠️ ALWAYS ON THE MOVE (the user, 2026-10-06: *"it can keep moving while using the flamethrower"*) — the standing
			// burst (`flamethrower_stand_aim`, beyond 200 and facing you) is gone
			StartFlame( false );
			return;
		}

		// ── on: when to stop ──
		if ( _flameStanding )
		{
			if ( p.IsValid() ) Face( p.WorldPosition );
			var lost = !p.IsValid() || (p.IsDown || p.DownedNet) || dist > FlameCancel * Size || Time.Now >= _flameStandUntil
				|| _ai.State != ZombieState.Special;
			if ( lost ) { StopFlame(); return; }
		}
		else
		{
			var lost = !p.IsValid() || (p.IsDown || p.DownedNet) || dist <= FlameMin * Size || dist >= FlameMax * Size
				|| _ai.State != ZombieState.Chasing;
			if ( lost ) { StopFlame(); return; }
		}

		if ( Time.Now < _nextFlameTick ) return;
		_nextFlameTick = Time.Now + FlameTick;
		Burn();
	}

	/// <summary>Light up: standing (a 3 s burst, re-weighed after) or walking (the flame walk's tier). THE HOST.</summary>
	void StartFlame( bool standing )
	{
		if ( standing && !PlayShared( "nz_soldat_flamethrower_stand_aim", 3f ) ) return;

		_flaming = true;
		_flameStanding = standing;
		_flameStandUntil = Time.Now + 3f;
		_nextFlameTick = Time.Now + 0.2f;
		NZNet.PanzerFlame( GameObject.Id, true );

		if ( !standing )
		{
			_ai.SpeedOverride = MathF.Max( 1f, FlameSpeed * Size );
			_ai.RepickAnimations();
		}
	}

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

		if ( _flameStanding && _ai.State == ZombieState.Special ) _ai.CancelSpecial();
		_flameStanding = false;

		_ai.SpeedOverride = MathF.Max( 1f, (_angry ? SprintSpeed : RunSpeed) * Size );
		_ai.RepickAnimations();
	}

	/// <summary>One tick of flame: every player in the box in front of the nozzle, in its line, under his cap. THE HOST.</summary>
	void Burn()
	{
		var nozzle = WorldPosition + Vector3.Up * 50f * Size;
		if ( _body.IsValid() && _body.TryGetBoneTransform( "j_elbow_ri", out var elbow ) )
			nozzle = elbow.PointToWorld( new Vector3( -34f, -0.3f, -8.4f ) );

		// ⛔ THE JET'S OWN AIM, NOT HIS FACING (2026-10-06 fix): from his right forearm a box along his facing ran ~55 units to his
		// side — beside the player it was drawn past. Every machine draws the jet the same way (`PanzerLook.FlameDir`).
		var dir = PanzerLook.FlameDir( GameObject, nozzle, Facing, FlameReach * Size );

		int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
		var damage = MathF.Min( ZombieStats.AttackDamageForRound( round ) * FlameWeight * Difficulty.ZombieDamage, MaxHit );

		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) continue;
			var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
			if ( !hp.IsValid() || hp.IsDead ) continue;

			var chest = p.WorldPosition + Vector3.Up * 40f;
			var v = chest - nozzle;
			var along = v.Dot( dir );
			if ( along < 0f || along > FlameReach * Size ) continue;
			var side = (v - dir * along).WithZ( 0 ).Length;
			if ( side > FlameHalfWidth * Size + 16f || MathF.Abs( v.z ) > 60f ) continue;

			var tr = Scene.Trace.Ray( nozzle, chest ).IgnoreGameObjectHierarchy( GameObject )
				.WithoutTags( "zombie", "player", "trigger" ).Run();
			if ( tr.Hit && tr.HitPosition.Distance( chest ) > 24f ) continue;

			hp.Apply( damage, false, GameObject, tick: true, source: "panzer flame" );
			NZNet.PanzerScorch( p.GameObject.Id );
		}
	}

	// ══ the claw / the taser ═════════════════════════════════════════════════

	void TickWeapon()
	{
		if ( _ai.State != ZombieState.Chasing || _flaming || _claw.IsValid() ) return;

		var target = _ai.Target;
		var p = target.IsValid() ? target.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
		if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) return;

		var dist = WorldPosition.Distance( p.WorldPosition );
		var now = Time.Now;

		if ( _hasClaw )
		{
			if ( now < _nextClaw || dist < ClawMin * Size || dist > ClawMax * Size ) return;
			// ⚠️ A LOOK FIRST, AND A FAILED ONE STILL COSTS THE WAIT (upstream's): from his eyes to yours
			_nextClaw = now + Game.Random.Float( WeaponGapMin, WeaponGapMax );
			if ( !CanSee( target ) ) return;
			StartClaw( p );
		}
		else
		{
			if ( now < _nextTaser || dist < TaserMin * Size || dist > TaserMax * Size ) return;
			if ( !FacingWithin( p.WorldPosition, 45f ) ) return;
			StartTaser( p );
		}
	}

	/// <summary>Aim (`weapon_intro_to_aim`), then the claw. THE HOST.</summary>
	bool StartClaw( NZPlayer p )
	{
		Face( p.WorldPosition );
		if ( !PlayShared( "nz_soldat_weapon_intro_to_aim", 36f / 30f + 0.1f ) ) return false;
		Queue( 36f / 30f, Beat.ClawFire );
		return true;
	}

	/// <summary>Aim, then three bolts, one per `weapon_fire`. THE HOST.</summary>
	bool StartTaser( NZPlayer p )
	{
		Face( p.WorldPosition );
		const float aim = 36f / 30f, shot = 28f / 30f;
		if ( !PlayShared( "nz_soldat_weapon_intro_to_aim", aim + 3f * shot + 0.1f ) ) return false;

		_nextTaser = Time.Now + aim + 3f * shot + Game.Random.Float( WeaponGapMin, WeaponGapMax );
		for ( int i = 0; i < 3; i++ ) Queue( aim + i * shot, Beat.TaserShot );
		return true;
	}

	/// <summary>Where the claw sits on his arm (`tag_claw`), or near enough.</summary>
	public Vector3 ClawPoint
	{
		get
		{
			if ( _body.IsValid() && _body.TryGetBoneTransform( "tag_gun_spin", out var gun ) ) return gun.PointToWorld( ClawOffset );
			return WorldPosition + Vector3.Up * 50f * Size + Facing * 30f * Size;
		}
	}

	/// <summary>
	/// Where the one his claw holds is kept (2026-10-06): an arm's length in front of him, a little to his left (the claw arm's
	/// side), inside his swing's reach but never inside him — and off his body, not his swinging arm's bones, so the hold does not
	/// sway with his walk. Read on every machine: the held player's own machine drags itself here (`PanzerGrab`).
	/// </summary>
	public Vector3 HoldPoint
	{
		get
		{
			var f = Facing;
			var left = new Vector3( -f.y, f.x, 0f );
			return WorldPosition + (Vector3.Up * 50f + f * 46f + left * 14f) * Size;
		}
	}

	Vector3 Muzzle
	{
		get
		{
			if ( _body.IsValid() && _body.TryGetBoneTransform( "tag_gun_spin", out var gun ) ) return gun.PointToWorld( MuzzleOffset );
			return WorldPosition + Vector3.Up * 50f * Size + Facing * 30f * Size;
		}
	}

	/// <summary>The claw is back on his arm (or gone): its bodygroup back, and he moves again. THE HOST (`PanzerClaw`).</summary>
	public void ClawDone()
	{
		_claw = null;
		NZNet.PanzerWeapon( GameObject.Id, 1 );
		NZSound.PlayShared( "nz.panzer.claw.retract", ClawPoint );
		if ( _clawHolding && _ai.IsValid() && _ai.State == ZombieState.Special ) _ai.CancelSpecial();
		_clawHolding = false;

		// a hold can outlast the wait drawn when it was fired: never straight back out at the one just freed
		_nextClaw = MathF.Max( _nextClaw, Time.Now + WeaponGapMin );
	}

	/// <summary>
	/// The one he caught is in his grip: he stops standing frozen and fights — the claw keeps them (`PanzerClaw`), right in his
	/// swing's reach. THE HOST.
	/// </summary>
	public void ClawArrived()
	{
		if ( !_clawHolding ) return;
		_clawHolding = false;
		if ( _ai.IsValid() && _ai.State == ZombieState.Special ) _ai.CancelSpecial();
	}

	/// <summary>
	/// The one his claw holds knifed it enough times (`NZNet.PanzerClawCut`, counted on their own machine): he lets go, with the
	/// armour-break sound and his pain cry. THE HOST.
	/// </summary>
	void ClawCut( NZPlayer by )
	{
		NZSound.PlayShared( "nz.panzer.armor.break", ClawPoint );
		NZSound.PlayShared( "nz.panzer.pain", Voice );
		_claw.Free();
		Log.Info( $"[nz-panzer] CLAW knifed off by {by.GameObject.Name} — he lets go" );
	}

	/// <summary>
	/// A player knifed their way out (`NZNet.PanzerClawCut`): the Panzer whose claw holds THEM lets go — nobody else's. False when
	/// no claw holds them (a message arriving after a down or his death already let go). THE HOST.
	/// </summary>
	public static bool CutBy( NZPlayer p )
	{
		if ( !p.IsValid() ) return false;
		foreach ( var b in All )
		{
			if ( !b._claw.IsValid() || !b._claw.Holds( p ) ) continue;
			b.ClawCut( p );
			return true;
		}
		return false;
	}

	// ══ beats ════════════════════════════════════════════════════════════════

	void Queue( float after, Beat what ) => _pending.Add( (Time.Now + after, what) );

	void TickPending()
	{
		for ( int i = _pending.Count - 1; i >= 0; i-- )
		{
			if ( i >= _pending.Count ) continue;
			var p = _pending[i];
			if ( Time.Now < p.At ) continue;
			_pending.RemoveAt( i );
			Run( p.What );
		}
	}

	void Run( Beat what )
	{
		var target = _ai.Target;

		switch ( what )
		{
			case Beat.Land:
				// `mech_land` + `mech_spawn_land` + `mech_alarm`: the thud, the shake, the second alarm
				NZSound.PlayShared( "nz.panzer.land", WorldPosition );
				NZSound.PlayShared( "nz.panzer.alarm2", Voice );
				NZNet.ShakeAt( WorldPosition, 0.6f, 1200f );
				ShockRing.FireShared( WorldPosition + Vector3.Up * 4f, 180f * Size, new Color( 0.75f, 0.65f, 0.5f ) );
				break;

			case Beat.Berserk:
				Berserk();
				break;

			case Beat.Angry:
				NZSound.PlayShared( "nz.panzer.angry", Voice );
				break;

			case Beat.ClawFire:
			{
				if ( !target.IsValid() || _ai.State != ZombieState.Special ) break;
				Face( target.WorldPosition );
				SwapClip( "nz_soldat_weapon_fire" );

				// ⚠️ HE STANDS FROZEN UNTIL IT IS BACK (upstream's special animation for as long as the claw lives); `ClawDone`
				// lets him go, and the hold is a ceiling in case it never comes
				_ai.HoldSpecialFor( ClawOut + ClawHold + 1.5f );
				_clawHolding = true;

				var from = ClawPoint;
				var to = target.WorldPosition + Vector3.Up * 50f;
				NZNet.PanzerWeapon( GameObject.Id, 0 );
				NZSound.PlayShared( "nz.panzer.claw.fire", from );
				NZSound.PlayShared( "nz.panzer.angry", Voice );
				_claw = PanzerClaw.Launch( Scene, from, to - from, this );
				if ( !_claw.IsValid() ) ClawDone();
				break;
			}

			case Beat.TaserShot:
			{
				if ( !target.IsValid() || _ai.State != ZombieState.Special ) break;
				Face( target.WorldPosition );
				SwapClip( "nz_soldat_weapon_fire" );

				int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
				var swing = ZombieStats.AttackDamageForRound( round ) * (_ai.Variant?.DamageMultiplier ?? 2f) * Difficulty.ZombieDamage;
				var from = Muzzle;
				var to = target.WorldPosition + Vector3.Up * 45f;
				AvogadroBolt.Launch( Scene, from, to - from, GameObject, swing * TaserWeight, TaserRadius, TaserDaze, 1000f );
				NZSound.PlayShared( "nz.avo.zap", from );
				break;
			}
		}
	}

	// ══ death ════════════════════════════════════════════════════════════════

	/// <summary>Dead, on every machine: the flame out, the claw dropped; the host's killshot and his corpse timer.</summary>
	void OnDeath()
	{
		if ( _deadHandled ) return;
		_deadHandled = true;
		_deathAt = Time.Now;
		_pending.Clear();

		if ( _look.IsValid() ) _look.Flaming = false;
		if ( _ai.IsPuppet ) return;

		if ( _flaming ) { _flaming = false; NZNet.PanzerFlame( GameObject.Id, false ); }
		if ( _claw.IsValid() ) _claw.Drop();

		NZSound.PlayShared( "nz.panzer.killshot", Voice );
		NZSound.PlayShared( "nz.panzer.rise", Voice );
		_ai.CorpseLinger = 0.2f;
	}

	/// <summary>
	/// The blast a few seconds after he goes down (upstream's ragdoll: `rise`, then 2 s, then `hound_explosion` three times and
	/// gone) — each machine hides its own copy; the host sets the fireball off for everyone.
	/// </summary>
	void TickBlowUp()
	{
		if ( _blownUp || Time.Now < _deathAt + 3.4f ) return;
		_blownUp = true;

		var at = WorldPosition + Vector3.Up * 40f * Size;
		if ( _body.IsValid() && _body.TryGetBoneTransform( "j_mainroot", out var root ) ) at = root.Position;
		MargwaFx.SetHidden( GameObject, true );

		if ( NZGame.IsClient ) return;
		BlastEffect.Spawn( at, 200f * Size );
		NZSound.PlayShared( "nz.panzer.explode", at );
		NZNet.ShakeAt( at, 0.7f, 1500f );
	}

	// ── helpers ──

	/// <summary>`ZombieAI.PlaySpecial`, and the same clip on every watching machine — `PlaySpecial` does not relay.</summary>
	bool PlayShared( string clip, float seconds )
	{
		if ( !_ai.PlaySpecial( clip, seconds ) ) return false;
		if ( Networking.IsActive ) NZNet.ZombieClip( GameObject.Id, clip, 1f );
		return true;
	}

	/// <summary>The next clip of a move already running, written to the renderer the special owns, and relayed.</summary>
	void SwapClip( string clip )
	{
		if ( _ai.State != ZombieState.Special || !_body.IsValid() ) return;
		_body.Sequence.Name = clip;
		_body.Sequence.Time = 0f;
		if ( Networking.IsActive ) NZNet.ZombieClip( GameObject.Id, clip, 1f );
	}

	void Face( Vector3 at )
	{
		var dir = (at - WorldPosition).WithZ( 0 );
		if ( dir.Length < 1f || !_ai.IsValid() ) return;
		WorldRotation = Rotation.LookAt( dir.Normal, Vector3.Up ) * _ai.ModelTurn;
	}

	bool FacingWithin( Vector3 at, float degrees )
	{
		var to = (at - WorldPosition).WithZ( 0 );
		if ( to.Length < 1f ) return true;
		return Vector3.GetAngle( Facing, to.Normal ) <= degrees;
	}

	bool CanSee( GameObject target )
	{
		var from = Voice;
		var to = target.WorldPosition + Vector3.Up * 60f;
		var tr = Scene.Trace.Ray( from, to ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "zombie", "trigger" ).Run();
		return !tr.Hit || tr.HitPosition.Distance( to ) < 40f;
	}

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

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

	/// <summary>`nz_panzer` — every Panzer alive, and the table.</summary>
	[ConCmd( "nz_panzer" )]
	public static void Report()
	{
		Log.Info( $"[nz-panzer] plate {PlateShare:0.##} of max (reach {PlateReach:0}), core {CoreShare:0.##} (reach {CoreReach:0})"
			+ $" · piece hit x{PieceScale:0.###} · all x{AllScale:0.##}, plate on x{PlateScale:0.##}, core on x{CoreScale:0.##}"
			+ $" · flame {FlameMin:0}-{FlameMax:0}u, {FlameReach:0} long, x{FlameWeight:0.##}/tick · claw {ClawMin:0}-{ClawMax:0}u"
			+ $" (knifed free in {KnifeMin}-{KnifeMax} swings)"
			+ $" · taser {TaserMin:0}-{TaserMax:0}u · hit cap {MaxHit:0}" );

		var list = All.ToList();
		Log.Info( $"[nz-panzer] {list.Count} alive" );
		foreach ( var p in list )
			Log.Info( $"[nz-panzer]   {(p._hasClaw ? "claw" : "taser")} · hp {p._hp?.Current ?? 0f:0}/{p._hp?.Max ?? 0f:0}"
				+ $" · plate {(p._plateOn ? $"{p._plateHp:0}" : "OFF")} · core {(p._coreOn ? $"{p._coreHp:0}" : "OFF")}"
				+ $"{(p._angry ? " · ANGRY" : "")}{(p._flaming ? " · FLAMING" : "")} · state {p._ai?.State} · hitboxes {p._ai?.HitboxCount ?? 0}" );
	}

	/// <summary>`nz_panzer_break &lt;0|1&gt;` — shoot the faceplate (0) or the power core (1) off every Panzer.</summary>
	[ConCmd( "nz_panzer_break" )]
	public static void BreakCmd( int piece = 0 )
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-panzer] the host decides" ); return; }
		foreach ( var p in All.ToList() ) p.BreakPiece( Math.Clamp( piece, 0, 1 ), null );
	}

	/// <summary>`nz_panzer_flame` — every Panzer lights up now (walking), whoever is in front.</summary>
	[ConCmd( "nz_panzer_flame" )]
	public static void FlameCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-panzer] the host decides" ); return; }
		foreach ( var p in All.Where( p => p._ai.IsValid() && !p._flaming ).ToList() ) p.StartFlame( false );
	}

	/// <summary>`nz_panzer_weapon` — every Panzer uses its claw or taser on its target now, whatever the range.</summary>
	[ConCmd( "nz_panzer_weapon" )]
	public static void WeaponCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-panzer] the host decides" ); return; }
		foreach ( var p in All.Where( p => p._ai.IsValid() ).ToList() )
		{
			var t = p._ai.Target.IsValid() ? p._ai.Target.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
			if ( !t.IsValid() ) continue;
			if ( p._hasClaw ) p.StartClaw( t ); else p.StartTaser( t );
		}
	}

	/// <summary>`nz_panzer_set &lt;key&gt; &lt;value&gt;` — retune him live.</summary>
	[ConCmd( "nz_panzer_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "plate": PlateShare = value; break;
			case "core": CoreShare = value; break;
			case "piece": PieceScale = value; break;
			case "all": AllScale = value; break;
			case "platescale": PlateScale = value; break;
			case "corescale": CoreScale = value; break;
			case "points": PiecePoints = (int)value; break;
			case "flame": FlameWeight = value; break;
			case "flamereach": FlameReach = value; break;
			case "clawspeed": ClawSpeed = value; break;
			case "clawhold": ClawHold = value; break;
			case "knifemin": KnifeMin = Math.Max( 1, (int)value ); break;
			case "knifemax": KnifeMax = Math.Max( 1, (int)value ); break;
			case "gapmin": WeaponGapMin = value; break;
			case "gapmax": WeaponGapMax = value; break;
			case "taser": TaserWeight = value; break;
			case "hitcap": HitCap = value; break;
			default:
				Log.Info( "[nz-panzer] nz_panzer_set <plate|core|piece|all|platescale|corescale|points|flame|flamereach|clawspeed"
					+ "|clawhold|knifemin|knifemax|gapmin|gapmax|taser|hitcap> <value>" );
				return;
		}

		Log.Info( $"[nz-panzer] {key} = {value:0.###}" );
		Report();
	}
}

/// <summary>
/// The Panzer's claw in flight (2026-10-06), upstream's `nz_panzer_claw`: out at 1000 u/s, back after 0.95 s; a player it
/// touches is caught and KEPT — dragged to him and held in front of him on that player's own machine (`NZNet.PanzerClawHold`)
/// while the claw rides along at their chest — until they knife it enough times (`NZNet.PanzerClawCut` → `PanzerBoss.CutBy` →
/// <see cref="Free"/>), they go down, or he dies; then it reels back in and is gone.
///
/// ⚠️ THE HOST FLIES IT; EVERYONE ELSE WATCHES: `Launch` network-spawns it; the look (upstream's own claw model, its glow and
/// the cable back to his arm) is built on every machine, the cable's far end found from <see cref="OwnerId"/>; <see cref="Phase"/>
/// tells every machine which clip it holds.
/// </summary>
public sealed class PanzerClaw : Component
{
	public const string ClawModel = "models/zombies/panzer_claw.vmdl";

	/// <summary>The Panzer that fired it, by id — a reference every machine can resolve.</summary>
	[Property] public Guid OwnerId { get; set; }

	/// <summary>0 flying, 1 holding someone, 2 reeling back in.</summary>
	[Sync( SyncFlags.FromHost )] public int Phase { get; set; }

	PanzerBoss _owner;
	Vector3 _dir;
	float _launched, _reelStart;
	NZPlayer _victim;
	bool _done, _arrived;

	/// <summary>Is it holding someone now. THE HOST.</summary>
	public bool Holding => Phase == 1 && _victim.IsValid();

	/// <summary>Is it holding this player now. THE HOST.</summary>
	public bool Holds( NZPlayer p ) => Holding && _victim == p;

	SkinnedModelRenderer _model;
	LineRenderer _cable;
	int _shownPhase = -1;

	public static PanzerClaw Launch( Scene scene, Vector3 from, Vector3 dir, PanzerBoss owner )
	{
		if ( !scene.IsValid() || NZGame.IsClient || !owner.IsValid() ) return null;

		var go = new GameObject( true, "nz_panzer_claw" );
		go.Flags |= GameObjectFlags.NotSaved;
		go.WorldPosition = from;
		go.WorldRotation = Rotation.LookAt( dir.Length > 0.01f ? dir.Normal : Vector3.Forward, Vector3.Up );

		var c = go.Components.Create<PanzerClaw>();
		c.OwnerId = owner.GameObject.Id;
		c._owner = owner;

		go.NetworkSpawn();
		return c;
	}

	protected override void OnStart()
	{
		_dir = WorldRotation.Forward;
		_launched = Time.Now;

		var lightGo = new GameObject { Parent = GameObject, Name = "glow" };
		lightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		var light = lightGo.Components.Create<PointLight>();
		light.LightColor = new Color( 1f, 90f / 255f, 0f ) * 2.5f;   // upstream's `redglow1` sprite colour
		light.Radius = 110f;
		light.Shadows = false;

		var model = Model.Load( ClawModel );
		if ( model is not null && !model.IsError )
		{
			var mgo = new GameObject { Parent = GameObject, Name = "claw" };
			mgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
			_model = mgo.Components.Create<SkinnedModelRenderer>();
			_model.Model = model;
			_model.UseAnimGraph = false;
			_model.Sequence.Name = "anim_idle";
		}
		else Log.Warning( $"[nz-panzer] no '{ClawModel}' — is it compiled? The claw flies as its glow alone" );

		var cgo = new GameObject { Parent = GameObject, Name = "cable" };
		cgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_cable = cgo.Components.Create<LineRenderer>();
		_cable.UseVectorPoints = true;
		_cable.VectorPoints = new List<Vector3>();
		_cable.Color = new Color( 0.12f, 0.11f, 0.1f );
		_cable.Width = 0.6f;
		_cable.CastShadows = false;
	}

	PanzerBoss Owner()
	{
		if ( _owner.IsValid() ) return _owner;
		var go = Game.ActiveScene?.Directory.FindByGuid( OwnerId );
		_owner = go.IsValid() ? go.Components.Get<PanzerBoss>( FindMode.EverythingInSelfAndDescendants ) : null;
		return _owner;
	}

	protected override void OnUpdate()
	{
		var owner = Owner();

		// ── every machine: the clip and the cable ──
		if ( _model.IsValid() && _shownPhase != Phase )
		{
			_shownPhase = Phase;
			_model.Sequence.Name = Phase == 1 ? "anim_close" : Phase == 2 ? "anim_open" : "anim_idle";
			_model.Sequence.Time = 0f;
		}
		if ( _cable.IsValid() && owner.IsValid() )
			_cable.VectorPoints = new List<Vector3> { owner.ClawPoint, WorldPosition };

		if ( NZGame.IsClient || _done ) return;

		// ── the host flies it ──
		if ( !owner.IsValid() || owner.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors )?.State == ZombieState.Dead )
		{
			Drop();
			return;
		}

		var dt = Time.Delta;
		switch ( Phase )
		{
			case 0:
			{
				var next = WorldPosition + _dir * PanzerBoss.ClawSpeed * dt;
				var wall = Scene.Trace.Ray( WorldPosition, next ).IgnoreGameObjectHierarchy( GameObject )
					.WithoutTags( "zombie", "player", "trigger", "ragdoll" ).Run();
				if ( wall.Hit || Time.Now - _launched >= PanzerBoss.ClawOut ) { Reel(); break; }

				WorldPosition = next;
				foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
				{
					if ( !p.IsValid() || (p.IsDown || p.DownedNet) || HeldByAnother( p ) ) continue;
					if ( (p.WorldPosition + Vector3.Up * 45f).Distance( next ) > 34f ) continue;
					Catch( p, owner );
					break;
				}
				break;
			}

			case 1:
			{
				// ⛔ NO TIMEOUT AND NO LETTING GO ONCE THEY REACH HIM (the user: *"the panzer does not let go once the player has been
				// pulled to it, the player must free themselves"*): their knife is `Free` (`PanzerBoss.CutBy`); here only a down, a player gone,
				// or one carried impossibly far from him (a teleport — never a drag) ends it
				var leash = PanzerBoss.ClawMax * 2f * MathF.Max( 1f, owner.WorldScale.x );
				if ( !_victim.IsValid() || _victim.IsDown || _victim.DownedNet
					|| (_victim.WorldPosition - owner.WorldPosition).WithZ( 0f ).Length > leash )
				{
					LetGo();
					Reel();
					break;
				}
				WorldPosition = _victim.WorldPosition + Vector3.Up * 50f;

				// reeled in: he stops standing frozen and fights, still holding them
				if ( !_arrived && (WorldPosition - owner.HoldPoint).WithZ( 0f ).Length < 70f )
				{
					_arrived = true;
					owner.ClawArrived();
				}
				break;
			}

			default:
			{
				var home = owner.ClawPoint;
				WorldPosition = Vector3.Lerp( WorldPosition, home, 1f - MathF.Exp( -14f * dt ) );
				WorldRotation = Rotation.LookAt( (WorldPosition - home).Normal, Vector3.Up );
				if ( WorldPosition.Distance( home ) < 60f || Time.Now - _reelStart > 2.5f )
					Finish( owner );
				break;
			}
		}
	}

	/// <summary>
	/// A player caught: their own machine drags them to him, holds them there, and counts their knife swings — how many cut them
	/// free is rolled here (<see cref="PanzerBoss.KnifeMin"/>-<see cref="PanzerBoss.KnifeMax"/>, the user's *"3-5 times"*). THE HOST.
	/// </summary>
	void Catch( NZPlayer p, PanzerBoss owner )
	{
		_victim = p;
		_arrived = false;
		Phase = 1;

		var lo = Math.Max( 1, PanzerBoss.KnifeMin );
		var knives = Game.Random.Int( lo, Math.Max( lo, PanzerBoss.KnifeMax ) );   // inclusive
		NZSound.PlayShared( "nz.panzer.claw.grab", WorldPosition );
		NZSound.PlayShared( "nz.panzer.claw.loop", WorldPosition );
		NZNet.PanzerClawHold( p.GameObject.Id, OwnerId, knives );
		Log.Info( $"[nz-panzer] CLAW caught {p.GameObject.Name} — held until they knife it {knives} times" );
	}

	/// <summary>Is this player already in another Panzer's claw — one hold at a time.</summary>
	bool HeldByAnother( NZPlayer p )
		=> Scene.GetAllComponents<PanzerClaw>().Any( c => c != this && c.Phase == 1 && c._victim == p );

	/// <summary>Let go of the one it holds and reel back in — they knifed it enough (`PanzerBoss.CutBy`). THE HOST.</summary>
	public void Free()
	{
		if ( _done || Phase != 1 ) return;
		LetGo();
		Reel();
	}

	/// <summary>Back toward his arm (after a miss or a release), with its own few seconds to get there. THE HOST.</summary>
	void Reel()
	{
		Phase = 2;
		_reelStart = Time.Now;
	}

	void LetGo()
	{
		if ( _victim.IsValid() ) NZNet.PanzerClawRelease( _victim.GameObject.Id );
		_victim = null;
	}

	/// <summary>Back on his arm. THE HOST; destroying a network object ends it everywhere.</summary>
	void Finish( PanzerBoss owner )
	{
		if ( _done ) return;
		_done = true;
		LetGo();
		if ( owner.IsValid() ) owner.ClawDone();
		GameObject.Destroy();
	}

	/// <summary>He died, or it lost him: let go and vanish. THE HOST.</summary>
	public void Drop()
	{
		if ( _done ) return;
		_done = true;
		LetGo();
		GameObject.Destroy();
	}
}