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.
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 <key> <value>` — 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}" : "")}" );
}
}