Zombies/BomberZombie.cs

A BossBase component implementing the Bomber zombie and its loose bomb for NZombies. Controls spawn, states (walking, fuse sprint, kamikaze, treasure), hit handling for bomb and harness, dropping/detonating loose bombs, explosions affecting zombies and players, and console debug/commands.

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

namespace NZombies;

/// <summary>
/// The Bomber Zombie — CoD WWII's bomber, ported 2026-10-07 from GMod nZombies' `nz_zombie_special_bomba` (GhostlyMoo's) and its
/// bomb `nz_bomb`. A SPECIAL, NOT A BOSS (`bomber.zvar`: `IsBoss` false, a walker's health): it walks into an ordinary round with
/// a bomb strapped to its back. Spec: `Sbox nzombies/Docs/boss_specs/bomber.md`.
///
///   • IT WALKS (52.6 u/s, `walk_v1`) AND NEVER SWINGS WHILE IT CARRIES THE BOMB (upstream's `OnTargetInAttackRange` waits on
///     `LostBomb`); it still tears boards.
///   • THE FUSE SPRINT: a player within <see cref="FuseRange"/> (350) and it lights the fuse — `sprint_fuse_v1` at 123 u/s, the bomb
///     bonking its head — for good (<see cref="FuseLatch"/>: upstream's way back to a walk only fires above speed 100, which nothing
///     reaches). The last <see cref="LastFewCount"/> zombies of a round from round <see cref="LastFewFrom"/> light it too (upstream's
///     own `LastZombieMomento`).
///   • THE KAMIKAZE: its target within <see cref="KamikazeRange"/> (150) and in sight for <see cref="KamikazeDelay"/> (1 s) without a
///     break — never in its first <see cref="KamikazeArm"/> (2) s — and it sets its own bomb off.
///   • SHOOT THE BOMB ON ITS BACK and it goes off (its hitbox, `tag_weapon_chest`; a blast reaching the bomber does the same): the
///     bomber dies in it, and the kills are the shooter's.
///   • SHOOT THE HARNESS (the `stomach` hitboxes round the bomb) after its first <see cref="KnockArm"/> (4) s and the bomb falls off,
///     live: it yells, stumbles (`bomb_drop`), runs (`run_h1`, 171.7) and swings (35 against a walker's 50, x0.7).
///   • KILL IT and the bomb falls off, live.
///   • A LOOSE BOMB (<see cref="LooseBomb"/>) lies <see cref="LooseFuse"/> (30) s, shootable — a shot, the knife, a grenade's blast —
///     then goes off on its own.
///   • THE BLAST, every bomb however it goes off (<see cref="LooseBomb.Explode"/>): within <see cref="BlastRadius"/> (200) every
///     zombie in the open dies (upstream's 10,000; never a boss), and players take x<see cref="BlastWeight"/> of its swing at the
///     middle (upstream's 175 against 35), falling to nothing at the edge, NEVER MORE THAN `BossBase.MaxHit` A BLAST.
///     ⚠️ A BOMB A PLAYER SETS OFF HURTS ONLY THAT PLAYER, and not with PhD Flopper — upstream's friendly fire (nZombies'
///     `PlayerShouldTakeDamage` refuses another player's damage), the grenade's rule here; <see cref="ShooterOnly"/> changes it.
///     ⚠️ ANOTHER BOMB'S BLAST NEVER SETS ONE OFF (upstream's blast hurts NPCs and players, never a bomb): a bomber it kills drops its.
///   • ONE IN 300 IS A TREASURE BOMBER (<see cref="TreasureChance"/>): a gold bag for a bomb (`skin1`), x<see cref="TreasureHealth"/>
///     health, no bomb, no swing; it runs from you (`sprint_v1`, 115.1), drops a Vulture drop (points or ammo, anyone's) every
///     <see cref="TreasureEvery"/> s, and after <see cref="TreasureDrops"/> its heart gives out. Killed by a player: Bonus Points —
///     or, <see cref="PerkChance"/> of the time, a free perk (upstream's perk bottle: the Director's grant).
///   • A BOMBER WITHOUT ITS BOMB lands `run_attack_v2`'s second blow too (frame 43; the Meúchler's later blow, one swing never past
///     `MaxHit`).
///
/// ⚠️ ON `BossBase`, NOT A PLAIN `Component` LIKE THE NAPALM ZOMBIE: its hook into `Health.OnDamage` (<see cref="ScaleDamage"/> —
/// where a hit lands is the whole bomb), `PlayShared`/`SwapClip`, `SetPart`/`ShowPart`, `HurtPlayer`, `MaxHit`, `SwingDamage`,
/// `CanSee` are what it is made of; nothing in `BossBase` assumes a boss (no bar, music or round rule — `IsBoss` is the variant's).
/// ⚠️ DEVIATIONS: upstream's knock-off read its hit group through `util.QuickTrace(hitpos, hitpos)` — a trace from the hit to twice
/// its position, which almost never meets the harness; here, the hitbox the shot met (for a hit relayed from another machine, where
/// it landed against the harness's boxes). Upstream's `GibForceTest` (force 150) passes for every bullet: any hit counts.
/// ⚠️ UNTIL `models/zombies/bomber_bomb.vmdl` IS PORTED a loose bomb is a hot orange glow (`BossShotLook`); the carried one is the
/// model's own `boomdevice` bodygroup (upstream hid it and parented the prop there instead).
/// ⚠️ LEFT OUT: the shocked death (its `electrobolt` clips: no shock damage type here), the BO3 Mystify / BO4 toxic statuses, the
/// GobbleGum treasure, the swing's whoosh (no variant slot for it).
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: what it looks like and how fast its legs go travel as one number (<see cref="LookPart"/>
/// via `NZNet.BossPart`, re-sent every 3 s for anyone who joined); a loose bomb is network-spawned and its `Health` relays a
/// client's shot to the host as a zombie's does; the blast is `BlastEffect` + `NZSound.PlayShared` + `NZNet.ShakeAt`.
/// ⛔ NO HIT OF ITS DOWNS ANYONE FROM FULL HEALTH (`BossBase.MaxHit`): the swing (`Ai.MaxHitDamage`), the later blow, every bomb.
/// </summary>
public sealed class BomberZombie : BossBase
{
	protected override string Tag => "[nz-bomber]";

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

	static float? _fuseRange, _kamiRange, _kamiDelay, _kamiArm, _knockArm, _walkPace;
	/// <summary>It lights the fuse for a target within 350 (upstream's `TargetInRange(350)`).</summary>
	public static float FuseRange { get => _fuseRange ?? 350f; set => _fuseRange = value; }
	/// <summary>Its target within 150 (upstream's `TargetInRange(150)`), in sight, for 1 s on end: it sets its bomb off.</summary>
	public static float KamikazeRange { get => _kamiRange ?? 150f; set => _kamiRange = value; }
	public static float KamikazeDelay { get => _kamiDelay ?? 1f; set => _kamiDelay = value; }
	/// <summary>Not in its first 2 s (upstream's `KAMIKAZETIME = CurTime() + 2`).</summary>
	public static float KamikazeArm { get => _kamiArm ?? 2f; set => _kamiArm = value; }
	/// <summary>The harness cannot be shot loose in its first 4 s (upstream's `Cooldown = CurTime() + 4`).</summary>
	public static float KnockArm { get => _knockArm ?? 4f; set => _knockArm = value; }
	/// <summary>
	/// Its walk with the bomb: 52.6 u/s, `walk_v1`'s own ground speed. ⚠️ THE `.zvar` PICKS THE LEGS BY THIS SPEED: from 65 they are
	/// upstream's unused run (`run_v1/_v2`), from 100 the treasure's sprint — keep it under 65.
	/// </summary>
	public static float WalkPace { get => _walkPace ?? 52.6f; set => _walkPace = value; }

	static bool? _latch, _shooterOnly;
	/// <summary>Once lit, the fuse sprint lasts until the bomb is gone (upstream's). Off: it walks again when nobody is within range.</summary>
	public static bool FuseLatch { get => _latch ?? true; set => _latch = value; }
	/// <summary>A bomb a player sets off hurts that player alone (upstream's friendly fire). Off: every player in reach.</summary>
	public static bool ShooterOnly { get => _shooterOnly ?? true; set => _shooterOnly = value; }

	static float? _blastRadius, _blastWeight, _zombieBlast, _looseFuse, _shake, _combo;
	/// <summary>Every bomb's blast reaches 200 (upstream's `FindInSphere(pos, 200)`).</summary>
	public static float BlastRadius { get => _blastRadius ?? 200f; set => _blastRadius = value; }
	/// <summary>
	/// A blast at its middle against the bomber's swing: 5 — upstream's 175 against its 35. Its swing is a walker's x0.7 (the
	/// variant), so the middle is 105 on round 5, 175 on 12 and 315 on 31 — ⛔ EVERY ONE CAPPED AT `MaxHit` (100); 100 units out, half.
	/// </summary>
	public static float BlastWeight { get => _blastWeight ?? 5f; set => _blastWeight = value; }
	/// <summary>What a zombie in the blast takes: upstream's 10,000 — or its health + 666 if that is more (the Shrieker's rule), so it dies.</summary>
	public static float ZombieBlast { get => _zombieBlast ?? 10000f; set => _zombieBlast = value; }
	/// <summary>A loose bomb goes off by itself after 30 s (upstream's `StartSelfDestruct`).</summary>
	public static float LooseFuse { get => _looseFuse ?? 30f; set => _looseFuse = value; }
	/// <summary>The blast's shake (upstream's `ScreenShake` amplitude 20 within 400).</summary>
	public static float BlastShake { get => _shake ?? 1f; set => _shake = value; }
	/// <summary>`run_attack_v2`'s two blows together never take more than this many `MaxHit`s (the Meúchler's `ComboCap`).</summary>
	public static float ComboCap { get => _combo ?? 1f; set => _combo = value; }

	static float? _treasureChance, _treasureHealth, _treasureEvery, _perkChance, _fleeDistance;
	static int? _treasureDrops, _lastFew, _lastFewFrom;
	/// <summary>One in 300 is a treasure bomber (upstream's `math.random(300) == 1`).</summary>
	public static float TreasureChance { get => _treasureChance ?? 1f / 300f; set => _treasureChance = value; }
	/// <summary>x4 a bomber's health (upstream's `GetZombieHealth() * 4`).</summary>
	public static float TreasureHealth { get => _treasureHealth ?? 4f; set => _treasureHealth = value; }
	/// <summary>A Vulture drop every 3 s, and its heart gives out after 12 (upstream's `TreasureDropRate`, `TreasureSuicide`).</summary>
	public static float TreasureEvery { get => _treasureEvery ?? 3f; set => _treasureEvery = value; }
	public static int TreasureDrops { get => _treasureDrops ?? 12; set => _treasureDrops = value; }
	/// <summary>Killed by a player: a free perk 1% of the time (upstream's `bottle`), else Bonus Points.</summary>
	public static float PerkChance { get => _perkChance ?? 0.01f; set => _perkChance = value; }
	/// <summary>It runs for a spot 300 away from you (upstream's `FleeDistance`), looked for again every half second.</summary>
	public static float FleeDistance { get => _fleeDistance ?? 300f; set => _fleeDistance = value; }
	/// <summary>The last 3 zombies of a round light the fuse (upstream's `GetZombiesMax() - 3`), from round 4.</summary>
	public static int LastFewCount { get => _lastFew ?? 3; set => _lastFew = value; }
	public static int LastFewFrom { get => _lastFewFrom ?? 4; set => _lastFewFrom = value; }

	static float? _propPitch, _propLift;
	/// <summary>The loose bomb's prop (once ported): turned this much about its side (degrees), lifted this far off the floor.</summary>
	public static float PropPitch { get => _propPitch ?? 0f; set => _propPitch = value; }
	public static float PropLift { get => _propLift ?? 0f; set => _propLift = value; }

	/// <summary>`nz_bomber_at 1`'s one-shot: the next bomber to start is (true) or is not (false) a treasure bomber.</summary>
	static bool? _treasureNext;

	public const string VariantPath = "zombies/bomber.zvar";

	// its clips (30 fps) — ⛔ `const`, never `static readonly` (INSTRUCTIONS.md §1)
	const string ClipDrop = "nz_s2_bmb_bomb_drop", ClipFuse = "nz_s2_bmb_sprint_fuse_v1", ClipTreasure = "nz_s2_bmb_sprint_v1",
		ClipIdle = "nz_s2_bmb_idle_v1", ClipTrav = "nz_s2_bmb_trav", ClipSwing2 = "nz_s2_run_attack_v2";
	const float DropLen = 54f / 30f, DropTravel = 144.1f, TreasureStride = 78f / 30f, Swing2At = 43f / 58f;

	/// <summary>The clips' own ground speeds (`bomber.zvar`'s tiers): the fuse sprint, the treasure's, the run with no bomb.</summary>
	const float FusePace = 123f, TreasurePace = 115.1f, MadPace = 171.7f;

	const string BombBone = "tag_weapon_chest", BombGroup = "boomdevice";

	/// <summary>Not a bodygroup: its <see cref="Look"/>, the host's word to every machine, taken by <see cref="ShowPart"/>.</summary>
	const string LookPart = "bomber_look";

	/// <summary>
	/// What it looks like and how its legs go, one number every machine is told: the bomb on its back while walking or on the fuse
	/// sprint, off once it is lost (mad) or gone (blown, dropped as it died), the gold bag while a treasure bomber.
	/// </summary>
	enum Look { Walk, Fuse, Mad, Treasure, Gone }

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

	Look _look;
	int _appliedLook = -1;
	bool _treasure, _sizeTreasure, _detonatePending, _knockPending, _knocked, _blowDone;
	GameObject _detonator, _knocker;
	float _kamikazeAt, _knockReadyAt, _lookSentAt, _nextDrop, _fleeRepathAt, _relayAt, _bonkPrev = -1f, _swingT;
	int _drops;
	string _swingClip;
	Vector3 _fleeTo;

	public bool Treasure => _treasure;

	/// <summary>The bomb is on its back.</summary>
	bool Carrying => _look is Look.Walk or Look.Fuse;

	/// <summary>Where the bomb hangs now (the model's `bombatt`), or behind its back at chest height.</summary>
	Vector3 BombPoint => Body.IsValid() && Body.TryGetBoneTransform( BombBone, out var t ) ? t.Position
		: WorldPosition + Vector3.Up * 52f * Size - Facing * 20f * Size;

	/// <summary>Something else carries the body: a link, a window, an arc, its climb.</summary>
	bool Busy => Ai.CrossingLink || Ai.OnLink || Ai.ParkedAt is not null || Ai.Arcing || Ai.CurrentClip == ClipTrav;

	static float PaceOf( Look look ) => look switch
	{
		Look.Walk => WalkPace,
		Look.Fuse => FusePace,
		Look.Treasure => TreasurePace,
		_ => MadPace,
	};

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

		// ⛔ NONE OF THE WALKER'S CLIMB CLIPS ON ITS MODEL: its own (upstream's `JumpSequences`)
		Ai.ClimbClipOverride = ClipTrav;
		Ai.MeleeDisabled = true;

		_treasure = _treasureNext ?? Game.Random.Float( 0f, 1f ) < TreasureChance;
		_treasureNext = null;
		_sizeTreasure = _treasure;
		_kamikazeAt = Time.Now + KamikazeArm;
		_knockReadyAt = Time.Now + KnockArm;
		_nextDrop = Time.Now + TreasureEvery;

		SetLook( _treasure ? Look.Treasure : Look.Walk );
		SetSpeed( PaceOf( _look ) );

		Say( _treasure
			? $"spawned — A TREASURE BOMBER: {Hp?.Max ?? 0f:0} hp x{TreasureHealth:0.#}, runs from you, a drop every {TreasureEvery:0.#}s, gone after {TreasureDrops}"
			: $"spawned — {Hp?.Max ?? 0f:0} hp · the fuse within {FuseRange:0}, the kamikaze within {KamikazeRange:0} for {KamikazeDelay:0.#}s"
				+ $" · blast {BlastRadius:0}u, {SwingDamage( BlastWeight ):0} at the middle (cap {MaxHit:0}) · hitboxes {Ai.HitboxCount}" );
	}

	protected override void EveryFrame()
	{
		ApplyLook();

		// ⚠️ A WATCHING MACHINE'S LEGS: the host's `SetSpeed` does not travel (the Panzerhund's note) — its puppet picks its tier from
		// the look it was told
		if ( Ai.IsPuppet )
		{
			var speed = MathF.Max( 1f, PaceOf( _look ) * Size );
			if ( MathF.Abs( Ai.SpeedOverride - speed ) > 0.01f )
			{
				Ai.SpeedOverride = speed;
				Ai.RepickAnimations();
			}
		}

		TickBonks();
	}

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

		// ⚠️ x4 ONCE ITS HEALTH IS KNOWN, and only on the frame after it started (a hotload leaves `_sizeTreasure` false)
		if ( _sizeTreasure && Hp.IsValid() && Hp.Max > 1f )
		{
			_sizeTreasure = false;
			Hp.Reset( Hp.Max * MathF.Max( 0.01f, TreasureHealth ) );
		}

		// a shot on the bomb, decided in `ScaleDamage`, goes off here, outside the damage call
		if ( _detonatePending )
		{
			_detonatePending = false;
			Blow( _detonator, "shot" );
			return;
		}

		// ⚠️ ITS LOOK AGAIN EVERY 3 S, for anyone who joined since (a broadcast is heard once)
		if ( Networking.IsActive && Time.Now >= _lookSentAt + 3f ) SetLook( _look );

		if ( Ai.State == ZombieState.Spawning ) return;

		if ( _treasure )
		{
			TickTreasure();
			return;
		}

		if ( _knockPending )
		{
			_knockPending = false;
			KnockOff( _knocker );
		}

		if ( Carrying )
		{
			TickFuse();
			if ( TickKamikaze() ) return;
		}
		else WatchSwing();

		TickPace();
	}

	protected override void Died( bool host )
	{
		ApplyLook();
		if ( !host ) return;

		// ⚠️ THE SHOT THAT HIT THE BOMB KILLED IT TOO: the bomb goes off all the same
		if ( _detonatePending )
		{
			_detonatePending = false;
			Blow( _detonator, "shot as it died" );
		}
		else if ( Carrying )
		{
			// upstream's `PerformDeath` → `DropBomb()`: the bomb falls off, live
			DropLoose();
			SetLook( Look.Gone );
			Say( $"killed — its bomb falls off, live for {LooseFuse:0}s" );
		}

		if ( _treasure ) Reward();
	}

	protected override void DeadFrame() => ApplyLook();

	/// <summary>
	/// Where a hit lands, and what that sets off: the bomb (its hitbox, or a blast) → it goes off, the hitter its detonator; the
	/// harness after <see cref="KnockArm"/> s → the bomb falls off. Both happen outside this call (<see cref="HostFrame"/>,
	/// <see cref="Died"/>). Every hit lands x1. THE HOST, ⛔ once a hit.
	/// </summary>
	protected override float ScaleDamage( float amount, in DamageInfo damage )
	{
		if ( _treasure || !Carrying || _detonatePending ) return 1f;

		var tags = damage.Tags;

		// ⚠️ ANOTHER BOMB'S BLAST NEVER SETS THIS ONE OFF (upstream's hurts NPCs and players, never a bomb): it may only kill the bomber
		if ( tags is not null && tags.Has( LooseBomb.BlastTag ) ) return 1f;

		var bone = damage.Hitbox?.Bone.Name;
		var at = damage.Position;
		var blast = tags is not null && (tags.Has( Health.ExplosionTag ) || tags.Has( TechBlast.BlastTag ));

		if ( blast || OnBomb( bone, at ) )
		{
			_detonatePending = true;
			_detonator = damage.Attacker;
			return 1f;
		}

		if ( !_knocked && !_knockPending && Time.Now >= _knockReadyAt && OnHarness( bone, at ) )
		{
			_knockPending = true;
			_knocker = damage.Attacker;
		}

		return 1f;
	}

	/// <summary>A watching machine told its look (`NZNet.BossPart`). ⚠️ THE HOST'S OWN ECHO IS TAKEN AND IGNORED.</summary>
	public override bool ShowPart( string group, int choice )
	{
		if ( group != LookPart ) return false;
		if ( NZGame.IsClient && choice >= 0 && choice <= (int)Look.Gone ) _look = (Look)choice;
		ApplyLook();
		return true;
	}

	// ══ the look ═════════════════════════════════════════════════════════════

	/// <summary>The look, every machine told (and the host's own). THE HOST.</summary>
	void SetLook( Look look )
	{
		_look = look;
		_lookSentAt = Time.Now;
		SetPart( LookPart, (int)look );
	}

	/// <summary>The bomb shown or not, the gold skin — on THIS machine's renderer, when the look changed.</summary>
	void ApplyLook()
	{
		if ( _appliedLook == (int)_look || !Body.IsValid() ) return;
		_appliedLook = (int)_look;

		Body.SetBodyGroup( BombGroup, _look is Look.Mad or Look.Gone ? 1 : 0 );
		if ( _look == Look.Treasure && Body.Model is not null && Body.Model.GetMaterialGroupIndex( "skin1" ) >= 0 )
			Body.MaterialGroup = "skin1";
	}

	/// <summary>
	/// The bomb bonking its head on the fuse sprint (`zmb_bomber_hit_head`, upstream's `bmb_bonk` at frames 12, 28, 43 and 58 of 73)
	/// — a model's events never reach code, so the clip is watched. Every machine, its own.
	/// </summary>
	void TickBonks()
	{
		if ( !Body.IsValid() || Body.Sequence.Name != ClipFuse )
		{
			_bonkPrev = -1f;
			return;
		}

		var t = Body.Sequence.TimeNormalized;
		var prev = _bonkPrev;
		_bonkPrev = t;
		if ( prev < 0f ) return;

		for ( int i = 0; i < 4; i++ )
		{
			var c = BonkAt( i );
			var crossed = t >= prev ? prev < c && c <= t : c > prev || c <= t;
			if ( !crossed ) continue;
			NZSound.Play( "nz.bomber.bonk", Voice );
			break;
		}
	}

	static float BonkAt( int i ) => i switch { 0 => 12f / 73f, 1 => 28f / 73f, 2 => 43f / 73f, _ => 58f / 73f };

	// ══ the bomb ═════════════════════════════════════════════════════════════

	/// <summary>A player within <see cref="FuseRange"/> (or the last few of a round): the fuse sprint and its shout. THE HOST.</summary>
	void TickFuse()
	{
		var p = TargetPlayer;
		var near = IsUp( p ) && WorldPosition.Distance( p.WorldPosition ) < FuseRange * Size;

		if ( _look == Look.Walk )
		{
			if ( near ) LightFuse( "a player within range" );
			else if ( LastFew() ) LightFuse( "one of the last few" );
		}
		else if ( _look == Look.Fuse && !FuseLatch && !near && !LastFew() )
		{
			SetLook( Look.Walk );
			Say( "nobody near — walking again (latch off)" );
		}
	}

	void LightFuse( string why )
	{
		if ( _look != Look.Walk ) return;
		SetLook( Look.Fuse );
		NZSound.PlayShared( "nz.bomber.charge", Voice );
		Say( $"FUSE LIT ({why}) — sprinting at {FusePace:0} u/s" );
	}

	/// <summary>
	/// Upstream's `LastZombieMomento`: from round 4, out of a special round, with 3 or fewer zombies left in the round (to spawn and
	/// alive — `killed >= max - 3`). Its own count, not the map's straggler sprint, as upstream's is.
	/// </summary>
	static bool LastFew()
	{
		var rm = RoundManager.Instance;
		if ( !rm.IsValid() || rm.State != RoundState.Active || rm.InSpecialRound || rm.Round < LastFewFrom ) return false;
		return rm.Remaining + rm.AliveBlocking <= LastFewCount;
	}

	/// <summary>
	/// Its target within <see cref="KamikazeRange"/> and in sight (upstream's `!IsAttackBlocked()`) for <see cref="KamikazeDelay"/> s
	/// on end: it sets its own bomb off — the detonator itself, so the kills pay nobody. True when it went off. THE HOST.
	/// </summary>
	bool TickKamikaze()
	{
		var p = TargetPlayer;
		var armed = IsUp( p ) && WorldPosition.Distance( p.WorldPosition ) < KamikazeRange * Size && CanSee( p.GameObject );
		if ( !armed )
		{
			_kamikazeAt = MathF.Max( _kamikazeAt, Time.Now + KamikazeDelay );
			return false;
		}

		if ( Time.Now < _kamikazeAt ) return false;
		Blow( GameObject, "kamikaze" );
		return true;
	}

	/// <summary>The bomb on its back goes off: off the model on every machine, then the blast — the bomber in it. THE HOST.</summary>
	void Blow( GameObject detonator, string why )
	{
		if ( !Carrying ) return;
		var at = BombPoint;
		SetLook( Look.Gone );
		Say( $"BOOM ({why}{(detonator.IsValid() && detonator != GameObject ? $" by {detonator.Name}" : "")})" );
		LooseBomb.Explode( Scene, at, detonator, SwingDamage( BlastWeight ), GameObject, GameObject, GameObject );
	}

	/// <summary>
	/// The harness shot loose: the bomb falls off, live; the clank, its yell, the stumble — and from here on it runs and swings
	/// (upstream's `ManIsMad`). THE HOST.
	/// </summary>
	void KnockOff( GameObject by )
	{
		if ( !Carrying || _knocked ) return;
		_knocked = true;

		var at = BombPoint;
		DropLoose();
		SetLook( Look.Mad );

		// ⚠️ THE PANZER'S METAL CLANK: upstream's HL2 `metal_box_impact_bullet` is in no archive
		NZSound.PlayShared( "nz.panzer.armor.hit", at );
		NZSound.PlayShared( "nz.bomber.pain", Voice );
		Stumble();

		Say( $"BOMB KNOCKED OFF{(by.IsValid() ? $" by {by.Name}" : "")} — live for {LooseFuse:0}s; it runs at {MadPace:0} u/s and swings" );
	}

	/// <summary>`bomb_drop` (1.8 s) carrying it 144 forward — the clip's own root motion, which the port strips — short of a wall.</summary>
	void Stumble()
	{
		if ( !PlayShared( ClipDrop, DropLen ) ) return;

		var from = WorldPosition + Vector3.Up * 40f * Size;
		var dir = Facing;
		var reach = DropTravel * Size;
		var wall = Scene.Trace.Ray( from, from + dir * reach ).IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( "zombie", "player", "trigger", "ragdoll" ).Run();
		if ( wall.Hit ) reach = MathF.Max( 0f, wall.Distance - 30f * Size );
		if ( reach > 16f ) Ai.BeginArc( WorldPosition + dir * reach, DropLen, 4f * Size );
	}

	/// <summary>The bomb off its back, falling from where it hung, live for <see cref="LooseFuse"/> s, at this round's blast. THE HOST.</summary>
	void DropLoose()
		=> LooseBomb.Drop( Scene, BombPoint, Rotation.LookAt( Facing, Vector3.Up ), GameObject, SwingDamage( BlastWeight ), LooseFuse );

	/// <summary>Its speed for its look, and the swing only without the bomb. ⚠️ NEVER MID-SPECIAL (the Krasny's rule). THE HOST.</summary>
	void TickPace()
	{
		if ( Ai.State is ZombieState.Special or ZombieState.Spawning ) return;
		Ai.MeleeDisabled = _look != Look.Mad;
		var speed = MathF.Max( 1f, PaceOf( _look ) * Size );
		if ( MathF.Abs( Ai.SpeedOverride - speed ) > 0.01f ) SetSpeed( PaceOf( _look ) );
	}

	/// <summary>True when the hit met the bomb: its hitbox, or — no hitbox (a relayed hit, the capsule) — inside the bomb's box.</summary>
	bool OnBomb( string bone, Vector3 at )
	{
		if ( bone is not null ) return bone == BombBone;
		return InBox( BombBone, new Vector3( -10f, -10f, -22.5f ), new Vector3( 10f, 10f, 22.5f ), at );
	}

	/// <summary>True when the hit met the harness (the `stomach` hitboxes round the bomb): the same two ways.</summary>
	bool OnHarness( string bone, Vector3 at )
	{
		for ( int i = 0; i < HarnessCount; i++ )
		{
			var h = Harness( i );
			if ( bone is not null ? bone == h.Bone : InBox( h.Bone, h.Min, h.Max, at ) ) return true;
		}
		return false;
	}

	const int HarnessCount = 10;

	/// <summary>The harness's hitboxes (`bomber.vmdl`, tagged `stomach`; upstream's HITGROUP_STOMACH): a bone and its box.</summary>
	static (string Bone, Vector3 Min, Vector3 Max) Harness( int i ) => i switch
	{
		0 => ("j_spineupperb", new Vector3( -8f, -3.5f, -8f ), new Vector3( 12f, 7.5f, 8f )),
		1 => ("j_headb", new Vector3( -6f, -6f, -6f ), new Vector3( 6f, 6f, 6f )),
		2 => ("j_shoulderc_le", new Vector3( -8f, -2.5f, -2.5f ), new Vector3( 8f, 2.5f, 2.5f )),
		3 => ("j_elbowc_le", new Vector3( -3f, -2.5f, -2.5f ), new Vector3( 13f, 2.5f, 2.5f )),
		4 => ("j_shoulderc_ri", new Vector3( -8f, -2.5f, -2.5f ), new Vector3( 8f, 2.5f, 2.5f )),
		5 => ("j_elbowc_ri", new Vector3( -13f, -2.5f, -2.5f ), new Vector3( 3f, 2.5f, 2.5f )),
		6 => ("j_shoulderb_le", new Vector3( -6f, -2.5f, -2.5f ), new Vector3( 10f, 2.5f, 2.5f )),
		7 => ("j_shoulderb_ri", new Vector3( -10f, -2.5f, -2.5f ), new Vector3( 6f, 2.5f, 2.5f )),
		8 => ("j_elbowb_le", new Vector3( -3f, -2.5f, -2.5f ), new Vector3( 13f, 2.5f, 2.5f )),
		_ => ("j_elbowb_ri", new Vector3( -13f, -2.5f, -2.5f ), new Vector3( 3f, 2.5f, 2.5f )),
	};

	/// <summary>A point inside a bone's box, in that bone's frame (the hitbox's own), 2 units of slack.</summary>
	bool InBox( string bone, Vector3 min, Vector3 max, Vector3 at )
	{
		if ( at.IsNearlyZero() || !Body.IsValid() || !Body.TryGetBoneTransform( bone, out var t ) ) return false;
		var l = t.PointToLocal( at );
		const float slack = 2f;
		return l.x >= min.x - slack && l.x <= max.x + slack && l.y >= min.y - slack && l.y <= max.y + slack
			&& l.z >= min.z - slack && l.z <= max.z + slack;
	}

	// ══ the swing without the bomb ═══════════════════════════════════════════

	/// <summary>`run_attack_v2`'s second `melee` (frame 43 of 58), watched as it plays — the first is the AI's own. THE HOST.</summary>
	void WatchSwing()
	{
		if ( !Body.IsValid() ) return;
		var clip = Body.Sequence.Name;
		var t = Body.Sequence.TimeNormalized;
		if ( clip != _swingClip || t + 0.05f < _swingT )
		{
			_swingClip = clip;
			_blowDone = false;
		}
		_swingT = t;

		if ( _blowDone || clip != ClipSwing2 || Ai.State != ZombieState.Attacking || t < Swing2At ) return;
		_blowDone = true;
		LandLaterBlow();
	}

	/// <summary>
	/// The later blow, within the AI's own reach and in sight — never at a window it is tearing (the boards take its swing). ⛔ ONE
	/// SWING, ONE CAP: the two never pass <see cref="ComboCap"/> x `MaxHit`. THE HOST.
	/// </summary>
	void LandLaterBlow()
	{
		if ( StatusEffects.IsDisarmed( GameObject ) || Ai.ParkedAt is not null ) return;

		var p = TargetPlayer;
		if ( !IsUp( p ) ) return;
		var reach = (Ai.AttackRange + Ai.AttackRangePadding) * Ai.ScaledAttackReach;
		if ( WorldPosition.Distance( p.WorldPosition ) > reach || !CanSee( p.GameObject ) ) return;

		var blow = SwingDamage( Ai.SwingScale );
		var damage = MathF.Min( blow, MaxHit * ComboCap - MathF.Min( blow, MaxHit ) );
		if ( damage < 1f ) return;
		if ( HurtPlayer( p, damage, tick: true, source: "bomber later blow" ) )
			NZSound.PlayShared( "nz.bomber.impact", p.WorldPosition + Vector3.Up * 40f );
	}

	// ══ the treasure bomber ══════════════════════════════════════════════════

	/// <summary>A drop every <see cref="TreasureEvery"/> s until its heart gives out, and the run from you between. THE HOST.</summary>
	void TickTreasure()
	{
		if ( Time.Now >= _nextDrop )
		{
			_nextDrop = Time.Now + TreasureEvery;

			// upstream's `drop_treasure`: the Vulture drop, points or ammo (50/50), anyone's
			Pickup.Spawn( WorldPosition + Vector3.Up * 40f * Size,
				Game.Random.Int( 0, 1 ) == 0 ? PickupKind.VulturePoints : PickupKind.VultureAmmo, ignore: GameObject );

			if ( ++_drops >= TreasureDrops )
			{
				// upstream's heart attack: its health + 666, from nobody — so no reward
				Say( $"{_drops} drops — its heart gives out" );
				Hp.OnDamage( new DamageInfo { Damage = Hp.Current + 666f, Position = Voice } );
				return;
			}
		}

		Flee();
	}

	/// <summary>
	/// Away from you (upstream's `FleeTarget(6)` every think): its sprint held as a special — the AI neither chases nor swings — and
	/// its agent told where to every <c>0.5</c> s (the Meúchler's flight). Nobody up, it stands. THE HOST.
	/// </summary>
	void Flee()
	{
		var threat = TargetPlayer;
		if ( !IsUp( threat ) ) threat = NearestPlayer();
		var running = IsUp( threat );

		if ( Ai.State != ZombieState.Special )
		{
			if ( !PlayShared( running ? ClipTreasure : ClipIdle, 3f ) ) return;
			_relayAt = Time.Now + TreasureStride;
			_fleeRepathAt = 0f;
		}
		Ai.HoldSpecialFor( 3f );

		var agent = Components.Get<NavMeshAgent>( FindMode.EverythingInSelfAndDescendants );
		if ( Busy ) { _fleeRepathAt = 0f; return; }

		if ( !running )
		{
			Halt( agent );
			if ( Body.IsValid() && Body.Sequence.Name != ClipIdle ) SwapClip( ClipIdle );
			return;
		}

		// its run back after an idle, a link's clip or a climb took the renderer
		if ( Body.IsValid() && Body.Sequence.Name != ClipTreasure )
		{
			SwapClip( ClipTreasure );
			_relayAt = Time.Now + TreasureStride;
		}

		if ( agent.IsValid() )
		{
			var speed = MathF.Max( 1f, TreasurePace * Size );
			if ( MathF.Abs( agent.MaxSpeed - speed ) > 0.5f ) agent.MaxSpeed = speed;
			if ( Time.Now >= _fleeRepathAt )
			{
				_fleeRepathAt = Time.Now + 0.5f;
				_fleeTo = FleeSpot( threat.WorldPosition );
				agent.MoveTo( _fleeTo );
			}
		}

		// ⚠️ A RELAYED CLIP PLAYS ONCE on a watching machine (`PlayClipAsPuppet`): the stride sent again as each one ends
		if ( Networking.IsActive && Time.Now >= _relayAt )
		{
			NZNet.ZombieClip( GameObject.Id, ClipTreasure, 1f );
			_relayAt = Time.Now + TreasureStride;
		}
	}

	/// <summary>
	/// Upstream's `GetFleeDestination`: <see cref="FleeDistance"/> straight away from the threat, on the navmesh — or, where that is a
	/// wall, whichever of a few points on the mesh within that reach lies furthest from them.
	/// </summary>
	Vector3 FleeSpot( Vector3 threat )
	{
		var here = WorldPosition;
		var away = (here - threat).WithZ( 0f );
		var dir = away.Length > 1f ? away.Normal : Facing;
		var reach = FleeDistance * Size;

		var best = ZombieAI.NavGround( Scene, here + dir * reach );
		var bestD = (best - threat).WithZ( 0f ).Length;

		var nav = Scene.NavMesh;
		if ( nav is null ) return best;
		for ( int i = 0; i < 6; i++ )
		{
			var q = nav.GetRandomPoint( here, reach );
			if ( !q.HasValue || MathF.Abs( q.Value.z - here.z ) > 64f ) continue;
			var d = (q.Value - threat).WithZ( 0f ).Length;
			if ( d <= bestD ) continue;
			bestD = d;
			best = q.Value;
		}
		return best;
	}

	void Halt( NavMeshAgent agent )
	{
		if ( !agent.IsValid() ) return;
		agent.MaxSpeed = 0f;
		agent.MoveTo( WorldPosition );
	}

	/// <summary>
	/// Killed by a player: Bonus Points where it fell — or <see cref="PerkChance"/> of the time, a free perk on the killer's own
	/// machine (upstream's `bottle`; `NZNet.DirectorPerk`, the Sawrunner's and the Director's grant). Its heart: nothing. THE HOST.
	/// </summary>
	void Reward()
	{
		var killer = Hp.IsValid() && Hp.LastAttacker.IsValid()
			? Hp.LastAttacker.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors )
			: null;
		if ( !killer.IsValid() ) { Say( "treasure bomber dead with no player to credit — no reward" ); return; }

		if ( Game.Random.Float( 0f, 1f ) < PerkChance )
		{
			NZNet.DirectorPerk( killer.GameObject.Id );
			Say( $"treasure bomber killed — a free perk for {killer.GameObject.Name}" );
			return;
		}

		Powerup.SpawnShared( WorldPosition, PowerupKind.BonusPoints );
		Say( $"treasure bomber killed by {killer.GameObject.Name} — Bonus Points" );
	}

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

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

	static IEnumerable<LooseBomb> Loose
		=> Game.ActiveScene?.GetAllComponents<LooseBomb>() ?? Enumerable.Empty<LooseBomb>();

	/// <summary>`nz_bomber` — every bomber alive, the loose bombs, and the tuning.</summary>
	[ConCmd( "nz_bomber" )]
	public static void Report()
	{
		Log.Info( $"[nz-bomber] walks {WalkPace:0.#}, fuse {FusePace:0} within {FuseRange:0}{(FuseLatch ? " (for good)" : " (latch off)")}, last {LastFewCount}"
			+ $" from round {LastFewFrom} · kamikaze within {KamikazeRange:0} for {KamikazeDelay:0.##}s (not before {KamikazeArm:0.#}s)"
			+ $" · harness from {KnockArm:0.#}s · loose bomb {LooseFuse:0}s" );
		Log.Info( $"[nz-bomber] blast {BlastRadius:0}u: zombies {ZombieBlast:0} (or health + 666), players x{BlastWeight:0.##} of the swing at the middle"
			+ $" (cap {MaxHit:0}){(ShooterOnly ? ", a player's bomb hurts only them" : "")} · shake {BlastShake:0.##} · combo cap x{ComboCap:0.##}" );
		Log.Info( $"[nz-bomber] treasure 1 in {(TreasureChance > 0f ? 1f / TreasureChance : 0f):0}: x{TreasureHealth:0.#} hp, a drop every {TreasureEvery:0.#}s,"
			+ $" {TreasureDrops} then its heart, flees {FleeDistance:0} · perk {PerkChance * 100f:0.#}%"
			+ $" · prop pitch {PropPitch:0} lift {PropLift:0.#}" );

		var list = All.ToList();
		Log.Info( $"[nz-bomber] {list.Count} alive" );
		foreach ( var b in list )
		{
			var p = b.TargetPlayer;
			var d = IsUp( p ) ? b.WorldPosition.Distance( p.WorldPosition ) : -1f;
			Log.Info( $"[nz-bomber]   {b._look}{(b._treasure ? $" ({b._drops}/{TreasureDrops} drops)" : "")} · hp {b.Hp?.Current ?? 0f:0}/{b.Hp?.Max ?? 0f:0}"
				+ $" · state {b.Ai?.State} · speed {b.Ai?.MoveSpeed ?? 0f:0} · target {(d < 0f ? "none" : $"{d:0}u")}"
				+ $"{(b.Carrying && b.IsHost ? $" · kamikaze in {MathF.Max( 0f, b._kamikazeAt - Time.Now ):0.0}s if it holds" : "")}" );
		}

		var loose = Loose.ToList();
		Log.Info( $"[nz-bomber] {loose.Count} loose bomb(s){(loose.Count > 0 ? ": " + string.Join( ", ", loose.Select( l => $"{l.Left:0.0}s left" ) ) : "")}" );
	}

	/// <summary>`nz_bomber_at [treasure 0|1] [forward]` — a bomber on the floor in front of you, facing you, now (a treasure one with 1).</summary>
	[ConCmd( "nz_bomber_at" )]
	public static void SpawnHere( int treasure = 0, float forward = 300f )
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-bomber] the host spawns" ); return; }

		var scene = Game.ActiveScene;
		var p = NZPlayer.Local;
		if ( !scene.IsValid() || !p.IsValid() ) { Log.Warning( "[nz-bomber] no player" ); return; }

		var variant = ResourceLibrary.Get<ZombieVariant>( VariantPath );
		if ( variant is null ) { Log.Warning( $"[nz-bomber] '{VariantPath}' did not load" ); return; }

		var rot = p.Components.Get<PlayerController>()?.EyeAngles.ToRotation() ?? p.WorldRotation;
		var at = p.WorldPosition + rot.Forward.WithZ( 0f ).Normal * MathF.Max( 64f, forward );
		var tr = scene.Trace.Ray( at + Vector3.Up * 128f, at - Vector3.Up * 4096f ).Run();
		var pos = tr.Hit ? tr.HitPosition : at;

		_treasureNext = treasure != 0;
		var z = ZombieCommands.SpawnAt( scene, pos, variant );
		if ( z is null ) { _treasureNext = null; Log.Warning( "[nz-bomber] spawn failed" ); return; }

		z.WorldRotation = Rotation.FromYaw( Rotation.LookAt( (p.WorldPosition - pos).WithZ( 0f ).Normal ).Yaw() );
		z.GameObject.Name = "bomber";
		Log.Info( $"[nz-bomber] dropped a{(treasure != 0 ? " TREASURE" : "")} bomber in front of you at {pos:0}" );
	}

	/// <summary>`nz_bomber_fuse` — every bomber with its bomb lights the fuse now.</summary>
	[ConCmd( "nz_bomber_fuse" )]
	public static void FuseCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-bomber] the host decides" ); return; }
		foreach ( var b in All.Where( b => b.Ai.IsValid() && !b.Dead && b._look == Look.Walk ).ToList() ) b.LightFuse( "nz_bomber_fuse" );
	}

	/// <summary>`nz_bomber_boom [loose 0|1]` — every carried bomb goes off now (a kamikaze); with 1, every loose one too.</summary>
	[ConCmd( "nz_bomber_boom" )]
	public static void BoomCmd( int loose = 0 )
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-bomber] the host decides" ); return; }
		foreach ( var b in All.Where( b => b.Ai.IsValid() && !b.Dead && b.Carrying ).ToList() ) b.Blow( b.GameObject, "nz_bomber_boom" );
		if ( loose == 0 ) return;
		foreach ( var l in Loose.ToList() ) l.Detonate( null, "nz_bomber_boom" );
	}

	/// <summary>`nz_bomber_knock` — every bomber's bomb falls off now, as if the harness were shot (its first 4 s too).</summary>
	[ConCmd( "nz_bomber_knock" )]
	public static void KnockCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-bomber] the host decides" ); return; }
		foreach ( var b in All.Where( b => b.Ai.IsValid() && !b.Dead && b.Carrying ).ToList() ) b.KnockOff( null );
	}

	/// <summary>`nz_bomber_set &lt;key&gt; &lt;value&gt;` — retune it live (booleans: 0 / 1).</summary>
	[ConCmd( "nz_bomber_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "fuse": FuseRange = value; break;
			case "latch": FuseLatch = value != 0f; break;
			case "kamikaze": KamikazeRange = value; break;
			case "delay": KamikazeDelay = value; break;
			case "arm": KamikazeArm = value; break;
			case "knockarm": KnockArm = value; break;
			case "walk": WalkPace = value; break;
			case "radius": BlastRadius = value; break;
			case "blast": BlastWeight = value; break;
			case "zombieblast": ZombieBlast = value; break;
			case "loosefuse": LooseFuse = value; break;
			case "shake": BlastShake = value; break;
			case "shooteronly": ShooterOnly = value != 0f; break;
			case "combo": ComboCap = value; break;
			case "treasure": TreasureChance = value; break;
			case "treasurehp": TreasureHealth = value; break;
			case "treasureevery": TreasureEvery = value; break;
			case "treasuredrops": TreasureDrops = (int)value; break;
			case "perk": PerkChance = value; break;
			case "flee": FleeDistance = value; break;
			case "lastfew": LastFewCount = (int)value; break;
			case "lastfewfrom": LastFewFrom = (int)value; break;
			case "proppitch": PropPitch = value; break;
			case "proplift": PropLift = value; break;
			default:
				Log.Info( "[nz-bomber] nz_bomber_set <fuse|latch|kamikaze|delay|arm|knockarm|walk|radius|blast|zombieblast|loosefuse|shake"
					+ "|shooteronly|combo|treasure|treasurehp|treasureevery|treasuredrops|perk|flee|lastfew|lastfewfrom|proppitch|proplift> <value>" );
				return;
		}
		Log.Info( $"[nz-bomber] {key} = {value:0.###}" );
		Report();
	}
}

