Zombies/ManglerBoss.cs

Boss component for the Mangler enemy and its projectile. ManglerBoss implements AI, armour pieces, cannon shooting, animations, sounds, death blast, radio and console commands; ManglerShot is a network-spawned homing projectile that draws a pulsing ring, handles homing, collision and explosion effects.

NetworkingFile Access
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

namespace NZombies;

/// <summary>
/// The Mangler — BO6's arm-cannon brute in the HVT colourway, ported 2026-10-07 from GMod nZombies' `nz_zombie_boss_raz_t10`
/// (GhostlyMoo's port; his clips are the Cold War Mangler's), the third batch. Spec: `Sbox nzombies/Docs/boss_specs/mangler.md`.
///
///   • HE WALKS (63.8 u/s), talking (thirty lines, the variant's voice), the radio on his back crackling to whoever is near.
///   • THE ARM CANNON, every <see cref="ShotGapMin"/>-<see cref="ShotGapMax"/> s counted from the wind-up (the first 3 s after he
///     arrives), at a target in sight, in front of him, <see cref="ShotMin"/>-<see cref="ShotMax"/> away: he plants and turns with
///     you (`attack_shoot_01`/`_02`), the cannon glows gold from `raz_charge`, and at `raz_shoot` one shot leaves at whoever is
///     nearest — a pulsing yellow ring at <see cref="ShotSpeed"/> u/s for <see cref="ShotLife"/> s, homing gently and leading you by
///     half a second (<see cref="ManglerShot"/>): x<see cref="ShotWeight"/> of his swing at its middle (⚠️ x1.5 upstream's 100
///     against 95, below), nothing at <see cref="ShotRadius"/>, everyone within <see cref="ShotReach"/> dazed for
///     <see cref="Daze"/> s (⚠️ x1.5 upstream's) and thrown back. It flies through zombies.
///   • SHOOT THE CANNON ARM WHILE HE AIMS IT (the whole clip, the tail after the shot too) and the shot is spoiled: it backfires
///     (`shoot_01_pain`) and 1.77 s in he ENRAGES — the sprint (192.8 u/s) until his next shot calms him. Those hits drain the
///     arm's own pool (<see cref="CannonShare"/> of his health); the first such hit after it is gone tears the cannon off
///     (`shoot_01_pain_gib`, the `arm` bodygroup at 2.77 s): no more shots, the sprint for good, a stump that bleeds.
///   • ARMOUR, judged by where a hit lands against the live bones (upstream's `PostTookDamage`, not hit groups), in this order:
///
/// | where | while it holds | once it is off |
/// |---|---|---|
/// | **helmet**: within <see cref="HeadReach"/> of `j_head`, its pool <see cref="HelmetShare"/> of his max | the helmet takes the hit raw; he takes x<see cref="ArmourScale"/> | x1 |
/// | **chest plate**: within <see cref="ChestReach"/> of `j_spine4`, <see cref="ChestShare"/> | the same | x1 |
/// | **cannon arm**: within <see cref="ArmReach"/> of `j_elbow_ri` | x<see cref="ArmScale"/> always — and the stagger above while he aims | |
/// | anywhere else | x<see cref="BodyScale"/> | |
///
///     A piece whose pool is gone comes off on the NEXT hit there (upstream's), which lands whole, pays its shooter
///     <see cref="PiecePoints"/>, and staggers him (`pain_facemask` / `pain_chest_armor`) unless he is busy.
///   • HIS SICKLE: the variant's swings (95 against a walker's 50), the chop and the uppercut x<see cref="HeavyScale"/> (125).
///   • HE DIES IN A BLAST (upstream's `Explode(25)`, at once): x<see cref="BlastWeight"/> of his swing at his middle, nothing at
///     <see cref="BlastRadius"/>, not through walls. ⚠️ Upstream credited it to his own target, who took damage "from himself"; his.
///   • WITH NO TARGET he checks the cannon now and then (`idle_twitch_check`, every 8-15 s).
///
/// ⚠️ DEVIATIONS, EACH ON PURPOSE:
///   • THE SHOT SINCE THE USER'S REVIEW (2026-10-07): *"for mangler, there should be a pulsing yellow ring shot from the cannon so
///     the player can follow it, it should deal more damage and stun for a bit longer"*. It was a light and hair-thin amber sparks;
///     it is a yellow ring round its path, swelling and brightening with each of its pulses (4 a second, its sound's), round a
///     glowing head with a short trail (`BossShotLook`, <see cref="ShotRing"/>). It hits x1.5 upstream's (<see cref="ShotWeight"/>
///     1.575, was 1.05) and dazes x1.5 as long (<see cref="Daze"/> 1.125 s, was upstream's 0.75); the throw is as it was.
///     ⛔ THE CAP STILL HOLDS: a hit at its middle is 100 from round 7 on, a hit on you (about half its middle) from round 16.
///   • THE THROW: upstream ADDED (player − shot) x10 + (0,35,0) to the velocity — more the further out, its lift on the world's Y
///     axis. Here a fixed throw out and up off the blast (<see cref="KnockPush"/>, `NZNet.AvogadroShock`, whose blue ring stands in
///     for the cyan flash in upstream's `cw_mangler_blast`).
///   • THE SHOT LEAVES THE MUZZLE AT WHERE IT HOMES; upstream's first heading was feet to feet from his origin (on flat ground the same).
///   • ONE HIT A SWING (`ZombieAI`): the three double swings land once where upstream's land twice.
///   • NOT HERE: the game-over taunt (`com_summon` is not on the model), the 1-in-10,000 Gigan, upstream's 1 s of spawn protection
///     (as Brenner and Zaballa), his indifference to lures (`ZombieAI` targets for every boss alike), the blast's 25 on other zombies.
///   • HL2's metal and flesh impacts are in no archive: `nz.panzer.armor.hit` (NMRiH's copies) and `nz.margwa.weakspot` stand in.
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the pieces are `NZNet.BossPart`, the shot a network-spawned <see cref="ManglerShot"/>, the
/// bursts `NZNet.BossBurstFx`, the throw `NZNet.AvogadroShock`; the cannon's glow and the stump's blood each machine reads off the
/// clip it plays (relayed) and the arm's bodygroup — no message of their own.
/// ⛔ NO HIT OF HIS DOWNS ANYONE FROM FULL HEALTH (`BossBase.MaxHit`), Oberon's rule — the shot and the blast included.
/// </summary>
public sealed class ManglerBoss : BossBase
{
	protected override string Tag => "[nz-mangler]";
	protected override float VoiceHeight => 70f;

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

