Effects and visual/interaction logic for the Panzer enemy. Defines particle/light prefabs and colours, handles breaking armour pieces, weapon bodygroups, flamethrower visual puffs, sparks, screen scorch for the local player, and the claw grab behaviour that locks and drags a local player until knife swings or release. Also contains PanzerLook which updates lamp, boosters, flame jet aiming, and PanzerFlamePuff for individual flame particles.
using Sandbox;
using System;
using System.Collections.Generic;
namespace NZombies;
/// <summary>
/// The Panzer Soldat's look, and what lands on a player's own machine (2026-10-06): his armour coming off, his arm's weapon,
/// the flamethrower's jet, armour sparks, the claw's pull and the flame's heat on the screen. The host decides; `NZNet.Panzer*`
/// carry it, and each machine draws its own copy here.
///
/// ⚠️ UPSTREAM'S PARTICLES ARE GMOD'S (`asw_mnb_flamethrower`, `npcarmor_hit`, `bo3_panzer_engine`, …), WHICH THIS ENGINE
/// CANNOT LOAD: the jet is the burning zombie's own flames (`napalm_flame.prefab`, as the Fire Margwa's line), thrown forward.
/// </summary>
public static class PanzerFx
{
public const string FlamePrefab = "prefabs/particles/nz/napalm_flame.prefab";
public static Color FireColour => new( 1f, 0.45f, 0.1f );
public static Color SparkColour => new( 1f, 0.75f, 0.35f );
/// <summary>The bodygroup and index for an armour piece: 0 the faceplate, 1 the power core's cap.</summary>
static string PieceGroup( int piece ) => piece == 0 ? "faceplate" : "powercap";
/// <summary>
/// An armour piece off, on THIS machine (`NZNet.PanzerArmor`): its bodygroup to "off", a burst of sparks, and with the
/// faceplate his headlamp goes out (upstream draws the lamp only while the plate is on).
/// </summary>
public static void BreakPiece( GameObject panzer, int piece )
{
if ( !panzer.IsValid() ) return;
var r = panzer.Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
if ( r.IsValid() ) r.SetBodyGroup( PieceGroup( piece ), 1 );
var look = panzer.Components.Get<PanzerLook>( FindMode.EverythingInSelfAndDescendants );
if ( look.IsValid() && piece == 0 ) look.LampOn = false;
var at = panzer.WorldPosition + Vector3.Up * (piece == 0 ? 70f : 50f);
if ( r.IsValid() && r.TryGetBoneTransform( piece == 0 ? "j_faceplate" : "j_spinelower", out var t ) ) at = t.Position;
Sparks( at, 10 );
AvogadroFx.Flash( at, 0.8f );
}
/// <summary>His arm's weapon on THIS machine: 0 none (the claw is out), 1 the claw, 2 the taser (`NZNet.PanzerWeapon`).</summary>
public static void SetWeapon( GameObject panzer, int choice )
{
if ( !panzer.IsValid() ) return;
var r = panzer.Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
if ( r.IsValid() ) r.SetBodyGroup( "weapon", Math.Clamp( choice, 0, 2 ) );
}
/// <summary>The flamethrower's jet on or off, on THIS machine (`NZNet.PanzerFlame`).</summary>
public static void SetFlame( GameObject panzer, bool on )
{
if ( !panzer.IsValid() ) return;
var look = panzer.Components.Get<PanzerLook>( FindMode.EverythingInSelfAndDescendants );
if ( look.IsValid() ) look.Flaming = on;
}
/// <summary>Hot metal sparks off his armour, on THIS machine (upstream's `npcarmor_hit`).</summary>
public static void Sparks( Vector3 at, int count = 4 )
{
for ( int i = 0; i < count; i++ )
{
var dir = Vector3.Random.Normal;
AvogadroSpark.Between( at, at + dir * Game.Random.Float( 8f, 22f ), Game.Random.Float( 0.06f, 0.15f ), 0.1f, SparkColour );
}
}
/// <summary>THIS machine's player in the flame: the napalm zombie's fire on the screen (`NapalmZombie.RaiseHeat`).</summary>
public static void Scorch( Guid body )
{
var me = NZPlayer.Local;
if ( !me.IsValid() || me.GameObject.Id != body ) return;
NapalmZombie.RaiseHeat( 1f );
}
/// <summary>
/// The claw has THIS machine's player, and keeps it (2026-10-06, the user: *"the player must be unnable to resist being
/// pulled / the panzer does not let go once the player has been pulled to it, the player must free themselves"*, then *"you
/// have to use your knife 3-5 times while getting pulled to be free instead of shooting the arm"*): dragged to the Panzer
/// <paramref name="panzer"/> and held an arm's length in front of him (`PanzerGrab`, `PanzerBoss.HoldPoint`) until
/// <paramref name="knives"/> knife swings cut it (`PanzerGrab.OnKnife`), or the host lets go (`NZNet.PanzerClawRelease`) — a
/// down, his death. The owner alone moves its body (`BossPull`'s reason).
/// </summary>
public static void Hold( Guid body, Guid panzer, int knives = 4 )
{
var me = NZPlayer.Local;
if ( !me.IsValid() || me.GameObject.Id != body || me.IsDown ) return;
var g = me.Components.GetOrCreate<PanzerGrab>();
g.Begin( panzer, knives );
PowerupBannerState.Show( $"KNIFE THE CLAW! 0/{g.Needed}" );
}
/// <summary>Let THIS machine's player go (`NZNet.PanzerClawRelease`).</summary>
public static void Release( Guid body )
{
var me = NZPlayer.Local;
if ( !me.IsValid() || me.GameObject.Id != body ) return;
var g = me.Components.Get<PanzerGrab>( FindMode.EverythingInSelf );
if ( !g.IsValid() || g.Done ) return;
g.Destroy();
if ( !me.IsDown ) PowerupBannerState.Show( "FREE" );
}
}
/// <summary>
/// The claw's hold on this machine's own player (`PanzerFx.Hold`): no movement of your own (look, shoot and knife stay — the knife
/// is the way out: <see cref="Needed"/> swings, <see cref="OnKnife"/>), dragged along the floor to an arm's length in front of
/// him and kept there, following him as he moves and turns, until cut, released (`PanzerFx.Release`), or he or you go down.
/// Removes itself, and gives your legs back, when it ends. `NZPlayers` leaves the legs off while it exists.
/// </summary>
public sealed class PanzerGrab : Component
{
/// <summary>The Panzer holding you, by network id — every machine has him.</summary>
public Guid PanzerId { get; set; }
/// <summary>Knife swings that cut you free, rolled by the host (`PanzerBoss.KnifeMin`-`KnifeMax`).</summary>
public int Needed { get; private set; } = 4;
/// <summary>Over — cut, or ended by a down or his death — and destroying itself.</summary>
public bool Done => _done;
float _nextLift;
bool _tookInput, _done;
int _swings;
PanzerBoss _panzer;
/// <summary>A (new) catch: whose claw, and how many swings get you out.</summary>
public void Begin( Guid panzer, int needed )
{
PanzerId = panzer;
_panzer = null;
Needed = Math.Max( 1, needed );
_swings = 0;
}
/// <summary>
/// A knife swing of yours while it holds you (`Knife.Swing` — hit or miss, the user's *"use your knife"*): a clank and sparks
/// at the claw and the count on screen; the last one lets you go at once and tells the host (`NZNet.PanzerClawCut`), which
/// lets go for everyone — its release finds nothing left to undo here.
/// </summary>
public void OnKnife()
{
if ( _done ) return;
var me = Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( !me.IsValid() || me.IsDown ) return;
_swings++;
var claw = me.WorldPosition + Vector3.Up * 50f; // where the claw rides
NZSound.Play( "nz.panzer.armor.hit", claw );
PanzerFx.Sparks( claw, 3 );
if ( _swings < Needed )
{
PowerupBannerState.Show( $"KNIFE THE CLAW! {_swings}/{Needed}" );
return;
}
// ⚠️ OVER BEFORE THE HOST HEARS IT: a held host runs the cut — and the release it sends back — inside this call
_done = true;
PowerupBannerState.Show( "FREE" );
Log.Info( $"[nz-panzer] knifed the claw {_swings} times — free" );
Destroy();
NZNet.PanzerClawCut();
}
PanzerBoss Panzer()
{
if ( _panzer.IsValid() ) return _panzer;
var go = Scene.Directory.FindByGuid( PanzerId );
_panzer = go.IsValid() ? go.Components.Get<PanzerBoss>( FindMode.EverythingInSelfAndDescendants ) : null;
return _panzer;
}
// ⚠️ BOTH: whichever runs last before the physics step, the drag's velocity is in it
protected override void OnUpdate() => Drag();
protected override void OnFixedUpdate() => Drag();
protected override void OnDestroy()
{
if ( !_tookInput ) return;
var c = Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
var me = Components.Get<NZPlayer>( FindMode.EverythingInSelf );
if ( c.IsValid() && !(me.IsValid() && me.IsOutOfRound) ) c.UseInputControls = true;
}
void Drag()
{
if ( _done ) return;
var me = Components.Get<NZPlayer>( FindMode.EverythingInSelf );
var panzer = Panzer();
var ai = panzer.IsValid() ? panzer.Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors ) : null;
if ( !me.IsValid() || me.IsDown || !panzer.IsValid() || (ai.IsValid() && ai.State == ZombieState.Dead) )
{
_done = true;
Destroy();
return;
}
var c = me.Components.Get<PlayerController>( FindMode.EverythingInSelfAndDescendants );
var body = c.IsValid() ? c.Body : null;
if ( !body.IsValid() ) return;
// ⛔ NO RESISTING: no movement input while he has you — re-asserted every frame in case anything else hands it back
// (`NZPlayers`' twice-a-second poll leaves it off while this exists). Only the legs: the camera, the look and the gun stay
// yours.
if ( c.UseInputControls ) { c.UseInputControls = false; _tookInput = true; }
c.WishVelocity = Vector3.Zero;
// ⛔ TO AN ARM'S LENGTH IN FRONT OF HIM, AS HE STANDS NOW (`PanzerBoss.HoldPoint`, off his transform on this machine), so
// you go where he goes, and are held there once you arrive: fast while far, then a spring that settles you. Not ON his
// claw arm (the first try): that put you inside him.
var point = panzer.HoldPoint;
// ⛔ ALONG THE FLOOR, NEVER UP (2026-10-06 fix, the user: *"pulls the player upwards instead of towards the panzer"*): it
// lifted the body by `Placeable.GroundBreak` — 12 units — EVERY FRAME, which is ~1000 u/s straight up at any frame rate.
// Now the drag is flat, toward the claw's point (upstream measures from the chest, `GetPos() + 50 up`), and the body is
// lifted off the floor only when it is standing on it, at most ten times a second (`BossPull`'s rhythm) — enough that a
// grounded controller does not cancel the drag; gravity brings it straight back down.
var to = (point - (me.WorldPosition + Vector3.Up * 50f)).WithZ( 0f );
var d = to.Length;
// off the floor only while far (the drag), never once held (no bobbing in his grip)
if ( d > 60f && c.IsOnGround && Time.Now >= _nextLift )
{
_nextLift = Time.Now + 0.1f;
me.WorldPosition += Vector3.Up * Placeable.GroundBreak;
}
var speed = d > 90f ? 750f : MathF.Min( 750f, d * 8f );
var flat = d > 1f ? to / d * speed : Vector3.Zero;
body.Velocity = flat.WithZ( MathF.Min( body.Velocity.z, 0f ) );
ShoveGuard.Of( me )?.NoteLaunch();
}
}
/// <summary>
/// The Panzer's look on EVERY machine: his arm's weapon (claw or taser, read off his network id so every machine agrees with
/// no message), his headlamp (upstream's crossed light sprites on `tag_headlamp_fx`, while the faceplate is on), the
/// boosters' flames as he drops in (upstream's `bo3_panzer_engine`, frames 1-27 of his arrival), and the flamethrower's jet.
/// </summary>
public sealed class PanzerLook : Component
{
/// <summary>The headlamp: on until the faceplate comes off.</summary>
public bool LampOn { get; set; } = true;
/// <summary>The flamethrower: on while the host says so (`NZNet.PanzerFlame`).</summary>
public bool Flaming { get; set; }
/// <summary>Claw (true) or taser — the same answer on every machine, from the id.</summary>
public bool HasClaw => (GameObject.Id.GetHashCode() & 1) == 0;
/// <summary>`tag_flamethrower_fx`: an offset in `j_elbow_ri`'s frame, from the model's QC.</summary>
static Vector3 NozzleOffset => new( -34f, -0.3f, -8.4f );
SkinnedModelRenderer _body;
ZombieAI _ai;
PointLight _lamp, _fireLight;
GameObject _lampGo, _fireGo;
float _born, _nextPuff;
readonly List<GameObject> _boosters = new();
bool _boostersOut;
protected override void OnStart()
{
_body = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
_born = Time.Now;
PanzerFx.SetWeapon( GameObject, HasClaw ? 1 : 2 );
_lampGo = new GameObject { Parent = GameObject, Name = "nz_panzer_lamp" };
_lampGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
_lamp = _lampGo.Components.Create<PointLight>();
_lamp.LightColor = new Color( 1f, 1f, 200f / 255f ) * 2.5f;
_lamp.Radius = 280f;
_lamp.Shadows = false;
_fireGo = new GameObject { Parent = GameObject, Name = "nz_panzer_fire" };
_fireGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
_fireLight = _fireGo.Components.Create<PointLight>();
_fireLight.LightColor = Color.Black;
_fireLight.Radius = 300f;
_fireLight.Shadows = false;
// the boosters' flames for his drop: two of the burning zombie's flames, one on each booster
foreach ( var bone in new[] { "tag_booster_le_fx", "tag_booster_ri_fx" } )
{
GameObject f = null;
try { f = GameObject.Clone( PanzerFx.FlamePrefab, new Transform( WorldPosition ) ); }
catch ( Exception ) { }
if ( !f.IsValid() ) continue;
f.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
f.NetworkMode = NetworkMode.Never;
f.SetParent( GameObject );
f.WorldScale = 0.9f;
_boosters.Add( f );
}
}
protected override void OnUpdate()
{
if ( !_body.IsValid() ) return;
var now = Time.Now;
var dead = _ai.IsValid() && _ai.State == ZombieState.Dead;
// ── the boosters, for the drop ──
if ( !_boostersOut )
{
var i = 0;
foreach ( var bone in new[] { "tag_booster_le_fx", "tag_booster_ri_fx" } )
{
if ( i >= _boosters.Count ) break;
var f = _boosters[i++];
if ( f.IsValid() && _body.TryGetBoneTransform( bone, out var t ) ) f.WorldPosition = t.Position;
}
if ( now >= _born + 27f / 30f )
{
_boostersOut = true;
foreach ( var f in _boosters ) if ( f.IsValid() ) f.Destroy();
_boosters.Clear();
}
}
// ── the headlamp ──
if ( _lamp.IsValid() )
{
if ( LampOn && !dead && _body.TryGetBoneTransform( "tag_headlamp_fx", out var h ) )
{
_lampGo.WorldPosition = h.Position;
_lamp.LightColor = new Color( 1f, 1f, 200f / 255f ) * 2.5f;
}
else _lamp.LightColor = Color.Black;
}
// ── the flamethrower's jet ──
if ( !Flaming || dead )
{
if ( _fireLight.IsValid() ) _fireLight.LightColor = Color.Black;
return;
}
var nozzle = WorldPosition + Vector3.Up * 50f;
if ( _body.TryGetBoneTransform( "j_elbow_ri", out var elbow ) ) nozzle = elbow.PointToWorld( NozzleOffset );
var facing = _ai.IsValid() ? (WorldRotation * _ai.ModelTurn.Inverse).Forward.WithZ( 0 ).Normal : WorldRotation.Forward;
var dir = FlameDir( GameObject, nozzle, facing, PanzerBoss.FlameReach * MathF.Max( 0.1f, WorldScale.x ) );
if ( _fireLight.IsValid() )
{
_fireGo.WorldPosition = nozzle + dir * 120f;
_fireLight.LightColor = PanzerFx.FireColour * (2.4f + 0.6f * MathF.Sin( now * 31f ));
}
if ( now < _nextPuff ) return;
_nextPuff = now + 0.05f;
PanzerFlamePuff.Throw( nozzle, dir );
}
/// <summary>
/// Which way the jet goes from <paramref name="nozzle"/>: at the nearest standing player in front of him (within 50° of his
/// facing and the jet's reach), at their chest; else at the point 200 ahead of his middle. ⛔ 2026-10-06 FIX (the user: *"the
/// flamethrower is always being shot to my left"*): the nozzle is on his right forearm, ~55 units to his side, and the jet
/// ran parallel to his facing from there — past your left shoulder every time. Every machine works it out from what every
/// machine knows (where the players are), and the host's damage box uses the same answer (`PanzerBoss.Burn`).
/// </summary>
public static Vector3 FlameDir( GameObject panzer, Vector3 nozzle, Vector3 facing, float reach )
{
var center = panzer.WorldPosition;
Vector3? best = null;
var bestD = float.MaxValue;
foreach ( var p in panzer.Scene.GetAllComponents<NZPlayer>() )
{
if ( !p.IsValid() || p.IsDown || p.DownedNet ) continue;
var to = (p.WorldPosition - center).WithZ( 0f );
var d = to.Length;
if ( d < 1f || d > reach * 1.3f || d >= bestD ) continue;
if ( Vector3.GetAngle( facing, to / d ) > 50f ) continue;
bestD = d;
best = p.WorldPosition + Vector3.Up * 45f;
}
var aim = best ?? center + facing * 200f + Vector3.Up * 45f;
var dir = aim - nozzle;
return dir.Length > 1f ? dir.Normal : facing;
}
protected override void OnDestroy()
{
foreach ( var f in _boosters ) if ( f.IsValid() ) f.Destroy();
if ( _lampGo.IsValid() ) _lampGo.Destroy();
if ( _fireGo.IsValid() ) _fireGo.Destroy();
}
}
/// <summary>One puff of the jet: a burning zombie's flame thrown forward, growing, gone in under half a second.</summary>
public sealed class PanzerFlamePuff : Component
{
Vector3 _vel;
float _born;
const float Life = 0.45f;
public static void Throw( Vector3 from, Vector3 dir )
{
GameObject f = null;
try { f = GameObject.Clone( PanzerFx.FlamePrefab, new Transform( from ) ); }
catch ( Exception ) { return; }
if ( !f.IsValid() ) return;
f.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
f.NetworkMode = NetworkMode.Never;
f.WorldScale = 0.35f;
var p = f.Components.Create<PanzerFlamePuff>();
var spread = Vector3.Random * 0.08f;
p._vel = (dir + spread).Normal * 700f;
p._born = Time.Now;
}
protected override void OnUpdate()
{
var t = (Time.Now - _born) / Life;
if ( t >= 1f ) { GameObject.Destroy(); return; }
WorldPosition += _vel * Time.Delta;
WorldScale = 0.35f + 1.35f * t;
}
}