/// <summary>
/// A BOMBER'S BOMB OFF ITS BACK (2026-10-07) — upstream's `nz_bomb` after `DropBomb`: it falls from where it hung to the floor and
/// lies there live for its <see cref="Fuse"/> (<see cref="BomberZombie.LooseFuse"/>, 30 s), then goes off; any hit before that — a
/// shot, the knife, a grenade's or a napalm's blast, anything that reaches its `Health` — sets it off at once, the hit's player its
/// detonator. <see cref="Explode"/> is every bomb's blast, the carried one's too.
///
/// ⚠️ THE HOST DROPS IT, TIMES IT AND SETS IT OFF; EVERYONE ELSE WATCHES. Network-spawned after its fuse is set (`[Property]`, so the
/// spawn carries it); its fall is the host moving it (the transform replicates); a client's shot reaches the host through its
/// `Health` (`Health.OnDamage` → `NZNet.HurtRemote`), as a zombie's does.
/// ⚠️ TAGGED `ragdoll`: players and zombies walk through it (`Collision.config`), bullets still meet it — upstream's
/// `COLLISION_GROUP_DEBRIS_TRIGGER`.
/// ⚠️ ITS LOOK: `models/zombies/bomber_bomb.vmdl` once that prop is ported (upstream's `moo_codz_s2_bmb_bomb.mdl`) with a lit fuse
/// on top; until then the fuse's glow, bigger, is the bomb (`BossShotLook`). Either way it pulses faster as the fuse burns down.
/// </summary>
public sealed class LooseBomb : Component
{
	public const string PropModel = "models/zombies/bomber_bomb.vmdl";