	static float? _shotMin, _shotMax, _gapMin, _gapMax, _shotWeight, _shotSpeed, _shotLife, _shotTurn, _shotRadius, _shotReach,
		_daze, _push;
	/// <summary>He fires at a target in sight and in front of him, more than 150 and at most 750 away (upstream's `TargetInRange`).</summary>
	public static float ShotMin { get => _shotMin ?? 150f; set => _shotMin = value; }
	public static float ShotMax { get => _shotMax ?? 750f; set => _shotMax = value; }
	/// <summary>Seconds from one wind-up to the next shot: 3-9 (upstream's `math.random(3,9)`).</summary>
	public static float ShotGapMin { get => _gapMin ?? 3f; set => _gapMin = value; }
	public static float ShotGapMax { get => _gapMax ?? 9f; set => _gapMax = value; }
	/// <summary>The shot at its middle against his swing: 1.575 — ⚠️ x1.5 upstream's 100 against 95 (1.05), the user's review
	/// (2026-10-07): *"it should deal more damage"*. ⛔ Never more than `BossBase.MaxHit` a hit (`ManglerShot.Explode`).</summary>
	public static float ShotWeight { get => _shotWeight ?? 1.575f; set => _shotWeight = value; }
	/// <summary>575 u/s for 1.75 s (about 1,000 u), upstream's.</summary>
	public static float ShotSpeed { get => _shotSpeed ?? 575f; set => _shotSpeed = value; }
	public static float ShotLife { get => _shotLife ?? 1.75f; set => _shotLife = value; }
	/// <summary>How hard it homes, per second: 1.69 — upstream's `LerpAngle(0.025)` a tick, at 66 ticks.</summary>
	public static float ShotTurn { get => _shotTurn ?? 1.69f; set => _shotTurn = value; }
	/// <summary>Its hurt falls to nothing at 50 from its middle; its daze and throw reach 75 — upstream's two numbers.</summary>
	public static float ShotRadius { get => _shotRadius ?? 50f; set => _shotRadius = value; }
	public static float ShotReach { get => _shotReach ?? 75f; set => _shotReach = value; }
	/// <summary>The shot's daze (`SonicDaze`: x0.35 speed and a blurred screen, held half of it, easing off over the rest), on
	/// everyone within <see cref="ShotReach"/> of where it went off: 1.125 s — ⚠️ x1.5 upstream's `NZSonicBlind(0.75)`, the user's
	/// review (2026-10-07): *"stun for a bit longer"*.</summary>
	public static float Daze { get => _daze ?? 1.125f; set => _daze = value; }
	/// <summary>The throw off the shot's blast, out and up (`NZNet.AvogadroShock`): 140; 0, none.</summary>
	public static float KnockPush { get => _push ?? 140f; set => _push = value; }

	static float? _shotRing, _shotHead, _shotTrail, _shotGlow;
	/// <summary>
	/// The shot's look (2026-10-07, the user: *"there should be a pulsing yellow ring shot from the cannon so the player can follow
	/// it"*; `BossShotLook`): the ring round its path, 16 out from its middle (0, none) — it swells to x1.3 and shrinks to x0.7
	/// with each pulse, <see cref="ShotPulseEvery"/> apart, the pulse its sound keeps; the glowing head, 20 wide; its trail, 7 wide
	/// at the head; how far past white they burn, x3.5. ⚠️ EACH MACHINE READS ITS OWN when a shot starts: a console change
	/// retunes what THAT machine draws.
	/// </summary>
	public static float ShotRing { get => _shotRing ?? 16f; set => _shotRing = value; }
	public static float ShotHead { get => _shotHead ?? 20f; set => _shotHead = value; }
	public static float ShotTrail { get => _shotTrail ?? 7f; set => _shotTrail = value; }
	public static float ShotGlow { get => _shotGlow ?? 3.5f; set => _shotGlow = value; }

	static float? _helmetShare, _chestShare, _cannonShare, _headReach, _chestReach, _armReach, _armourScale, _armScale, _bodyScale;
	static int? _points;
	/// <summary>The helmet's and the chest plate's own pools, a share of his max: 0.5 each; the cannon arm's 0.015 — upstream's.</summary>
	public static float HelmetShare { get => _helmetShare ?? 0.5f; set => _helmetShare = value; }
	public static float ChestShare { get => _chestShare ?? 0.5f; set => _chestShare = value; }
	public static float CannonShare { get => _cannonShare ?? 0.015f; set => _cannonShare = value; }
	/// <summary>How near a hit must land, at size 1: 13 to `j_head` or `j_spine4`, 16 to `j_elbow_ri` — upstream's.</summary>
	public static float HeadReach { get => _headReach ?? 13f; set => _headReach = value; }
	public static float ChestReach { get => _chestReach ?? 13f; set => _chestReach = value; }
	public static float ArmReach { get => _armReach ?? 16f; set => _armReach = value; }
	/// <summary>What he takes from a hit on armour that holds: x0.015; on the cannon arm x0.25; anywhere else x0.15 — upstream's.</summary>
	public static float ArmourScale { get => _armourScale ?? 0.015f; set => _armourScale = value; }
	public static float ArmScale { get => _armScale ?? 0.25f; set => _armScale = value; }
	public static float BodyScale { get => _bodyScale ?? 0.15f; set => _bodyScale = value; }
	/// <summary>Points for knocking a piece off: 90, upstream's.</summary>
	public static int PiecePoints { get => _points ?? 90; set => _points = value; }

	static float? _heavy, _blastRadius, _blastWeight;
	/// <summary>The chop and the uppercut (`melee_heavy`) against his other swings: 1.316 — upstream's 125 against 95.</summary>
	public static float HeavyScale { get => _heavy ?? 1.316f; set => _heavy = value; }
	/// <summary>His death blast: 200 wide, x0.263 of his swing at the middle — upstream's `Explode(25)` against 95.</summary>
	public static float BlastRadius { get => _blastRadius ?? 200f; set => _blastRadius = value; }
	public static float BlastWeight { get => _blastWeight ?? 0.263f; set => _blastWeight = value; }

