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.
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 <helmet|chest|cannon|stagger>` — 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 <key> <value>` — 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();
}
}