	/// <summary>On a bomb's blast: another bomber's never sets its own off (`BomberZombie.ScaleDamage`).</summary>
	public const string BlastTag = "bomber_blast";

	static Color Glow => new( 1f, 0.45f, 0.1f );

	[Property] public float Fuse { get; set; } = 30f;

	// ── the host's ──
	public GameObject Owner { get; set; }
	public float Damage { get; set; }

	float _born, _floorZ, _fall;
	bool _done, _landed;
	Health _hp;
	BossShotLook _glow;

	/// <summary>Seconds until it goes off by itself.</summary>
	public float Left => MathF.Max( 0f, _born + Fuse - Time.Now );

	/// <summary>Drop one at <paramref name="from"/>. THE HOST; a client only watches.</summary>
	public static LooseBomb Drop( Scene scene, Vector3 from, Rotation facing, GameObject owner, float damage, float fuse )
	{
		if ( !scene.IsValid() || NZGame.IsClient ) return null;

		var go = new GameObject( true, "nz_bomber_bomb" );
		go.Flags |= GameObjectFlags.NotSaved;
		go.WorldPosition = from;
		go.WorldRotation = facing;
		// ⚠️ THE TAG FIRST: a collider's shapes take the object's tags as they are made (`ZombieAI.StopBeingSolid`'s note)
		go.Tags.Add( "ragdoll" );

		var b = go.Components.Create<LooseBomb>();
		b.Fuse = MathF.Max( 0.1f, fuse );
		b.Owner = owner;
		b.Damage = damage;

		// something to shoot, sitting on the floor
		var col = go.Components.Create<SphereCollider>();
		col.Radius = 12f;
		col.Center = Vector3.Up * 10f;
		go.Components.Create<Health>();

		// ⚠️ AFTER IT IS SET UP: the spawn is serialised at this moment
		go.NetworkSpawn();
		return b;
	}