	// his clips (30 fps) and their events — ⛔ `const`, never `static readonly` (INSTRUCTIONS.md §1)
	const string ClipShoot1 = "nz_base_zmb_raz_attack_shoot_01", ClipShoot2 = "nz_base_zmb_raz_attack_shoot_02",
		ClipPain = "nz_base_zmb_raz_attack_shoot_01_pain", ClipGib = "nz_base_zmb_raz_attack_shoot_01_pain_gib",
		ClipFacemask = "nz_base_zmb_raz_pain_facemask", ClipChestPain = "nz_base_zmb_raz_pain_chest_armor",
		ClipInspect = "nz_base_zmb_raz_idle_twitch_check", ClipMantle = "nz_base_zmb_raz_mantle_over_48",
		ClipChop = "nz_base_zmb_raz_attack_sickle_swing_down", ClipUppercut = "nz_base_zmb_raz_attack_sickle_swing_uppercut";
	const float Shoot1Len = 81f / 30f, Shoot1Charge = 13f / 30f, Shoot1Fire = 52f / 30f,
		Shoot2Len = 91f / 30f, Shoot2Charge = 19f / 30f, Shoot2Fire = 61f / 30f,
		PainLen = 119f / 30f, PainEnrage = 53f / 30f, BackfireAt = 1f / 30f,
		GibLen = 176f / 30f, GibAt = 83f / 30f, GibEnrage = 128f / 30f,
		FacemaskLen = 56f / 30f, ChestPainLen = 50f / 30f, InspectLen = 145f / 30f, InspectClick = 58f / 30f;

	/// <summary>His clips' own speeds — `mangler.zvar`'s tiers are picked by them.</summary>
	const float WalkSpeed = 63.8f, SprintSpeed = 192.8f;

	/// <summary>How often the host weighs a shot or a cannon check (upstream's `AI()` runs once a behaviour loop, 0.1-1 s).</summary>
	const float ThinkEvery = 0.25f;

	/// <summary>The cannon check with no target, every 8-15 s; the stump's blood, a 30% chance every half second (upstream's 3 in
	/// 10 an `AI()` pass); the radio, heard within 1,200 (upstream's level 55).</summary>
	const float InspectGapMin = 8f, InspectGapMax = 15f, BloodEvery = 0.5f, BloodChance = 0.3f, RadioEarshot = 1200f;

	/// <summary>The shot: its lead on its victim (half a second), where on them it aims (their eyes less 7), how near it must pass to
	/// touch them, and its pulse — upstream's.</summary>
	public const float ShotLead = 0.5f, ShotAimHeight = 57f, ShotTouch = 20f, ShotPulseEvery = 0.25f;

	/// <summary>The shot's look (2026-10-07): its trail keeps a fifth of a second of its path (about 115 u), and its ring's line
	/// is 3 wide (`BossShotLook`'s 2.5 thickened, to hold at 750).</summary>
	public const float ShotTrailSeconds = 0.2f, ShotRingWidth = 3f;

	/// <summary>`weapon_fx_tag` in `j_weapon_spin`'s frame (the muzzle), `rarm_fx_tag` in `j_shoulder_ri`'s (where the cannon was),
	/// `eyes` in `j_head`'s — from the QC.</summary>
	static Vector3 MuzzleOffset => new( 8f, 0f, 0f );
	static Vector3 StumpOffset => new( -5f, 0f, -1f );
	static Vector3 EyesOffset => new( 3.5f, 3.4f, 0f );

	public static Color Amber => new( 1f, 0.58f, 0.16f );
	/// <summary>The shot's own yellow (2026-10-07, the user's *"pulsing yellow ring"*) — its blast stays <see cref="Amber"/>.</summary>
	public static Color ShotYellow => new( 1f, 0.85f, 0.15f );
	static Color Gold => new( 1f, 0.82f, 0.31f );
	static Color PortalRed => new( 1f, 0.16f, 0.17f );
	static Color Violet => new( 0.77f, 0.27f, 1f );
	static Color Flare => new( 1f, 0.78f, 0.5f );
	static Color Blood => new( 0.42f, 0.02f, 0.02f );
	static Color EyeColour => new( 1f, 0.5f, 0.12f );

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

	bool _armed, _helmet = true, _chest = true, _cannon = true, _shooting, _enraged;
	float _helmetHp, _chestHp, _cannonHp, _nextShot, _nextThink, _nextInspect, _nextClank;

	// each machine's own
	BossGlow _charge;
	SoundHandle _radio;
	bool _radioOn, _gibbed;
	float _nextRadio, _nextGush;

	enum Beat { Charge, Fire, ShotEnd, Backfire, Gib, Enrage, Click }

	public bool Enraged => _enraged;
	public bool HasCannon => _cannon;

	protected override void BossStart()
	{
		// ⚠️ EVERY MACHINE: the cannon's charge (`hcea_hunter_ab_charge` on `weapon_fx_tag`, gold arcs and a flare), lit only from
		// `raz_charge` to `raz_shoot` (`EveryFrame`) …
		_charge = Components.Create<BossGlow>();
		_charge.Bone = "j_weapon_spin";
		_charge.Offset = MuzzleOffset;
		_charge.Colour = Gold;
		_charge.Radius = 140f;
		_charge.Brightness = 2.6f;
		_charge.SparkEvery = 0.04f;
		_charge.SparkReach = 16f;
		_charge.SparkColour = new Color( 1f, 0.9f, 0.55f );
		_charge.Lit = false;

		// … and his eyes (the base's eye glow, which upstream's client draws whatever his server's flags say)
		var eyes = Components.Create<BossGlow>();
		eyes.Bone = "j_head";
		eyes.Offset = EyesOffset;
		eyes.Colour = EyeColour;
		eyes.Radius = 50f;
		eyes.Brightness = 1.8f;

		_nextRadio = Time.Now + 5f;
	}

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

		// ⛔ NONE OF THE WALKER'S CLIMB CLIPS ON HIS MODEL: his own mantle (upstream's `mantle_over_36` never came across; the 48 did)
		Ai.ClimbClipOverride = ClipMantle;

		_nextShot = Time.Now + 3f;
		_nextInspect = Time.Now + 5f;
		Arm();

		// his arrival: the boss round's vox to everyone (upstream's `BroadcastLua`), the red portal (`zmb_trickster_portal`; its
		// sound is the variant's SpawnSound) and `raz_portal_spawn` at frame 0 of his entrance
		NZSound.PlayShared( "nz.mangler.intro" );
		NZSound.PlayShared( "nz.mangler.appear", Voice );
		BossFx.BurstShared( WorldPosition + Vector3.Up * 4f, 110f * Size, PortalRed, 16 );

