Zombies/PanzerFx.cs

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.

File AccessNetworking
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;
	}
}