	protected override void OnStart()
	{
		_born = Time.Now;
		BuildLook();
		if ( NZGame.IsClient ) return;

		_hp = Components.Get<Health>();
		if ( _hp.IsValid() ) _hp.OnDamaged = ( amount, headshot ) => Detonate( _hp.LastAttacker, "hit" );
		_floorZ = FloorUnder( WorldPosition );
	}

	/// <summary>The prop (once ported) and the fuse's glow, on every machine — children of a networked object are each machine's own.</summary>
	void BuildLook()
	{
		var model = Model.Load( PropModel );
		var prop = model is not null && !model.IsError;
		if ( prop )
		{
			var propGo = new GameObject { Parent = GameObject, Name = "prop" };
			propGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
			propGo.NetworkMode = NetworkMode.Never;
			propGo.LocalPosition = Vector3.Up * BomberZombie.PropLift;
			propGo.LocalRotation = Rotation.From( BomberZombie.PropPitch, 0f, 0f );
			propGo.Components.Create<ModelRenderer>().Model = model;
		}

		var fuseGo = new GameObject { Parent = GameObject, Name = "fuse" };
		fuseGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
		fuseGo.NetworkMode = NetworkMode.Never;
		fuseGo.LocalPosition = Vector3.Up * (prop ? 22f : 10f);
		_glow = BossShotLook.On( fuseGo, Glow, prop ? 7f : 20f, pulseHz: 1.2f, brightness: prop ? 4f : 3f );
	}