		Say( $"spawned — {Hp?.Max ?? 0f:0} hp (x{BodyScale:0.##} body, x{ArmourScale:0.###} on armour) · cannon {ShotMin:0}-{ShotMax:0}u"
			+ $" every {ShotGapMin:0}-{ShotGapMax:0}s · hitboxes {Ai.HitboxCount}" );
	}

	protected override void EveryFrame()
	{
		var clip = Body.IsValid() ? Body.Sequence.Name : null;
		var t = Body.IsValid() ? Body.Sequence.Time : 0f;

		// the cannon's charge: from `raz_charge` to `raz_shoot` of the shot this machine plays (the clip is relayed)
		if ( _charge.IsValid() )
			_charge.Lit = (clip == ClipShoot1 && t >= Shoot1Charge && t < Shoot1Fire)
				|| (clip == ClipShoot2 && t >= Shoot2Charge && t < Shoot2Fire);

		// `raz_mangler_gib`: the cannon torn away in a spray (`ins_blood_dismember_limb`), once
		if ( !_gibbed && clip == ClipGib && t >= GibAt )
		{
			_gibbed = true;
			BossFx.Sparks( Stump, Blood, 26, 36f * Size );
		}

		// and the stump bleeding after it, now and then — off the arm's bodygroup, which every machine has (`NZNet.BossPart`)
		if ( Time.Now >= _nextGush )
		{
			_nextGush = Time.Now + BloodEvery;
			if ( Body.IsValid() && Body.GetBodyGroup( "arm" ) == 1 && Game.Random.Float( 0f, 1f ) < BloodChance )
			{
				var at = Stump;
				BossFx.Sparks( at, Blood, 8, 20f * Size );
				NZSound.Play( "nz.margwa.weakspot", at );
			}
		}

		TickRadio();
	}

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

		// ⚠️ ONE HIT A SWING (`ZombieAI`), SIZED AT THAT MOMENT: the chop and the uppercut (`melee_heavy`) hit harder
		Ai.SwingScale = Ai.State == ZombieState.Attacking && IsHeavy( Ai.CurrentClip ) ? HeavyScale : 1f;

		if ( _shooting )
		{
			// something else took him out of the shot (a freeze, a knockdown): it is over, and its beats with it
			if ( Ai.State != ZombieState.Special ) { _shooting = false; ClearBeats(); return; }

			// ⚠️ HE TURNS WITH YOU THE WHOLE SHOT, at no turn rate (upstream's `FaceEnemy` every tick)
			var target = Ai.Target;
			if ( target.IsValid() ) Face( target.WorldPosition );
			return;
		}

		if ( Time.Now < _nextThink ) return;
		_nextThink = Time.Now + ThinkEvery;
		TickInspect();
		TickCannon();
	}

	protected override void Died( bool host )
	{
		if ( _radio.IsValid() ) _radio.Stop();
		_radioOn = false;
		if ( _charge.IsValid() ) _charge.Lit = false;
		if ( !host ) return;

		_shooting = false;
		Ai.SwingScale = 1f;
		DeathBlast();
		Say( "killed" );
	}

	protected override void OnDestroy()
	{
		if ( _radio.IsValid() ) _radio.Stop();
	}

	protected override void OnBeat( int beat )
	{
		switch ( (Beat)beat )
		{
			case Beat.Charge:
				// `raz_charge`: the whine, both layers (the glow is each machine's own, off the clip)
				NZSound.PlayShared( "nz.mangler.charge", Muzzle );
				NZSound.PlayShared( "nz.mangler.chargeswt", Muzzle );
				break;

			case Beat.Fire:
				if ( _shooting && _cannon && Ai.State == ZombieState.Special ) Fire();
				break;

			case Beat.ShotEnd:
				_shooting = false;
				break;

			case Beat.Backfire:
			{
				// `raz_mangler_explode`: the spoiled shot goes off in the cannon (`bo3_explosion_micro` on the muzzle)
				var muzzle = Muzzle;
				NZSound.PlayShared( "nz.mangler.pain", Voice );
				NZSound.PlayShared( "nz.mangler.interrupt", muzzle );
				NZSound.PlayShared( "nz.mangler.backfire", muzzle );
				BossFx.BurstShared( muzzle, 60f * Size, Flare, 14 );
				NZNet.ShakeAt( muzzle, 0.3f, 500f );
				break;
			}

			case Beat.Gib:
				// `raz_mangler_gib`: the cannon off on every machine (its spray is each machine's own, off the clip)
				SetPart( "arm", 1 );
				NZSound.PlayShared( "nz.mangler.pain", Voice );
				NZSound.PlayShared( "nz.mangler.whoosh", Stump );
				NZSound.PlayShared( "nz.margwa.weakspot", Stump );
				NZSound.PlayShared( "nz.mangler.gib", Stump );
				break;

			case Beat.Enrage:
				// `raz_enrage`: the roar and the sprint, until his next shot (for good, with no cannon left)
				_enraged = true;
				NZSound.PlayShared( "nz.mangler.rage", Voice );
				SetSpeed( SprintSpeed );
				Say( $"ENRAGED — sprinting at {Ai.SpeedOverride:0} u/s{(_cannon ? " until his next shot" : " for good")}" );
				break;

			case Beat.Click:
				// `raz_idle_click`: `doom_mancu_blast`, a quick flash at the muzzle
				BossFx.BurstShared( Muzzle, 24f * Size, Amber, 6 );
				break;
		}
	}

	// ══ the arm cannon ═══════════════════════════════════════════════════════

	/// <summary>Upstream's `AI()` test: cooled down, cannon on, on the ground, the target in sight and in front, 150-750 away. THE HOST.</summary>
	void TickCannon()
	{
		if ( !_cannon || Time.Now < _nextShot || Ai.State != ZombieState.Chasing || Ai.OnLink ) return;
		var p = TargetPlayer;
		if ( !IsUp( p ) ) return;

		var d = WorldPosition.Distance( p.WorldPosition );
		if ( d <= ShotMin * Size || d > ShotMax * Size ) return;
		if ( !FacingWithin( p.WorldPosition, 90f ) || !CanSee( p.GameObject ) ) return;
		StartShot( p );
	}

	/// <summary>The wind-up: one of his two shooting clips, standing, turning with you; the gap to the next counts from now. THE HOST.</summary>
	bool StartShot( NZPlayer p )
	{
		var second = Game.Random.Int( 0, 1 ) == 1;
		var len = second ? Shoot2Len : Shoot1Len;
		Face( p.WorldPosition );
		if ( !PlayShared( second ? ClipShoot2 : ClipShoot1, len ) ) return false;

		_shooting = true;
		_nextShot = Time.Now + Game.Random.Float( ShotGapMin, MathF.Max( ShotGapMin, ShotGapMax ) );
		NZSound.PlayShared( "nz.mangler.prefire", Voice );
		Queue( second ? Shoot2Charge : Shoot1Charge, (int)Beat.Charge );
		Queue( second ? Shoot2Fire : Shoot1Fire, (int)Beat.Fire );
		Queue( len, (int)Beat.ShotEnd );
		return true;
	}

	/// <summary>`raz_shoot`: the shot leaves the muzzle at whoever is nearest now (upstream's `Retarget`), and he calms to a walk. THE HOST.</summary>
	void Fire()
	{
		var from = Muzzle;
		NZSound.PlayShared( "nz.mangler.fire", from );
		BossFx.BurstShared( from, 40f * Size, Gold, 16 );

		var p = NearestPlayer();
		if ( !IsUp( p ) ) p = TargetPlayer;
		if ( IsUp( p ) )
		{
			var aim = p.WorldPosition + Vector3.Up * ShotAimHeight;
			ManglerShot.Launch( Scene, from, aim - from, p.GameObject, GameObject, SwingDamage( ShotWeight ) );
		}

		// upstream calms him on every shot: `Enraged = false`, back to the walk
		_enraged = false;
		SetSpeed( WalkSpeed );
	}

	/// <summary>
	/// A hit on the cannon arm while he aims it: the shot is spoiled. The arm's pool takes the hit while it lasts — the stagger
	/// (`shoot_01_pain`); the first such hit after it is gone tears the cannon off (`shoot_01_pain_gib`). THE HOST.
	/// </summary>
	void HitCannon( float amount )
	{
		_shooting = false;
		ClearBeats();

		var gib = _cannonHp <= 0f;
		if ( gib ) _cannon = false;
		else _cannonHp -= MathF.Max( 0f, amount );

		Stagger( gib );
		Say( gib ? "CANNON TORN OFF" : $"shot spoiled — the cannon arm holds {MathF.Max( 0f, _cannonHp ):0} more" );
	}

	/// <summary>The backfire (`shoot_01_pain`) or the cannon torn away (`shoot_01_pain_gib`), each ending in the enrage. THE HOST.</summary>
	void Stagger( bool gib )
	{
		var clip = gib ? ClipGib : ClipPain;
		var len = gib ? GibLen : PainLen;

		// mid-shot he is in its special already: the clip swapped under it and the hold re-armed; else a special of its own
		if ( Ai.State == ZombieState.Special )
		{
			SwapClip( clip );
			Ai.HoldSpecialFor( len );
		}
		else PlayShared( clip, len );

		Queue( BackfireAt, (int)Beat.Backfire );
		if ( gib ) Queue( GibAt, (int)Beat.Gib );
		Queue( gib ? GibEnrage : PainEnrage, (int)Beat.Enrage );
	}

	/// <summary>With no target, now and then, he checks his cannon (`idle_twitch_check`, its click a flash at the muzzle). THE HOST.</summary>
	void TickInspect()
	{
		if ( !_cannon || Time.Now < _nextInspect || Ai.Target.IsValid() ) return;
		if ( Ai.State is not (ZombieState.Idle or ZombieState.Chasing) ) return;
		Inspect();
	}

	void Inspect()
	{
		_nextInspect = Time.Now + Game.Random.Float( InspectGapMin, InspectGapMax );
		if ( !PlayShared( ClipInspect, InspectLen ) ) return;
		NZSound.PlayShared( "nz.mangler.flourish", Voice );
		Queue( InspectClick, (int)Beat.Click );
	}

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

	/// <summary>The pools, once his own health is known (`ZombieAI` sizes it after this component exists).</summary>
	void Arm()
	{
		if ( _armed || !Hp.IsValid() || Hp.Max <= 1f ) return;
		_armed = true;
		_helmetHp = Hp.Max * HelmetShare;
		_chestHp = Hp.Max * ChestShare;
		_cannonHp = Hp.Max * CannonShare;
	}

	/// <summary>
	/// His table, by where the hit landed against the live bones (upstream's `PostTookDamage`): the head, then the chest, then the
	/// cannon arm, else the body. THE HOST, ⛔ once a hit (it counts); <paramref name="amount"/> is the hit before this scale, which
	/// a piece takes whole.
	/// </summary>
	protected override float ScaleDamage( float amount, in DamageInfo damage )
	{
		Arm();
		var at = damage.Position;
		if ( !_armed || at.IsNearlyZero() || !Body.IsValid() ) return BodyScale;

		if ( Near( at, "j_head", HeadReach ) ) return HitPiece( true, amount, at, damage.Attacker );
		if ( Near( at, "j_spine4", ChestReach ) ) return HitPiece( false, amount, at, damage.Attacker );
		if ( Near( at, "j_elbow_ri", ArmReach ) )
		{
			if ( _cannon && _shooting ) HitCannon( amount );
			return ArmScale;
		}
		return BodyScale;
	}

	bool Near( Vector3 at, string bone, float reach )
		=> Body.TryGetBoneTransform( bone, out var t ) && at.Distance( t.Position ) < reach * Size;

	/// <summary>
	/// A hit on the helmet or the chest plate: while its pool lasts the piece takes it (x<see cref="ArmourScale"/>); once the pool is
	/// gone, the next hit there knocks it off and lands whole (upstream's order); off, x1.
	/// </summary>
	float HitPiece( bool helmet, float amount, Vector3 at, GameObject attacker )
	{
		if ( helmet ? !_helmet : !_chest ) return 1f;

		var left = helmet ? _helmetHp : _chestHp;
		if ( left > 0f )
		{
			left -= MathF.Max( 0f, amount );
			if ( helmet ) _helmetHp = left;
			else _chestHp = left;
			ArmourHit( at );
			return ArmourScale;
		}

		BreakPiece( helmet, attacker );
		return 1f;
	}

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

	/// <summary>A piece off on every machine: the crack (here and at its shooter), his pain, the shooter's 90, a flinch. THE HOST.</summary>
	void BreakPiece( bool helmet, GameObject attacker )
	{
		if ( helmet ) { if ( !_helmet ) return; _helmet = false; }
		else { if ( !_chest ) return; _chest = false; }

		var at = helmet ? HeadPoint : ChestPoint;
		SetPart( helmet ? "helmet" : "torso", 1 );
		NZSound.PlayShared( "nz.mangler.pain", Voice );
		NZSound.PlayShared( "nz.panzer.armor.break", at );
		BossFx.BurstShared( at, 34f * Size, Violet, 14 );

		// ⚠️ THE PAY GOES THROUGH `AddPoints`, which relays to a client's own machine (the Panzer's)
		var shooter = attacker.IsValid() ? attacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
		if ( shooter.IsValid() )
		{
			NZSound.PlayShared( "nz.panzer.armor.break", shooter.WorldPosition + Vector3.Up * 50f );
			if ( PiecePoints > 0 ) shooter.AddPoints( PiecePoints );
		}

		// ⚠️ ONLY IF HE IS FREE: mid-shot or mid-stagger the piece still comes off, without the flinch (upstream's)
		if ( Ai.State is ZombieState.Chasing or ZombieState.Attacking or ZombieState.Idle )
			PlayShared( helmet ? ClipFacemask : ClipChestPain, helmet ? FacemaskLen : ChestPainLen );

		Say( $"{(helmet ? "HELMET" : "CHEST PLATE")} OFF · {(shooter.IsValid() ? $"+{PiecePoints} to {shooter.GameObject.Name}" : "no shooter")}" );
	}

	static bool IsHeavy( string clip ) => clip == ClipChop || clip == ClipUppercut;

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

	/// <summary>
	/// His death blast (upstream's `Explode(25)`, at once): every player up within <see cref="BlastRadius"/> of his middle, not behind
	/// a wall, takes x<see cref="BlastWeight"/> of his swing at the middle, nothing at the rim. THE HOST.
	/// </summary>
	void DeathBlast()
	{
		var at = WorldPosition + Vector3.Up * 40f * Size;
		var radius = BlastRadius * Size;
		var damage = SwingDamage( BlastWeight );
		foreach ( var p in PlayersWithin( at, radius ).ToList() )
		{
			var chest = p.WorldPosition + Vector3.Up * 40f;
			var wall = Scene.Trace.Ray( at, chest ).IgnoreGameObjectHierarchy( GameObject )
				.WithoutTags( "zombie", "player", "trigger", "ragdoll" ).Run();
			if ( wall.Hit ) continue;
			HurtPlayer( p, damage * Math.Clamp( 1f - chest.Distance( at ) / radius, 0f, 1f ), blast: true, at: at,
				source: "mangler death blast" );
		}

		// `HelicopterMegaBomb` and `Explosion` at his feet, `bo3_explosion_micro` on his chest, the two bangs, the shake
		BlastEffect.Spawn( at, 160f * Size );
		BossFx.BurstShared( ChestPoint, 110f * Size, Flare, 16 );
		NZSound.PlayShared( "nz.mangler.backfire", at );
		NZSound.PlayShared( "nz.zaballa.maskbreak", at );
		NZNet.ShakeAt( at, 0.6f, 400f );
	}

	// ══ each machine's own ═══════════════════════════════════════════════════

	/// <summary>The radio on his back, near him (upstream's level 55): one crackle, a 1-5 s gap, the next — each machine its own.</summary>
	void TickRadio()
	{
		if ( _radioOn )
		{
			if ( _radio.IsValid() && _radio.IsPlaying ) { _radio.Position = Voice; return; }
			_radioOn = false;
			_nextRadio = Time.Now + Game.Random.Float( 1f, 5f );
			return;
		}

		if ( Time.Now < _nextRadio ) return;
		_nextRadio = Time.Now + 1f;
		if ( NZSound.Ear.Distance( WorldPosition ) > RadioEarshot ) return;
		_radio = NZSound.Play( "nz.mangler.radio", Voice );
		_radioOn = _radio.IsValid();
	}

	Vector3 OnBone( string bone, Vector3 offset, Vector3 fallback )
		=> Body.IsValid() && Body.TryGetBoneTransform( bone, out var t ) ? t.PointToWorld( offset ) : fallback;

	/// <summary>The cannon's muzzle (`weapon_fx_tag`), where the cannon was (`rarm_fx_tag`), the helmet (`head_fx_tag`), the plate (`chest_fx_tag`).</summary>
	Vector3 Muzzle => OnBone( "j_weapon_spin", MuzzleOffset, Voice + Facing * 30f * Size );
	Vector3 Stump => OnBone( "j_shoulder_ri", StumpOffset, Voice );
	Vector3 HeadPoint => OnBone( "j_neck", Vector3.Zero, Voice );
	Vector3 ChestPoint => OnBone( "j_spineupper", Vector3.Zero, WorldPosition + Vector3.Up * 52f * Size );

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

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

	/// <summary>`nz_mangler` — every Mangler alive, and the tuning.</summary>
	[ConCmd( "nz_mangler" )]
	public static void Report()
	{
		Log.Info( $"[nz-mangler] cannon {ShotMin:0}-{ShotMax:0}u every {ShotGapMin:0.#}-{ShotGapMax:0.#}s · shot {ShotSpeed:0}u/s for {ShotLife:0.##}s,"
			+ $" turn {ShotTurn:0.##}/s, x{ShotWeight:0.###} a swing to {ShotRadius:0}u, daze {Daze:0.###}s and throw {KnockPush:0} to {ShotReach:0}u"
			+ $" · helmet {HelmetShare:0.##} / chest {ChestShare:0.##} / cannon {CannonShare:0.###} of max · armour x{ArmourScale:0.###}"
			+ $" · arm x{ArmScale:0.##} · body x{BodyScale:0.##} · heavy swing x{HeavyScale:0.###} · death blast {BlastRadius:0}u"
			+ $" x{BlastWeight:0.###} · +{PiecePoints} a piece · hit cap {MaxHit:0}" );
		Log.Info( $"[nz-mangler] the shot's look (this machine's): a yellow ring {ShotRing:0.#}u out (x0.7-x1.3), {1f / ShotPulseEvery:0.#} pulses/s"
			+ $" · head {ShotHead:0.#}u · trail {ShotTrail:0.#}u for {ShotTrailSeconds:0.##}s · glow x{ShotGlow:0.##}" );

		var list = All.ToList();
		Log.Info( $"[nz-mangler] {list.Count} alive" );
		foreach ( var m in list )
		{
			// the shot this round, at its middle and after the cap (a hit on you lands about half its middle: it goes off 20-26 out)
			var shot = m.Ai.IsValid() ? m.SwingDamage( ShotWeight ) : 0f;
			Log.Info( $"[nz-mangler]   hp {m.Hp?.Current ?? 0f:0}/{m.Hp?.Max ?? 0f:0} · state {m.Ai?.State} · speed {m.Ai?.MoveSpeed ?? 0f:0}"
				+ $" · helmet {(m._helmet ? $"{m._helmetHp:0}" : "OFF")} · chest {(m._chest ? $"{m._chestHp:0}" : "OFF")}"
				+ $" · cannon {(m._cannon ? $"{m._cannonHp:0}" : "GONE")}{(m._enraged ? " · ENRAGED" : "")}{(m._shooting ? " · SHOOTING" : "")}"
				+ $" · shot {shot:0} at its middle → {MathF.Min( shot, MaxHit ):0}, ~{MathF.Min( shot * 0.5f, MaxHit ):0} on you"
				+ $" · next shot in {MathF.Max( 0f, m._nextShot - Time.Now ):0.#}s · hitboxes {m.Ai?.HitboxCount ?? 0}" );
		}
	}

	/// <summary>`nz_mangler_shoot` — every Mangler with a cannon fires at his target (or the nearest player) now, whatever the range.</summary>
	[ConCmd( "nz_mangler_shoot" )]
	public static void ShootCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-mangler] the host decides" ); return; }
		foreach ( var m in All.Where( m => m.Ai.IsValid() && !m.Dead && m._cannon && !m._shooting ).ToList() )
		{
			var p = m.TargetPlayer;
			if ( !IsUp( p ) ) p = m.NearestPlayer();
			if ( IsUp( p ) ) m.StartShot( p );
		}
	}

	/// <summary>
	/// `nz_mangler_break &lt;helmet|chest|cannon|stagger&gt;` — knock the helmet or the chest plate off every Mangler; tear the cannon
	/// off (`cannon`, its pool emptied first); or spoil a shot as a hit on the arm would (`stagger`, which tears it off once its pool is
	/// gone).
	/// </summary>
	[ConCmd( "nz_mangler_break" )]
	public static void BreakCmd( string piece = "" )
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-mangler] the host decides" ); return; }
		var what = piece.ToLowerInvariant();
		if ( what is not ("helmet" or "chest" or "cannon" or "stagger") )
		{
			Log.Info( "[nz-mangler] nz_mangler_break <helmet|chest|cannon|stagger>" );
			return;
		}

		foreach ( var m in All.Where( m => m.Ai.IsValid() && !m.Dead ).ToList() )
		{
			m.Arm();
			if ( what == "helmet" ) m.BreakPiece( true, null );
			else if ( what == "chest" ) m.BreakPiece( false, null );
			else if ( m._cannon )
			{
				if ( what == "cannon" ) m._cannonHp = 0f;
				m.HitCannon( 0f );
			}
		}
	}

	/// <summary>`nz_mangler_inspect` — every Mangler with a cannon checks it now (`idle_twitch_check`), target or not.</summary>
	[ConCmd( "nz_mangler_inspect" )]
	public static void InspectCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-mangler] the host decides" ); return; }
		foreach ( var m in All.Where( m => m.Ai.IsValid() && !m.Dead && m._cannon && !m._shooting ).ToList() ) m.Inspect();
	}

	/// <summary>`nz_mangler_set &lt;key&gt; &lt;value&gt;` — retune him live. ⚠️ The three shares size a Mangler's pools when he arrives;
	/// the shot's look (`ring`, `head`, `trail`, `glow`, 2026-10-07) is this machine's own and takes from its next shot.</summary>
	[ConCmd( "nz_mangler_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "min": ShotMin = value; break;
			case "range": ShotMax = value; break;
			case "gapmin": ShotGapMin = value; break;
			case "gapmax": ShotGapMax = value; break;
			case "shot": ShotWeight = value; break;
			case "speed": ShotSpeed = value; break;
			case "life": ShotLife = value; break;
			case "turn": ShotTurn = value; break;
			case "radius": ShotRadius = value; break;
			case "reach": ShotReach = value; break;
			case "daze": Daze = value; break;
			case "push": KnockPush = value; break;
			case "ring": ShotRing = value; break;
			case "head": ShotHead = value; break;
			case "trail": ShotTrail = value; break;
			case "glow": ShotGlow = value; break;
			case "helmet": HelmetShare = value; break;
			case "chest": ChestShare = value; break;
			case "cannon": CannonShare = value; break;
			case "headreach": HeadReach = value; break;
			case "chestreach": ChestReach = value; break;
			case "armreach": ArmReach = value; break;
			case "armour": ArmourScale = value; break;
			case "arm": ArmScale = value; break;
			case "body": BodyScale = value; break;
			case "heavy": HeavyScale = value; break;
			case "blast": BlastRadius = value; break;
			case "blastweight": BlastWeight = value; break;
			case "points": PiecePoints = (int)value; break;
			default:
				Log.Info( "[nz-mangler] nz_mangler_set <min|range|gapmin|gapmax|shot|speed|life|turn|radius|reach|daze|push|ring|head|trail"
					+ "|glow|helmet|chest|cannon|headreach|chestreach|armreach|armour|arm|body|heavy|blast|blastweight|points> <value>" );
				return;
		}
		Log.Info( $"[nz-mangler] {key} = {value:0.###}" );
		Report();
	}
}

