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.
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 <0|1>` — 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 <key> <value>` — 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();
}
}