	float FloorUnder( Vector3 from )
	{
		var trace = Scene.Trace.Ray( from, from - Vector3.Up * 600f ).IgnoreGameObjectHierarchy( GameObject )
			.WithoutTags( "zombie", "player", "trigger", "ragdoll" );
		if ( Owner.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( Owner );
		var tr = trace.Run();
		return tr.Hit ? tr.HitPosition.z : from.z - 52f;
	}

	protected override void OnUpdate()
	{
		// every machine: the glow quickens as the fuse burns down
		var t = Math.Clamp( (Time.Now - _born) / MathF.Max( 0.1f, Fuse ), 0f, 1f );
		if ( _glow.IsValid() ) _glow.PulseHz = 1.2f + 6f * t * t;

		if ( _done || NZGame.IsClient ) return;

		// the fall, under Source's gravity (600)
		if ( !_landed )
		{
			_fall += 600f * Time.Delta;
			var z = WorldPosition.z - _fall * Time.Delta;
			if ( z <= _floorZ ) { z = _floorZ; _landed = true; }
			WorldPosition = WorldPosition.WithZ( z );
		}

		if ( Time.Now >= _born + Fuse ) Detonate( null, "its fuse ran out" );
	}

	/// <summary>Off now: <paramref name="by"/> the detonator (a player hurts only himself), the blast, gone. THE HOST.</summary>
	public void Detonate( GameObject by, string why )
	{
		if ( _done || NZGame.IsClient ) return;
		_done = true;

		Log.Info( $"[nz-bomber] a loose bomb goes off ({why}{(by.IsValid() ? $" by {by.Name}" : "")})" );
		Explode( Scene, WorldPosition + Vector3.Up * 10f, by, Damage, Owner.IsValid() ? Owner : GameObject, null, GameObject );
		GameObject.Destroy();
	}

	/// <summary>
	/// A bomber's bomb going off at <paramref name="at"/> (upstream's `nz_bomb:OnTakeDamage`). THE HOST.
	///   • every zombie within <see cref="BomberZombie.BlastRadius"/> with no wall between dies — <see cref="BomberZombie.ZombieBlast"/>
	///     or its health + 666 — never a boss nor basalt's beast in its fight; <paramref name="carrier"/>, the bomber whose back it was
	///     on, dies whatever; the kills are <paramref name="by"/>'s (a player's pay; the bomber's own or nobody's do not);
	///   • every player up within it takes <paramref name="damage"/> x (1 − distance / radius), walls or not (upstream's), ⛔ never
	///     more than `BossBase.MaxHit` — ⚠️ only <paramref name="by"/> himself when a player set it off (upstream's friendly fire),
	///     and not him with PhD Flopper; <paramref name="from"/> is whose blast it is on their screen (the bomber, for their armour);
	///   • the fireball, the bang and the shake on every machine.
	/// </summary>
	public static void Explode( Scene scene, Vector3 at, GameObject by, float damage, GameObject from, GameObject carrier, GameObject ignore )
	{
		if ( !scene.IsValid() || NZGame.IsClient ) return;

		var radius = MathF.Max( 1f, BomberZombie.BlastRadius );
		var shooter = by.IsValid() ? by.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
		int killed = 0, hurt = 0;

		foreach ( var z in ZombieAI.All.ToList() )
		{
			if ( !z.IsValid() || z.State == ZombieState.Dead ) continue;
			if ( z.Variant?.IsBoss ?? false ) continue;
			if ( HexPlatforms.IsFightBoss( z ) ) continue;

			var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
			if ( !hp.IsValid() || hp.IsDead ) continue;

			var mid = z.WorldPosition + Vector3.Up * 36f * MathF.Max( 0.1f, z.WorldScale.x );
			if ( !(carrier.IsValid() && z.GameObject == carrier) )
			{
				var d = mid.Distance( at );
				if ( d > radius ) continue;

				// upstream's brush-only trace: only the world shields a zombie
				var trace = scene.Trace.Ray( at, mid ).WithoutTags( "zombie", "player", "trigger", "ragdoll" );
				if ( ignore.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( ignore );
				var tr = trace.Run();
				if ( tr.Hit && tr.Distance < d - 8f ) continue;
			}

			var info = new DamageInfo
			{
				Damage = MathF.Max( BomberZombie.ZombieBlast, hp.Current + 666f ),
				Attacker = by,
				Position = mid,
				Tags = new TagSet(),
			};
			info.Tags.Add( Health.ExplosionTag );
			info.Tags.Add( BlastTag );
			hp.OnDamage( info );
			killed++;
		}

		foreach ( var p in scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !BossBase.IsUp( p ) ) continue;
			if ( shooter.IsValid() && BomberZombie.ShooterOnly && p != shooter ) continue;
			if ( shooter.IsValid() && p == shooter && PerkEffects.ImmuneToNonZombieDamage( 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;

			var dmg = MathF.Min( damage * (1f - Math.Clamp( d / radius, 0f, 1f )), BossBase.MaxHit );
			if ( dmg < 1f ) continue;
			hp.Apply( dmg, false, from, blast: true, blastAt: at, source: "bomber bomb" );
			hurt++;
		}

		BlastEffect.Spawn( at, radius );
		NZSound.PlayShared( "nz.bomber.explode", at );
		NZNet.ShakeAt( at, BomberZombie.BlastShake, 400f );

		Log.Info( $"[nz-bomber] BOOM at {at:0} — {killed} zombie(s) killed, {hurt} player(s) hurt ({damage:0} at the middle, cap {BossBase.MaxHit:0})"
			+ $"{(shooter.IsValid() ? $" · set off by {shooter.GameObject.Name}" : "")}" );
	}
}