/// <summary>
/// The Mangler's cannon shot (2026-10-07), upstream's `nz_proj_mangler_shot_cw`: a pulsing yellow ring that flies at
/// <see cref="ManglerBoss.ShotSpeed"/> u/s for <see cref="ManglerBoss.ShotLife"/> s, homing gently on the one it was fired at
/// (<see cref="ManglerBoss.ShotTurn"/> a second, leading them by <see cref="ManglerBoss.ShotLead"/> s) while they stand, and goes off
/// on a wall, on a player, or when its time is up: x<see cref="ManglerBoss.ShotWeight"/> of his swing at its middle, nothing at
/// <see cref="ManglerBoss.ShotRadius"/>; everyone within <see cref="ManglerBoss.ShotReach"/> dazed for <see cref="ManglerBoss.Daze"/> s
/// and thrown back (even those it did not hurt — upstream's two radii). It passes through zombies, him included (upstream's
/// `IsMooZombie` pass).
///
/// ⚠️ A RING YOU CAN FOLLOW (2026-10-07). The user: *"for mangler, there should be a pulsing yellow ring shot from the cannon so the
/// player can follow it, it should deal more damage and stun for a bit longer"*. It was a light and hair-thin amber sparks, 33 a
/// second; now `BossShotLook` draws a ring round its path, square to where it flies — so the one it comes at sees it whole —
/// swelling and brightening four times a second with its pulse sound, round a glowing head with a short trail that shows its curve.
/// Its light stays (in the yellow, still throbbing with each pulse); its sparks are cut to a third, in the yellow, inside the ring.
///
/// ⚠️ A SMALL CLASS OF ITS OWN IN THIS FILE, NOT A NEW KIND IN SHARED CODE: `MargwaProjectile`'s shape — the host flies it and
/// `Launch` network-spawns it, so its transform replicates; every machine builds the look (`BossShotLook`, the light and sparks) and
/// plays its own pulse. ⚠️ Its victim's speed is measured here, frame to frame, not read off their controller: on the host a client's
/// body is a proxy whose controller does not move it.
/// ⛔ NEVER MORE THAN `BossBase.MaxHit` A HIT.
/// </summary>
public sealed class ManglerShot : Component
{
	public GameObject Victim { get; set; }
	public GameObject Owner { get; set; }
	/// <summary>Its hit at the middle — sized on the host at launch, so his death mid-flight changes nothing.</summary>
	public float Damage { get; set; }

