Avogadro boss and its bolt projectile for NZombies. Implements boss behavior (teleports/dashes, jumps, bolts, shockwave, stun on melee), movement, animation/clip syncing, networking hooks for effects, and the AvogadroBolt flight, collision and blast logic.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Avogadro — the electric man, ported 2026-10-06 from GMod nZombies' `nz_zombie_boss_avogadro` (GhostlyMoo's port of Alpha
/// Omega's). The user picked him with the Astronaut, the Director and the Panzer Soldat, after the Margwa.
///
/// | | a melee hit, once every <see cref="MeleeLock"/> s | everything else |
/// |---|---|---|
/// | **damage** | **×2**, and he is stunned (`nz_avo_pain_long`, untouchable while it plays) | **×0.1** |
///
/// ⛔ UPSTREAM'S TABLE AS UPSTREAM MEANT IT. Its `OnInjured` tests the weapon with `~= or ~= or ~=`, which is always true, so
/// in GMod every hit is ×0.1 and the melee branch — ×2, the stun, `pain_long`, five seconds before a knife counts again — is
/// dead code. Here the melee branch is live: the knife is how you answer him, at a price (he is untouchable for the stun).
///
/// HIS MOVES (host), every think while he is walking at his target, a coin flip like upstream's `math.random(2)`:
/// • TELEPORT (every 5 s): one of his twelve dashes — forward, back, left or right, short, medium or long, upstream's own
/// clips — invisible from the start of it to frame 8 (`phase_out`), a ball of lightning crossing the floor meanwhile. The
/// direction is random, as upstream's is; a dash into a wall is skipped and still costs the five seconds, as upstream's
/// `SequenceHasSpace` failure does.
/// • JUMP (every 8-12 s, 5-8 under half health; the user's, 2026-10-06: *"teleport around to appear near any player"*): to
/// ANY player at least <see cref="JumpFrom"/> away — at random among them, so in co-op he hunts the whole team — he turns
/// into lightning, bolts across, and lands 150-300 from them on the navmesh. ⚠️ A WARNING FIRST: the spot crackles and a
/// ring fills for <see cref="JumpWarn"/> s before he is there. He comes out facing them, with a bolt ready a second later.
/// • BOLT (every 4-7 s, the first after 6; the user took all of it): `ranged_attack_intro`, then a bolt of lightning at
/// 1250 u/s (`AvogadroBolt`), then `ranged_attack_end`. It is AIMED WHERE YOU ARE GOING (your speed times its flight, up to
/// <see cref="LeadMax"/>), THREE IN A FAN under half health, and where it lands it leaves an ELECTRIC PATCH for 3 s
/// (`AvogadroField`) that hurts and stops regen. ⚠️ ONLY AT A TARGET HE CAN SEE, within <see cref="ZapRange"/>: upstream's
/// trace ignores the world (`ignoreworld = true`) and measures nothing, so it fired through walls from anywhere.
/// • SHOCKED (the user's: *"when shocked, we can make the player unable to shoot"*): a bolt's blast or his patch leaves you
/// unable to shoot (`PlayerShock`, read by `Weapon.CanShoot`) — 2 s from a bolt, for as long as you stand in a patch. The
/// knife still works.
/// • SHOCKWAVE (every 5 s): anyone within <see cref="ShockRange"/> sets it off — players hurt a little and thrown, zombies
/// knocked over, and he recoils (`pain_med`). ⚠️ ONE PLAYER IS ENOUGH: upstream's test counts players inside its own loop
/// and needs two, so it never fired solo and fired N-1 times with N close.
/// • UNDER HALF HEALTH HE RUNS (`nz_avo_run`, 70.6 u/s): upstream compares `Health()` with `Health() / 2`, which is never
/// true; `MaxHealth() / 2` was the plain intent.
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the host decides; `NZNet.AvogadroPhase` / `AvogadroShock` / `AvogadroBoltLand` /
/// `AvogadroJump` carry it, and `AvogadroFx` draws it on each machine — with his body's electricity (`AvogadroCrackle`) and his hum.
/// ⚠️ HIS SPECIAL CLIPS ARE RELAYED (`PlayShared`): `ZombieAI.PlaySpecial` does not tell watching machines what it plays.
///
/// ⛔ NO ATTACK OF HIS DOWNS ANYONE FROM FULL HEALTH (<see cref="HitCap"/>), Oberon's rule.
/// </summary>
public sealed class AvogadroBoss : Component
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1: a static's value survives a hotload, its initialiser does not re-run.
static float? _bulletScale, _meleeScale, _meleeLock, _stunSeconds;
/// <summary>What every hit but a counted melee does: ×0.1, upstream's `ScaleDamage(0.1)`.</summary>
public static float BulletScale { get => _bulletScale ?? 0.1f; set => _bulletScale = value; }
/// <summary>A counted melee hit: ×2, upstream's.</summary>
public static float MeleeScale { get => _meleeScale ?? 2f; set => _meleeScale = value; }
/// <summary>Seconds after a counted melee hit before the next one counts (×0 meanwhile): 5, upstream's `LastStun`.</summary>
public static float MeleeLock { get => _meleeLock ?? 5f; set => _meleeLock = value; }
/// <summary>The stun: `nz_avo_pain_long`, 97 frames at 30.</summary>
public static float StunSeconds { get => _stunSeconds ?? 97f / 30f; set => _stunSeconds = value; }
static float? _teleportGap;
/// <summary>Seconds between dashes: 5, upstream's `LastTeleport`.</summary>
public static float TeleportGap { get => _teleportGap ?? 5f; set => _teleportGap = value; }
static float? _zapGapMin, _zapGapMax, _zapRange, _zapSpeed, _zapRadius, _zapWeight, _zapDaze;
/// <summary>Seconds between bolts, after one: 4-7 (upstream's `math.random(5, 10)` until the "more dangerous" pass).</summary>
public static float ZapGapMin { get => _zapGapMin ?? 4f; set => _zapGapMin = value; }
public static float ZapGapMax { get => _zapGapMax ?? 7f; set => _zapGapMax = value; }
/// <summary>How far he will throw one: 1500 (upstream: anywhere).</summary>
public static float ZapRange { get => _zapRange ?? 1500f; set => _zapRange = value; }
/// <summary>The bolt's speed: 1250 u/s, upstream's `Launch` velocity.</summary>
public static float ZapSpeed { get => _zapSpeed ?? 1250f; set => _zapSpeed = value; }
/// <summary>Its blast: 50 units, upstream's `FindInSphere(50)`.</summary>
public static float ZapRadius { get => _zapRadius ?? 50f; set => _zapRadius = value; }
/// <summary>Its damage against one swipe: 1.2 — upstream's 90 against his 75.</summary>
public static float ZapWeight { get => _zapWeight ?? 1.2f; set => _zapWeight = value; }
/// <summary>The daze it leaves: 2 s, upstream's `NZSonicBlind(2)`.</summary>
public static float ZapDaze { get => _zapDaze ?? 2f; set => _zapDaze = value; }
static float? _zapFirst, _fanSpread, _leadMax, _shockSeconds, _fieldSeconds, _fieldRadius, _fieldDps;
/// <summary>His first bolt after he arrives: 6 s (upstream's 10).</summary>
public static float ZapFirst { get => _zapFirst ?? 6f; set => _zapFirst = value; }
/// <summary>Under half health he throws three: the side ones this many degrees off the middle one.</summary>
public static float FanSpread { get => _fanSpread ?? 12f; set => _fanSpread = value; }
/// <summary>The most a bolt leads its target by: 350 units (your speed times its flight time, capped).</summary>
public static float LeadMax { get => _leadMax ?? 350f; set => _leadMax = value; }
/// <summary>How long a bolt's blast leaves you SHOCKED — unable to shoot (`PlayerShock`): 2 s, the length of its daze.</summary>
public static float ShockSeconds { get => _shockSeconds ?? 2f; set => _shockSeconds = value; }
/// <summary>The electric patch a bolt leaves on the floor: 3 s, 70 across at size 1, 0.25 of a swipe a second.</summary>
public static float FieldSeconds { get => _fieldSeconds ?? 3f; set => _fieldSeconds = value; }
public static float FieldRadius { get => _fieldRadius ?? 70f; set => _fieldRadius = value; }
public static float FieldDps { get => _fieldDps ?? 0.25f; set => _fieldDps = value; }
static float? _jumpGapMin, _jumpGapMax, _angryJumpGapMin, _angryJumpGapMax, _jumpFrom, _jumpNear, _jumpFar, _jumpWarn;
/// <summary>Seconds between jumps: 8-12; under half health 5-8.</summary>
public static float JumpGapMin { get => _jumpGapMin ?? 8f; set => _jumpGapMin = value; }
public static float JumpGapMax { get => _jumpGapMax ?? 12f; set => _jumpGapMax = value; }
public static float AngryJumpGapMin { get => _angryJumpGapMin ?? 5f; set => _angryJumpGapMin = value; }
public static float AngryJumpGapMax { get => _angryJumpGapMax ?? 8f; set => _angryJumpGapMax = value; }
/// <summary>He jumps to a player at least this far from him: 450 (nearer, he walks and dashes).</summary>
public static float JumpFrom { get => _jumpFrom ?? 450f; set => _jumpFrom = value; }
/// <summary>Where he lands: 150-300 from that player, on the navmesh, on their floor.</summary>
public static float JumpNear { get => _jumpNear ?? 150f; set => _jumpNear = value; }
public static float JumpFar { get => _jumpFar ?? 300f; set => _jumpFar = value; }
/// <summary>The warning on the landing spot before he arrives: 0.6 s.</summary>
public static float JumpWarn { get => _jumpWarn ?? 0.6f; set => _jumpWarn = value; }
static float? _shockRange, _shockRadius, _shockWeight, _shockGap, _shockPush;
/// <summary>A player this near sets the shockwave off: 100, upstream's `GetRangeTo < 100`.</summary>
public static float ShockRange { get => _shockRange ?? 100f; set => _shockRange = value; }
/// <summary>How far it reaches: 250, upstream's `FindInSphere(250)`.</summary>
public static float ShockRadius { get => _shockRadius ?? 250f; set => _shockRadius = value; }
/// <summary>Its damage against one swipe: 0.27 — upstream's flat 20 against his 75.</summary>
public static float ShockWeight { get => _shockWeight ?? 0.27f; set => _shockWeight = value; }
/// <summary>Seconds between shockwaves: 5, upstream's.</summary>
public static float ShockGap { get => _shockGap ?? 5f; set => _shockGap = value; }
/// <summary>The throw: 255 u/s out and 255 up, upstream's `SetVelocity`.</summary>
public static float ShockPush { get => _shockPush ?? 255f; set => _shockPush = value; }
static float? _enrageAt, _runSpeed, _hitCap, _thinkEvery;
/// <summary>Health share at which he starts running: 0.5.</summary>
public static float EnrageAt { get => _enrageAt ?? 0.5f; set => _enrageAt = value; }
/// <summary>His run: 70.6 u/s, `nz_avo_run`'s own ground speed (165 u over 70 frames). He walks at 46 (`FixedSpeed`).</summary>
public static float RunSpeed { get => _runSpeed ?? 70.6f; set => _runSpeed = value; }
/// <summary>The most one hit of his takes, as a share of the match's base health. Two-thirds, Oberon's.</summary>
public static float HitCap { get => _hitCap ?? (2f / 3f); set => _hitCap = value; }
/// <summary>How often the coin is flipped: 0.2 s (upstream every tick, which comes to the same once one is ready).</summary>
public static float ThinkEvery { get => _thinkEvery ?? 0.2f; set => _thinkEvery = value; }
public static float MaxHit => MathF.Max( 1f, Difficulty.MaxHealth * Math.Clamp( HitCap, 0.1f, 0.95f ) );
// ══ the dashes ═══════════════════════════════════════════════════════════
//
// ⛔ SWITCHES, NOT ARRAYS (§1). Direction 0 forward, 1 back, 2 left, 3 right; length 0 short, 1 medium, 2 long.
static string DirName( int d ) => d switch { 0 => "forward", 1 => "back", 2 => "left", _ => "right" };
static string LenName( int l ) => l switch { 0 => "short", 1 => "med", _ => "long" };
static string DashClip( int d, int l ) => $"nz_avo_teleport_{DirName( d )}_{LenName( l )}";
/// <summary>How far each dash goes, at size 1: its root travel, measured from the decompiled SMDs.</summary>
static float DashTravel( int d, int l ) => (d, l) switch
{
(0, 0) => 64.9f, (0, 1) => 103.3f, (0, 2) => 178.8f,
(1, 0) => 64.8f, (1, 1) => 135.1f, (1, 2) => 184.4f,
(2, 0) => 46.0f, (2, 1) => 84.7f, (2, 2) => 146.6f,
(3, 0) => 42.1f, (3, 1) => 84.7f, (_, _) => 146.6f,
};
/// <summary>Every dash is 11 frames at 30: the travel runs from frame 1 to 10; `phase_in` is frame 2, `phase_out` frame 8.</summary>
const float DashLast = 10f / 30f;
const float PhaseInAt = 2f / 30f;
const float PhaseOutAt = 8f / 30f;
// ══ state ════════════════════════════════════════════════════════════════
ZombieAI _ai;
Health _hp;
SkinnedModelRenderer _body;
AvogadroCrackle _crackle;
float _nextTeleport, _nextZap, _nextShock, _nextThink, _stunUntil, _meleeLockUntil, _deathAt, _nextJump;
// the jump in progress: where he lands and who he came for
Vector3 _jumpTo;
GameObject _jumpFor;
// his target's ground speed, measured frame to frame (a client's body only arrives as positions) — the bolt's lead
GameObject _tracked;
Vector3 _trackedAt, _trackedVel;
bool _stunPending, _enraged, _phased, _shielded, _deadHandled, _hidDead;
bool _sliding;
Vector3 _slideFrom, _slideTo;
float _slideStart;
SoundHandle _hum;
float _nextHumTry;
/// <summary>A timed beat inside one of his moves — data, not a lambda, so a hotload mid-move cannot strand a closure.</summary>
enum Beat { ZapFire, PhaseIn, PhaseOut, JumpLand, JumpRetarget }
readonly List<(float At, Beat What)> _pending = new();
public bool Enraged => _enraged;
public bool Phased => _phased;
/// <summary>Which way his BODY faces — the object's rotation carries the rig's yaw correction (`ZombieAI.ModelTurn`).</summary>
Vector3 Facing => _ai.IsValid() ? (WorldRotation * _ai.ModelTurn.Inverse).Forward.WithZ( 0 ).Normal : WorldRotation.Forward;
float Size => MathF.Max( 0.1f, WorldScale.x );
/// <summary>Where his voice comes from: head height (his model stands 74 tall).</summary>
Vector3 Voice => WorldPosition + Vector3.Up * 64f * Size;
protected override void OnStart()
{
_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
_hp = Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
_body = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );
// ⚠️ EVERY MACHINE: the electricity on his body is each machine's own drawing
_crackle = Components.GetOrCreate<AvogadroCrackle>();
var now = Time.Now;
_nextZap = now + ZapFirst; // upstream's `LastShot = CurTime() + 10`; 6 since the "more dangerous" pass
_nextJump = now + 8f;
_nextTeleport = now + 5f; // `LastTeleport`, `ShockWave` = CurTime() + 5
_nextShock = now + 5f;
if ( _ai.IsValid() && !NZGame.IsClient )
{
_ai.MaxHitDamage = MaxHit;
// ⛔ HE HAS NONE OF THE WALKER'S CLIMB CLIPS, and an unknown sequence is the bind pose (Oberon's note). His jump across
// is his traversal clip upstream (`ACT_JUMP`), in place here since `boss_port.py` strips its travel.
_ai.ClimbClipOverride = "nz_avo_jump_across_120";
}
if ( !NZGame.IsClient )
Log.Info( $"[nz-avo] spawned — {_hp?.Max ?? 0f:0} hp · melee x{MeleeScale:0.##} (one per {MeleeLock:0.#}s, stuns),"
+ $" else x{BulletScale:0.##} · hitboxes {_ai?.HitboxCount ?? 0}" );
}
protected override void OnUpdate()
{
if ( !_ai.IsValid() ) return;
if ( _ai.State == ZombieState.Dead )
{
OnDeath();
TickDeadHide();
return;
}
TickHum();
// ── the host decides ──
if ( _ai.IsPuppet ) return;
_ai.MaxHitDamage = MaxHit;
TickShield();
TickStun();
TickEnrage();
TickPending();
TickSlide();
TrackTarget();
TickMoves();
}
protected override void OnDestroy()
{
if ( _hum.IsValid() ) _hum.Stop();
}
// ══ every machine ════════════════════════════════════════════════════════
/// <summary>
/// His hum (`move_loop.wav`, which loops on its own `smpl` chunk), following him — upstream's ambient loop. Started again
/// whenever it is not playing and you are near enough to hear it: a loop started out of earshot is culled at the start
/// (`NZSound.CullOutOfEarshot`) and would otherwise never come back.
/// </summary>
void TickHum()
{
if ( Time.Now < _nextHumTry ) return;
_nextHumTry = Time.Now + 1f;
if ( _hum.IsValid() && _hum.IsPlaying ) return;
if ( NZSound.Ear.Distance( WorldPosition ) > 1300f ) return;
_hum = NZSound.Play( "nz.avo.loop", WorldPosition + Vector3.Up * 40f * Size );
_ai.TrackVoice( _hum );
}
/// <summary>
/// Dead, on every machine: shown again if he died phased out, his electricity fading over the exit, his hum stopped.
/// </summary>
void OnDeath()
{
if ( _deadHandled ) return;
_deadHandled = true;
_deathAt = Time.Now;
_pending.Clear();
_sliding = false;
_stunPending = false;
// ⚠️ NEVER A HIDDEN BODY FOR HIS EXIT: killed mid-dash (a nuke, a command), each machine shows its own copy again
_phased = false;
MargwaFx.SetHidden( GameObject, false );
if ( _crackle.IsValid() ) { _crackle.Phased = false; _crackle.Fading = true; }
if ( _hum.IsValid() ) _hum.Stop();
// ⚠️ THE HOST CLEARS HIM AWAY THE MOMENT THE EXIT ENDS: upstream removes him there, and a corpse 520 units up has
// nothing to lie on
if ( !_ai.IsPuppet ) _ai.CorpseLinger = 0.2f;
}
/// <summary>
/// Gone at the end of his exit: `nz_avo_exit` lifts him 520 units into the air and upstream removes him there, so no corpse
/// lies on the floor — each machine hides its own copy once the clip is over.
/// </summary>
void TickDeadHide()
{
if ( _hidDead || Time.Now < _deathAt + 126f / 30f - 0.1f ) return;
_hidDead = true;
MargwaFx.SetHidden( GameObject, true );
}
// ══ the host ═════════════════════════════════════════════════════════════
/// <summary>Untouchable while he arrives (upstream's `SetInvulnerable` over `nz_avo_arrive`), while phased out, and stunned.</summary>
void TickShield()
{
var want = _ai.State == ZombieState.Spawning || _phased || Time.Now < _stunUntil;
if ( want == _shielded || !_hp.IsValid() ) return;
_shielded = want;
_hp.Invulnerable = want;
}
/// <summary>A counted melee hit asked for the stun (`ScaleFor`); it starts here, outside the damage call.</summary>
void TickStun()
{
if ( !_stunPending ) return;
_stunPending = false;
if ( _sliding || _phased ) return;
if ( PlayShared( "nz_avo_pain_long", StunSeconds ) )
{
_stunUntil = Time.Now + StunSeconds;
_pending.RemoveAll( p => p.What == Beat.ZapFire );
}
}
void TickEnrage()
{
if ( _enraged || !_hp.IsValid() || _hp.Max <= 0f ) return;
if ( _ai.State == ZombieState.Spawning || _hp.Current / _hp.Max > EnrageAt ) return;
_enraged = true;
_ai.SpeedOverride = MathF.Max( 1f, RunSpeed * Size );
_ai.RepickAnimations();
NZSound.PlayShared( "nz.avo.damaged", Voice );
Log.Info( $"[nz-avo] UNDER {EnrageAt * 100f:0}% — he runs, {_ai.SpeedOverride:0} u/s" );
}
void TickMoves()
{
var state = _ai.State;
if ( state is not (ZombieState.Chasing or ZombieState.Attacking) ) return;
if ( _sliding || _phased ) return;
var target = _ai.Target;
if ( !target.IsValid() ) return;
var now = Time.Now;
// ── the shockwave: anyone close ──
if ( now >= _nextShock && AnyPlayerWithin( ShockRange * Size ) )
{
Shockwave();
return;
}
// ⚠️ UPSTREAM'S AI WAITS OUT A SWING (`IsAttackBlocked`): the dash and the bolt only from a walk
if ( state != ZombieState.Chasing || now < _nextThink ) return;
_nextThink = now + ThinkEvery;
// ── the jump: to a player far from him, any of them ──
if ( now >= _nextJump && StartJump() ) return;
if ( Game.Random.Int( 0, 1 ) == 0 )
{
if ( now >= _nextTeleport ) StartTeleport();
}
else if ( now >= _nextZap && WorldPosition.Distance( target.WorldPosition ) <= ZapRange && CanSee( target ) )
{
StartZap();
}
}
bool AnyPlayerWithin( float range )
{
foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
{
if ( !p.IsValid() || (p.IsDown || p.DownedNet) || (p.IsOutOfRound || p.OutOfRoundNet) ) continue;
var d = p.WorldPosition - WorldPosition;
if ( d.WithZ( 0 ).Length <= range && MathF.Abs( d.z ) < 80f ) return true;
}
return false;
}
void Queue( float after, Beat what ) => _pending.Add( (Time.Now + after, what) );
void TickPending()
{
for ( int i = _pending.Count - 1; i >= 0; i-- )
{
if ( i >= _pending.Count ) continue;
var p = _pending[i];
if ( Time.Now < p.At ) continue;
_pending.RemoveAt( i );
Run( p.What );
}
}
void Run( Beat what )
{
switch ( what )
{
case Beat.ZapFire:
{
var target = _ai.Target;
if ( target.IsValid() ) Face( target.WorldPosition );
SwapClip( "nz_avo_ranged_attack_end" );
NZSound.PlayShared( "nz.avo.zap", Voice );
// ⚠️ FROM HIS HEAD, as upstream's shot leaves his `EyePos`, at his target's chest (`GetPos() + 50 up`) — and since the
// "more dangerous" pass, AHEAD of them: their ground speed times the bolt's flight time, up to `LeadMax`
var from = _body.IsValid() && _body.TryGetBoneTransform( "j_head", out var head )
? head.Position + Facing * 8f * Size
: Voice + Facing * 8f;
var to = target.IsValid() ? target.WorldPosition + Vector3.Up * 50f : from + Facing * 500f;
if ( target.IsValid() && target == _tracked )
{
var lead = _trackedVel.WithZ( 0 ) * (from.Distance( to ) / MathF.Max( 1f, ZapSpeed ));
if ( lead.Length > LeadMax ) lead = lead.Normal * LeadMax;
to += lead;
}
// under half health, three in a fan
var aim = to - from;
var shots = _enraged ? 3 : 1;
for ( int i = 0; i < shots; i++ )
{
var yaw = shots == 1 ? 0f : (i - 1) * FanSpread;
var dir = Rotation.FromYaw( yaw ) * aim;
AvogadroBolt.Launch( Scene, from, dir, GameObject, SwipeDamage() * ZapWeight,
shock: ShockSeconds, fieldSeconds: FieldSeconds, fieldRadius: FieldRadius * Size, fieldDps: SwipeDamage() * FieldDps );
}
break;
}
case Beat.PhaseIn:
NZSound.PlayShared( "nz.avo.warp", WorldPosition + Vector3.Up * 40f * Size );
break;
case Beat.PhaseOut:
Phase( false );
NZSound.PlayShared( "nz.avo.tele", WorldPosition + Vector3.Up * 40f * Size );
break;
case Beat.JumpLand:
{
// out of the lightning beside the one he came for, facing them, a bolt ready a second later
WarpTo( _jumpTo );
if ( _jumpFor.IsValid() ) Face( _jumpFor.WorldPosition );
Phase( false );
NZSound.PlayShared( "nz.avo.tele", _jumpTo + Vector3.Up * 40f * Size );
NZSound.PlayShared( "nz.avo.damaged", Voice );
_nextZap = MathF.Min( _nextZap, Time.Now + 1f );
break;
}
case Beat.JumpRetarget:
// ⚠️ AFTER THE HOLD ENDS: `ForceRetarget` sets him chasing, which would cut a special in half
if ( _ai.State != ZombieState.Special ) _ai.ForceRetarget();
break;
}
}
/// <summary>What one of his swipes deals on this round — the bolt's and the shockwave's yardstick.</summary>
float SwipeDamage()
{
int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
return ZombieStats.AttackDamageForRound( round ) * (_ai?.Variant?.DamageMultiplier ?? 1.5f) * Difficulty.ZombieDamage;
}
// ── the bolt ──
/// <summary>Wind up the bolt: `ranged_attack_intro` (21 frames), the bolt at its end, then `ranged_attack_end`. THE HOST.</summary>
bool StartZap()
{
var target = _ai.Target;
if ( !target.IsValid() ) return false;
Face( target.WorldPosition );
if ( !PlayShared( "nz_avo_ranged_attack_intro", 21f / 30f + 31f / 30f ) ) return false;
_nextZap = Time.Now + 52f / 30f + Game.Random.Float( ZapGapMin, MathF.Max( ZapGapMin, ZapGapMax ) );
Queue( 21f / 30f, Beat.ZapFire );
return true;
}
// ── the jump ──
/// <summary>
/// Lightning to a player at least <see cref="JumpFrom"/> away — any of them, at random — landing 150-300 from them after a
/// <see cref="JumpWarn"/> s warning on the spot. THE HOST. False when there is nobody to jump to, or nowhere to land.
/// </summary>
bool StartJump()
{
var now = Time.Now;
_nextJump = now + 2f; // nobody far enough, or no floor near them: look again in a moment
var far = Scene.GetAllComponents<NZPlayer>()
.Where( p => p.IsValid() && !(p.IsDown || p.DownedNet) && !(p.IsOutOfRound || p.OutOfRoundNet) && p.WorldPosition.Distance( WorldPosition ) >= JumpFrom * Size )
.ToList();
if ( far.Count == 0 ) return false;
var who = far[Game.Random.Int( 0, far.Count - 1 )];
var spot = JumpSpot( who );
if ( spot is null ) return false;
// ⚠️ ANY CLIP WILL DO: he is hidden for the whole of it. The hold is the jump; the landing is a beat of its own.
if ( !PlayShared( "nz_avo_teleport_forward_short", JumpWarn + 0.05f ) ) return false;
_nextJump = now + (_enraged
? Game.Random.Float( AngryJumpGapMin, MathF.Max( AngryJumpGapMin, AngryJumpGapMax ) )
: Game.Random.Float( JumpGapMin, MathF.Max( JumpGapMin, JumpGapMax ) ));
_jumpTo = spot.Value;
_jumpFor = who.GameObject;
Phase( true );
NZNet.AvogadroJump( WorldPosition, _jumpTo, JumpWarn );
NZSound.PlayShared( "nz.avo.warp", WorldPosition + Vector3.Up * 40f * Size );
NZSound.PlayShared( "nz.avo.warp", _jumpTo + Vector3.Up * 40f * Size );
Queue( JumpWarn, Beat.JumpLand );
Queue( JumpWarn + 0.12f, Beat.JumpRetarget );
Log.Info( $"[nz-avo] JUMP to {who.GameObject.Name} — {WorldPosition.Distance( who.WorldPosition ):0}u away, landing"
+ $" {_jumpTo.Distance( who.WorldPosition ):0}u from them in {JumpWarn:0.##}s" );
return true;
}
/// <summary>
/// Where to land near <paramref name="who"/>: on the navmesh, 150-300 from them, on their floor, in their sight when it can
/// (a few tries at that first, then any such spot). Null when there is none.
/// </summary>
Vector3? JumpSpot( NZPlayer who )
{
var nav = Scene.NavMesh;
if ( nav is null ) return null;
var center = who.WorldPosition;
Vector3? fallback = null;
for ( int i = 0; i < 16; i++ )
{
var p = nav.GetRandomPoint( center, JumpFar * Size );
if ( !p.HasValue ) continue;
var flat = (p.Value - center).WithZ( 0 ).Length;
if ( flat < JumpNear * Size || flat > JumpFar * Size || MathF.Abs( p.Value.z - center.z ) > 64f ) continue;
var eye = center + Vector3.Up * 60f;
var tr = Scene.Trace.Ray( p.Value + Vector3.Up * 50f, eye ).WithoutTags( "zombie", "player", "trigger" ).Run();
if ( !tr.Hit || tr.HitPosition.Distance( eye ) < 30f ) return p.Value;
fallback ??= p.Value;
}
return fallback;
}
/// <summary>Put him there at once, the agent with him, as a snap on every machine (`MargwaBoss.WarpTo`).</summary>
void WarpTo( Vector3 to )
{
Place( to );
// ⛔ A SNAP, NOT A SLIDE: without this every other machine interpolates the body across the map
GameObject.Network.ClearInterpolation();
}
/// <summary>His target's ground speed, frame to frame, smoothed — what the bolt leads by. THE HOST.</summary>
void TrackTarget()
{
var t = _ai.Target;
if ( !t.IsValid() ) { _tracked = null; return; }
var pos = t.WorldPosition;
if ( t != _tracked )
{
_tracked = t;
_trackedAt = pos;
_trackedVel = Vector3.Zero;
return;
}
var dt = MathF.Max( 0.001f, Time.Delta );
var v = (pos - _trackedAt) / dt;
_trackedAt = pos;
if ( v.Length > 1200f ) return; // a teleport or a respawn, not a run
_trackedVel = Vector3.Lerp( _trackedVel, v, 1f - MathF.Exp( -8f * dt ) );
}
// ── the dash ──
/// <summary>One of the twelve dashes, at random. THE HOST.</summary>
bool StartTeleport()
{
_nextTeleport = Time.Now + TeleportGap; // ⚠️ spent even when there is no room, as upstream's is
int d = Game.Random.Int( 0, 3 ), l = Game.Random.Int( 0, 2 );
var f = Facing;
var left = new Vector3( -f.y, f.x, 0f );
var way = d switch { 0 => f, 1 => -f, 2 => left, _ => -left };
var landing = Landing( WorldPosition + way * DashTravel( d, l ) * Size );
if ( landing is null ) return false;
if ( !PlayShared( DashClip( d, l ), DashLast + 0.05f ) ) return false;
Phase( true );
_slideFrom = WorldPosition;
_slideTo = landing.Value;
_slideStart = Time.Now;
_sliding = true;
Queue( PhaseInAt, Beat.PhaseIn );
Queue( PhaseOutAt, Beat.PhaseOut );
return true;
}
/// <summary>Where a dash may end: on the navmesh near the point, on his floor, with no wall between. Else null.</summary>
Vector3? Landing( Vector3 want )
{
var nav = Scene.NavMesh;
var p = nav?.GetClosestPoint( want );
if ( !p.HasValue ) return null;
var at = p.Value;
if ( (at - want).WithZ( 0 ).Length > 32f * Size || MathF.Abs( at.z - WorldPosition.z ) > 48f ) return null;
var up = Vector3.Up * 40f * Size;
var tr = Scene.Trace.Ray( WorldPosition + up, at + up ).IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "zombie", "player", "trigger" ).Run();
return tr.Hit ? null : at;
}
/// <summary>Carry him along the dash, frame 1 to 10, eased — the clip's own travel, which `boss_port.py` took off it.</summary>
void TickSlide()
{
if ( !_sliding ) return;
var t = Math.Clamp( (Time.Now - _slideStart - 1f / 30f) / (DashLast - 1f / 30f), 0f, 1f );
Place( Vector3.Lerp( _slideFrom, _slideTo, t * t * (3f - 2f * t) ) );
if ( t < 1f ) return;
_sliding = false;
Place( _slideTo );
}
/// <summary>Put him somewhere, the agent with him (`MargwaBoss.WarpTo`'s pair, without the snap: the slide is the look).</summary>
void Place( Vector3 at )
{
WorldPosition = at;
var agent = Components.Get<NavMeshAgent>( FindMode.EverythingInSelfAndDescendants );
if ( agent.IsValid() ) agent.SetAgentPosition( at );
}
/// <summary>Phase out or in, on every machine. THE HOST.</summary>
void Phase( bool hidden )
{
_phased = hidden;
NZNet.AvogadroPhase( GameObject.Id, hidden );
}
// ── the shockwave ──
/// <summary>
/// Upstream's `Explode(20)`: every player within <see cref="ShockRadius"/> hurt (a little) and thrown, every zombie
/// knocked over, and he recoils. THE HOST.
/// </summary>
void Shockwave()
{
_nextShock = Time.Now + ShockGap;
var at = WorldPosition + Vector3.Up * 50f * Size;
var radius = ShockRadius * Size;
NZSound.PlayShared( "nz.avo.shock", at );
NZNet.AvogadroShock( at, radius, ShockPush );
var damage = MathF.Min( SwipeDamage() * ShockWeight, MaxHit );
foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
{
if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) continue;
var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
if ( !hp.IsValid() || hp.IsDead ) continue;
var to = p.WorldPosition + Vector3.Up * 40f;
if ( to.Distance( at ) > radius ) continue;
var tr = Scene.Trace.Ray( at, to ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "zombie", "player", "trigger" ).Run();
if ( tr.Hit && tr.HitPosition.Distance( to ) > 24f ) continue;
hp.Apply( damage, false, GameObject, blast: true, blastAt: at );
}
// upstream flings everything solid near him; a zombie here goes over backwards (Shockwave's own fall)
foreach ( var z in Scene.GetAllComponents<ZombieAI>().ToList() )
{
if ( !z.IsValid() || z == _ai || z.State == ZombieState.Dead ) continue;
if ( z.WorldPosition.Distance( WorldPosition ) > radius ) continue;
z.Knockback( WorldPosition, 110f, 1.6f, 0.6f );
}
// he recoils, upstream's `DoSpecialAnimation("nz_avo_pain_med")`
PlayShared( "nz_avo_pain_med", 18f / 30f );
}
// ── clips ──
/// <summary>`ZombieAI.PlaySpecial`, and the same clip on every watching machine — `PlaySpecial` does not relay.</summary>
bool PlayShared( string clip, float seconds )
{
if ( !_ai.PlaySpecial( clip, seconds ) ) return false;
if ( Networking.IsActive ) NZNet.ZombieClip( GameObject.Id, clip, 1f );
return true;
}
/// <summary>
/// The next clip of a move already running (the bolt's intro into its end): written straight to the renderer, which a
/// special owns (`ZombieAI.UpdateAnimation` leaves it alone), and relayed.
/// </summary>
void SwapClip( string clip )
{
if ( _ai.State != ZombieState.Special || !_body.IsValid() ) return;
_body.Sequence.Name = clip;
_body.Sequence.Time = 0f;
if ( Networking.IsActive ) NZNet.ZombieClip( GameObject.Id, clip, 1f );
}
/// <summary>Turn to face a point — his own facing, the rig's yaw correction included (`ZombieAI.FaceMovement`'s rule).</summary>
void Face( Vector3 at )
{
var dir = (at - WorldPosition).WithZ( 0 );
if ( dir.Length < 1f || !_ai.IsValid() ) return;
WorldRotation = Rotation.LookAt( dir.Normal, Vector3.Up ) * _ai.ModelTurn;
}
bool CanSee( GameObject target )
{
var from = Voice;
var to = target.WorldPosition + Vector3.Up * 50f;
var tr = Scene.Trace.Ray( from, to ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "zombie", "trigger" ).Run();
return !tr.Hit || tr.HitPosition.Distance( to ) < 40f;
}
// ══ the damage table ═════════════════════════════════════════════════════
/// <summary>
/// The multiplier for one hit. THE HOST (`Health.OnDamage`).
///
/// ⛔ IT MUTATES, SO EXACTLY ONCE PER HIT, the helmet's rule: a counted melee hit starts the lock and asks for the stun.
/// </summary>
public float ScaleFor( bool melee, GameObject attacker )
{
if ( _deadHandled ) return 1f;
if ( _shielded ) return 0f;
if ( !melee ) return MathF.Max( 0f, BulletScale );
// a knife inside the lock does nothing, upstream's `ScaleDamage(0)` until `LastStun`
if ( Time.Now < _meleeLockUntil ) return 0f;
_meleeLockUntil = Time.Now + MeleeLock;
_stunPending = true;
NZSound.PlayShared( "nz.avo.pain", Voice );
NZSound.PlayShared( "nz.avo.damaged", Voice );
Log.Info( $"[nz-avo] KNIFED by {(attacker.IsValid() ? attacker.Name : "?")} — x{MeleeScale:0.##}, stunned {StunSeconds:0.#}s,"
+ $" next knife counts in {MeleeLock:0.#}s" );
return MathF.Max( 0f, MeleeScale );
}
/// <summary>The scale for a hit on this object: Avogadro's table, or 1 when it is not him (`Health.OnDamage`).</summary>
public static float ScaleOn( GameObject victim, bool melee, GameObject attacker )
{
if ( !victim.IsValid() ) return 1f;
var a = victim.Components.Get<AvogadroBoss>( FindMode.EverythingInSelfAndAncestors );
return a.IsValid() ? a.ScaleFor( melee, attacker ) : 1f;
}
// ══ diagnostics ══════════════════════════════════════════════════════════
static IEnumerable<AvogadroBoss> All
=> Game.ActiveScene?.GetAllComponents<AvogadroBoss>() ?? Enumerable.Empty<AvogadroBoss>();
/// <summary>`nz_avo` — every Avogadro alive, and the table.</summary>
[ConCmd( "nz_avo" )]
public static void Report()
{
Log.Info( $"[nz-avo] table — melee x{MeleeScale:0.##} once per {MeleeLock:0.#}s (stun {StunSeconds:0.#}s), else x{BulletScale:0.##}"
+ $" · dash every {TeleportGap:0.#}s · jump every {JumpGapMin:0}-{JumpGapMax:0}s ({AngryJumpGapMin:0}-{AngryJumpGapMax:0} angry)"
+ $" to a player {JumpFrom:0}u+ away, warning {JumpWarn:0.##}s"
+ $" · bolt every {ZapGapMin:0}-{ZapGapMax:0}s, {ZapRange:0}u, x{ZapWeight:0.##}, daze {ZapDaze:0.#}s, shock {ShockSeconds:0.#}s,"
+ $" lead up to {LeadMax:0}u, fan of 3 angry, patch {FieldSeconds:0.#}s/{FieldRadius:0}u/x{FieldDps:0.##}/s"
+ $" · shock under {ShockRange:0}u, {ShockRadius:0}u, x{ShockWeight:0.##}, every {ShockGap:0.#}s · runs under {EnrageAt * 100f:0}%"
+ $" · hit cap {MaxHit:0}" );
var list = All.ToList();
Log.Info( $"[nz-avo] {list.Count} alive" );
foreach ( var a in list )
Log.Info( $"[nz-avo] hp {a._hp?.Current ?? 0f:0}/{a._hp?.Max ?? 0f:0} · state {a._ai?.State} · speed {a._ai?.MoveSpeed ?? 0f:0} u/s"
+ $"{(a._enraged ? " (running)" : "")}{(a._phased ? " · PHASED" : "")}{(Time.Now < a._stunUntil ? " · STUNNED" : "")}"
+ $" · hitboxes {a._ai?.HitboxCount ?? 0}" );
}
/// <summary>`nz_avo_zap` — every Avogadro throws a bolt at its target now.</summary>
[ConCmd( "nz_avo_zap" )]
public static void ZapCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-avo] the host decides" ); return; }
var n = All.Count( a => a._ai.IsValid() && a.StartZap() );
Log.Info( $"[nz-avo] {n} bolt(s) — {ZapSpeed:0} u/s, {ZapRadius:0}u blast, daze {ZapDaze:0.#}s" );
}
/// <summary>`nz_avo_teleport` — every Avogadro dashes now (a random one of the twelve).</summary>
[ConCmd( "nz_avo_teleport" )]
public static void TeleportCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-avo] the host decides" ); return; }
var n = All.Count( a => a._ai.IsValid() && a.StartTeleport() );
Log.Info( $"[nz-avo] {n} dash(es) · a dash with no room is skipped (run it again)" );
}
/// <summary>`nz_avo_jump` — every Avogadro jumps to a player now (one at least `JumpFrom` away).</summary>
[ConCmd( "nz_avo_jump" )]
public static void JumpCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-avo] the host decides" ); return; }
var n = All.Count( a => a._ai.IsValid() && a._ai.State == ZombieState.Chasing && a.StartJump() );
Log.Info( $"[nz-avo] {n} jump(s) · needs a player {JumpFrom:0}u+ from him, and him walking (not mid-move)" );
}
/// <summary>`nz_avo_shock` — every Avogadro lets the shockwave go now.</summary>
[ConCmd( "nz_avo_shock" )]
public static void ShockCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-avo] the host decides" ); return; }
var n = 0;
foreach ( var a in All.Where( a => a._ai.IsValid() ).ToList() ) { a.Shockwave(); n++; }
Log.Info( $"[nz-avo] {n} shockwave(s) · {ShockRadius:0}u" );
}
/// <summary>`nz_avo_stun` — every Avogadro takes the knife's stun now (no damage, no lock).</summary>
[ConCmd( "nz_avo_stun" )]
public static void StunCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-avo] the host decides" ); return; }
foreach ( var a in All ) a._stunPending = true;
Log.Info( $"[nz-avo] stunned for {StunSeconds:0.#}s" );
}
/// <summary>`nz_avo_set <key> <value>` — retune him live.</summary>
[ConCmd( "nz_avo_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "bullet": BulletScale = value; break;
case "melee": MeleeScale = value; break;
case "lock": MeleeLock = value; break;
case "stun": StunSeconds = value; break;
case "dash": TeleportGap = value; break;
case "zapmin": ZapGapMin = value; break;
case "zapmax": ZapGapMax = value; break;
case "zaprange": ZapRange = value; break;
case "zapspeed": ZapSpeed = value; break;
case "zapweight": ZapWeight = value; break;
case "daze": ZapDaze = value; break;
case "shockrange": ShockRange = value; break;
case "shockradius": ShockRadius = value; break;
case "shockweight": ShockWeight = value; break;
case "shockgap": ShockGap = value; break;
case "push": ShockPush = value; break;
case "enrage": EnrageAt = value; break;
case "run": RunSpeed = value; break;
case "hitcap": HitCap = value; break;
case "jumpmin": JumpGapMin = value; break;
case "jumpmax": JumpGapMax = value; break;
case "jumpfrom": JumpFrom = value; break;
case "warn": JumpWarn = value; break;
case "fan": FanSpread = value; break;
case "lead": LeadMax = value; break;
case "shocked": ShockSeconds = value; break;
case "field": FieldSeconds = value; break;
case "fieldradius": FieldRadius = value; break;
case "fielddps": FieldDps = value; break;
default:
Log.Info( "[nz-avo] nz_avo_set <bullet|melee|lock|stun|dash|zapmin|zapmax|zaprange|zapspeed|zapweight|daze|shockrange"
+ "|shockradius|shockweight|shockgap|push|enrage|run|hitcap|jumpmin|jumpmax|jumpfrom|warn|fan|lead|shocked|field"
+ "|fieldradius|fielddps> <value>" );
return;
}
Log.Info( $"[nz-avo] {key} = {value:0.###}" );
Report();
}
}
/// <summary>
/// Avogadro's bolt (2026-10-06), upstream's `nz_avo_shot`: a ball of lightning flying straight at where its target was, at
/// 1250 u/s — no gravity, no homing — through zombies, until it meets a wall or a player. Then a 50-unit blast: damage to
/// the players in it (more at the middle) and a 2 s daze.
///
/// ⚠️ THE HOST FLIES IT; EVERYONE ELSE WATCHES. `Launch` network-spawns the object, so its transform replicates, and the look
/// (a light at the head, sparks left in its wake) is built on every machine in <see cref="OnStart"/>. ⚠️ A 3 s LIFE: upstream's
/// has none and flies until it meets something.
/// </summary>
public sealed class AvogadroBolt : Component
{
static float? _life, _touch;
/// <summary>Seconds before it fizzles: 3 (3750 units at its speed).</summary>
public static float Life { get => _life ?? 3f; set => _life = value; }
/// <summary>How near a player's middle it must pass to hit: 24.</summary>
public static float Touch { get => _touch ?? 24f; set => _touch = value; }
/// <summary>Who threw it: the damage carries his name.</summary>
public GameObject Owner { get; set; }
/// <summary>Its damage at the middle of the blast, sized on the host at launch, so his death mid-flight changes nothing.</summary>
public float Damage { get; set; }
/// <summary>This bolt's blast, daze and speed — Avogadro's statics unless a thrower set its own (the Panzer's taser).</summary>
public float Radius { get; set; }
public float Daze { get; set; } = -1f;
public float Speed { get; set; }
/// <summary>Seconds its blast leaves a player SHOCKED (no shooting), and the electric patch it lays — 0, none (the Panzer's).</summary>
public float Shock { get; set; }
public float FieldSeconds { get; set; }
public float FieldRadius { get; set; }
public float FieldDps { get; set; }
Vector3 _dir;
float _dieAt, _nextSpark;
bool _done;
GameObject _lightGo;
/// <summary>Throw one. THE HOST; a client only watches.</summary>
public static AvogadroBolt Launch( Scene scene, Vector3 from, Vector3 dir, GameObject owner, float damage,
float radius = 0f, float daze = -1f, float speed = 0f, float shock = 0f, float fieldSeconds = 0f, float fieldRadius = 0f,
float fieldDps = 0f )
{
if ( !scene.IsValid() || NZGame.IsClient ) return null;
var go = new GameObject( true, "nz_avo_bolt" );
go.Flags |= GameObjectFlags.NotSaved;
go.WorldPosition = from;
go.WorldRotation = Rotation.LookAt( dir.Length > 0.01f ? dir.Normal : Vector3.Forward, Vector3.Up );
var b = go.Components.Create<AvogadroBolt>();
b.Owner = owner;
b.Damage = damage;
b.Radius = radius;
b.Daze = daze;
b.Speed = speed;
b.Shock = shock;
b.FieldSeconds = fieldSeconds;
b.FieldRadius = fieldRadius;
b.FieldDps = fieldDps;
// ⚠️ UNCONDITIONAL (a no-op solo), and after the component is configured, since the spawn is serialised now
go.NetworkSpawn();
return b;
}
protected override void OnStart()
{
_dir = WorldRotation.Forward;
_dieAt = Time.Now + Life;
_lightGo = new GameObject { Parent = GameObject, Name = "light" };
_lightGo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked;
var light = _lightGo.Components.Create<PointLight>();
light.LightColor = AvogadroFx.Blue * 5f;
light.Radius = 200f;
light.Shadows = false;
// ⚠️ A BALL YOU CAN SEE (2026-10-07). The user: *"AVOGADRO is great, but i cannot see the projectile"* — it was this light
// and the hair-thin sparks below. A glowing blue head with a fading trail, every machine (the Panzer's taser too)
BossShotLook.On( GameObject, AvogadroFx.Blue, 26f, trailWidth: 8f, trailSeconds: 0.14f, brightness: 4f );
}
protected override void OnUpdate()
{
// ── every machine: sparks left in its wake ──
if ( !_done && Time.Now >= _nextSpark )
{
_nextSpark = Time.Now + 0.025f;
var c = WorldPosition;
AvogadroSpark.Between( c + Vector3.Random * 5f, c + Vector3.Random.Normal * Game.Random.Float( 9f, 16f ),
Game.Random.Float( 0.06f, 0.12f ), 0.16f );
}
if ( _done || NZGame.IsClient ) return;
var from = WorldPosition;
var next = from + _dir * (Speed > 0f ? Speed : AvogadroBoss.ZapSpeed) * Time.Delta;
// ── a wall ends it; zombies it passes through, as upstream's trigger does ──
var wall = Scene.Trace.Ray( from, next ).IgnoreGameObjectHierarchy( GameObject )
.WithoutTags( "zombie", "player", "trigger", "ragdoll" ).Run();
if ( wall.Hit )
{
End( wall.HitPosition - _dir * 4f );
return;
}
// ── a player it passes ──
foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
{
if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) continue;
if ( DistanceToBody( p, from, next ) > Touch ) continue;
End( next );
return;
}
WorldPosition = next;
if ( Time.Now >= _dieAt ) End( next );
}
/// <summary>How near the segment this frame passes to a player's body (a line from shin to head).</summary>
static float DistanceToBody( NZPlayer p, Vector3 a, Vector3 b )
{
var lo = p.WorldPosition + Vector3.Up * 12f;
var hi = p.WorldPosition + Vector3.Up * 64f;
var best = float.MaxValue;
for ( int i = 0; i <= 4; i++ )
{
var q = Vector3.Lerp( a, b, i / 4f );
var z = Math.Clamp( q.z, lo.z, hi.z );
best = MathF.Min( best, q.Distance( new Vector3( lo.x, lo.y, z ) ) );
}
return best;
}
/// <summary>The blast. THE HOST; destroying a network object ends it everywhere.</summary>
void End( Vector3 at )
{
if ( _done ) return;
_done = true;
var radius = Radius > 0f ? Radius : AvogadroBoss.ZapRadius;
foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
{
if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) continue;
var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
if ( !hp.IsValid() || hp.IsDead ) continue;
var d = DistanceToBody( p, at, at );
if ( d > radius ) continue;
// ⚠️ A FLOOR OF 0.35 AT THE RIM: upstream measures to the body's centre, so a hit on the shoulder already read as 60%
var share = Math.Clamp( 1f - d / MathF.Max( 1f, radius ), 0.35f, 1f );
hp.Apply( MathF.Min( Damage * share, AvogadroBoss.MaxHit ), false, Owner.IsValid() ? Owner : GameObject,
blast: true, blastAt: at );
}
NZNet.AvogadroBoltLand( at, radius, Daze >= 0f ? Daze : AvogadroBoss.ZapDaze, Shock, FieldSeconds, FieldRadius, FieldDps );
NZSound.PlayShared( "nz.pop.deadwire.shock", at );
NZNet.ShakeAt( at, 0.35f, 300f );
GameObject.Destroy();
}
}