	Vector3 _dir, _victimLast, _victimVel;
	bool _victimSeen, _done;
	float _dieAt, _nextPulse, _pulsed, _nextSpark;
	NZPlayer _victim;
	PointLight _light;
	GameObject _lightGo;

	/// <summary>Send one. THE HOST; a client only watches.</summary>
	public static ManglerShot Launch( Scene scene, Vector3 from, Vector3 dir, GameObject victim, GameObject owner, float damage )
	{
		if ( !scene.IsValid() || NZGame.IsClient ) return null;

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

		var s = go.Components.Create<ManglerShot>();
		s.Victim = victim;
		s.Owner = owner;
		s.Damage = damage;

		// ⚠️ AFTER IT IS CONFIGURED: the spawn is serialised at this moment (`MargwaProjectile`'s note)
		go.NetworkSpawn();
		return s;
	}

	protected override void OnStart()
	{
		_dir = WorldRotation.Forward;
		_dieAt = Time.Now + ManglerBoss.ShotLife;
		_victim = Victim.IsValid() ? Victim.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;

		_lightGo = new GameObject { Parent = GameObject, Name = "light" };
		_lightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		_light = _lightGo.Components.Create<PointLight>();
		_light.LightColor = ManglerBoss.ShotYellow * 3f;
		_light.Radius = 160f;
		_light.Shadows = false;

		// ⚠️ THE RING YOU FOLLOW (2026-10-07), every machine: it pulses at its sound's rate, so ring, light and pulse keep time
		var look = BossShotLook.On( GameObject, ManglerBoss.ShotYellow, ManglerBoss.ShotHead, trailWidth: ManglerBoss.ShotTrail,
			trailSeconds: ManglerBoss.ShotTrailSeconds, ringRadius: ManglerBoss.ShotRing, pulseHz: 1f / ManglerBoss.ShotPulseEvery,
			brightness: ManglerBoss.ShotGlow );
		if ( look.IsValid() ) look.RingWidth = ManglerBoss.ShotRingWidth;
	}

	protected override void OnUpdate()
	{
		var now = Time.Now;

		// ── every machine: the light that throbs with each pulse, a few sparks crackling inside the ring (`BossShotLook` draws the
		// ring, the head and the trail) — and the pulse itself ──
		if ( now >= _nextPulse )
		{
			_nextPulse = now + ManglerBoss.ShotPulseEvery;
			_pulsed = now;
			NZSound.Play( "nz.mangler.pulse", WorldPosition );
		}
		if ( _light.IsValid() )
			_light.LightColor = ManglerBoss.ShotYellow * (2.5f + 3f * MathF.Max( 0f, 1f - (now - _pulsed) / ManglerBoss.ShotPulseEvery ));

		// ⚠️ A THIRD AS MANY AS BEFORE (every 0.09 s, was 0.03) AND SHORTER (6-12, was 8-18): kept inside the ring, not across it
		if ( now >= _nextSpark )
		{
			_nextSpark = now + 0.09f;
			var c = WorldPosition;
			AvogadroSpark.Between( c + Vector3.Random * 3f, c + Vector3.Random.Normal * Game.Random.Float( 6f, 12f ),
				Game.Random.Float( 0.05f, 0.12f ), 0.16f, ManglerBoss.ShotYellow );
		}

		if ( _done || NZGame.IsClient ) return;

		// ── the host flies it: home on its victim while they stand, leading them ──
		var dt = Time.Delta;
		if ( BossBase.IsUp( _victim ) )
		{
			var feet = _victim.WorldPosition;
			if ( _victimSeen && dt > 0f )
				_victimVel = Vector3.Lerp( _victimVel, (feet - _victimLast) / dt, 1f - MathF.Exp( -10f * dt ) );
			_victimLast = feet;
			_victimSeen = true;

			var aim = feet + Vector3.Up * ManglerBoss.ShotAimHeight + _victimVel * ManglerBoss.ShotLead - WorldPosition;
			if ( aim.Length > 1f ) _dir = Vector3.Lerp( _dir, aim.Normal, 1f - MathF.Exp( -ManglerBoss.ShotTurn * dt ) ).Normal;
		}

		var from = WorldPosition;
		var next = from + _dir * ManglerBoss.ShotSpeed * dt;

		// a wall ends it (zombies it passes through)
		var wall = Scene.Trace.Ray( from, next + _dir * 4f ).IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( "zombie", "player", "trigger", "ragdoll" ).Run();
		if ( wall.Hit )
		{
			Explode( wall.HitPosition - _dir * 4f );
			return;
		}

		WorldPosition = next;
		WorldRotation = Rotation.LookAt( _dir, Vector3.Up );

		// so does any player it touches, and its time running out
		if ( now >= _dieAt || Scene.GetAllComponents<NZPlayer>().Any( p => BossBase.IsUp( p ) && Touches( p, next ) ) ) Explode( next );
	}

	/// <summary>Within <see cref="ManglerBoss.ShotTouch"/> of their body, shins to head.</summary>
	static bool Touches( NZPlayer p, Vector3 at )
	{
		var a = p.WorldPosition + Vector3.Up * 6f;
		var ab = Vector3.Up * 60f;
		var t = Math.Clamp( (at - a).Dot( ab ) / ab.LengthSquared, 0f, 1f );
		return (a + ab * t).Distance( at ) <= ManglerBoss.ShotTouch;
	}

	/// <summary>
	/// Gone off: the hurt (more at the middle, under his cap), and on every machine the blast — each its own player dazed inside
	/// it (`BossFx.BurstShared`) and thrown back (`NZNet.AvogadroShock`) — and the bang. THE HOST.
	/// </summary>
	void Explode( Vector3 at )
	{
		if ( _done ) return;
		_done = true;

		var radius = MathF.Max( 1f, ManglerBoss.ShotRadius );
		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !BossBase.IsUp( p ) ) continue;
			var d = (p.WorldPosition + Vector3.Up * 40f).Distance( at );
			if ( d >= radius ) continue;
			var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
			if ( !hp.IsValid() || hp.IsDead ) continue;
			hp.Apply( MathF.Min( Damage * (1f - d / radius), BossBase.MaxHit ), false, Owner.IsValid() ? Owner : GameObject,
				blast: true, blastAt: at, source: "mangler cannon" );
		}

		BossFx.BurstShared( at, ManglerBoss.ShotReach, ManglerBoss.Amber, 14, ManglerBoss.Daze );
		if ( ManglerBoss.KnockPush > 0f ) NZNet.AvogadroShock( at, ManglerBoss.ShotReach, ManglerBoss.KnockPush );
		else NZNet.ShakeAt( at, 0.5f, ManglerBoss.ShotReach * 3f );
		NZSound.PlayShared( "nz.mangler.impact", at );
		GameObject.Destroy();
	}

	protected override void OnDestroy()
	{
		if ( _lightGo.IsValid() ) _lightGo.Destroy();
	}
}