Component implementing the Astronaut boss zombie. Controls movement pacing, the grab/headbutt special (timing, misses -> anger, applying effects to the victim), death blast that kills nearby zombies, sound/animation cues, and console commands for diagnostics and tuning.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// The Astronaut — Moon's spaceman, ported 2026-10-06 from GMod nZombies' `nz_zombie_boss_astro` (GhostlyMoo's port of the BO3
/// remaster). The user picked him with Avogadro, the Director and the Panzer Soldat.
///
/// HIS ONE MOVE IS THE GRAB AND THE HEADBUTT, and it replaces the swing (`ZombieAI.MeleeDisabled`):
/// 1. In reach (<see cref="HeadbuttRange"/>), he grabs: you are dazed for 2 s (3 when he is angry) — upstream's
/// `NZAstroSlow`, which in GMod creates a status nothing reads; here it holds you in his reach, as it is meant to.
/// 2. The whole `nz_astro_headbutt` plays (2.4 s), facing you, the grab and the static swell on its frames 19 and 28.
/// ⚠️ QUICKER WITH EVERY MISS (the user, 2026-10-06: *"it must take a little less time to headbut every time it misses"*):
/// each miss since his last landed headbutt takes <see cref="MissQuicken"/> off its time — 2.4 s, 2.1, 1.8, 1.5 … — down to
/// <see cref="MinHeadbuttShare"/> of it (~1 s); a landed one puts it back to 2.4.
/// 3. At its end, if you are still in reach: the headbutt lands — ONE OF YOUR PERKS IS TAKEN, at random (the user's: *"it must
/// remove 1 random perk from the player when headbutting"*, BO1 Moon's steal), your health goes down to 90% of the match's
/// base health and no lower (upstream's `Health() - 90` against its base 100: it CANNOT kill), and the Gersch teleport's
/// crack. If you got away: `nz_astro_headbutt_release`, and a miss counted.
/// 4. THE THIRD MISS MAKES HIM ANGRY: he sprints (`nz_supersprint_lowg`, the moon's low-gravity bound, 225 u/s) until a
/// headbutt lands, which calms him. ⚠️ Upstream keeps the count and the anger in Lua GLOBALS shared by every astronaut;
/// here they are his own.
///
/// HE WALKS ONE OF TWO WALKS, AT ITS OWN SPEED (upstream's `UpdateMovementSpeed`): a creep at 17.9 u/s or a shamble at 45.9,
/// rolled once at spawn.
///
/// HIS DEATH IS A BLAST, NOT A CORPSE (upstream's `Explode`): every zombie within 250 dies, everyone near is thrown (no damage
/// to players), and he is gone. Bosses are spared here, which upstream does not say.
///
/// ⚠️ HOST AUTHORITY: the grab and the headbutt land on the owner's machine (`NZNet.AstronautGrab` / `AstronautHeadbutt`,
/// `AstronautFx`); the blast is Avogadro's ring and throw (`NZNet.AvogadroShock`, the same two sounds upstream shares).
/// </summary>
public sealed class AstronautBoss : Component
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1.
static float? _range, _floor, _grab, _angryGrab, _angrySpeed, _blastRadius, _blastPush, _walkSlow, _walkFast;
static int? _misses;
/// <summary>His reach, for the grab and for the headbutt's landing: 72, upstream's `AttackRange`.</summary>
public static float HeadbuttRange { get => _range ?? 72f; set => _range = value; }
/// <summary>Where the headbutt leaves you: 0.9 of the match's base health (upstream: 90 of 100).</summary>
public static float HeadbuttFloor { get => _floor ?? 0.9f; set => _floor = value; }
/// <summary>The grab's daze: 2 s, upstream's `NZAstroSlow(2)`; 3 s angry.</summary>
public static float GrabDaze { get => _grab ?? 2f; set => _grab = value; }
public static float AngryGrabDaze { get => _angryGrab ?? 3f; set => _angryGrab = value; }
/// <summary>Misses until he is angry: 3 (upstream's `trexarms > 2`).</summary>
public static int MissesToAnger { get => _misses ?? 3; set => _misses = value; }
/// <summary>His angry sprint: 224.8 u/s, `nz_supersprint_lowg`'s own ground speed.</summary>
public static float AngrySpeed { get => _angrySpeed ?? 224.8f; set => _angrySpeed = value; }
/// <summary>His death blast: 250 u, upstream's `FindInSphere(250)`; the throw 200 u/s out and up (upstream 175 out, 225 up).</summary>
public static float BlastRadius { get => _blastRadius ?? 250f; set => _blastRadius = value; }
public static float BlastPush { get => _blastPush ?? 200f; set => _blastPush = value; }
/// <summary>His two walks' own speeds: `nz_astro_walk_v1` 17.9, `nz_astro_walk_v2` 45.9.</summary>
public static float WalkSlow { get => _walkSlow ?? 17.9f; set => _walkSlow = value; }
public static float WalkFast { get => _walkFast ?? 45.9f; set => _walkFast = value; }
static float? _quicken, _quickest;
/// <summary>What each miss since his last landed headbutt takes off the next one's time: 0.15 (15%).</summary>
public static float MissQuicken { get => _quicken ?? 0.15f; set => _quicken = value; }
/// <summary>The quickest a headbutt gets, as a share of its own length: 0.4 (~1 s).</summary>
public static float MinHeadbuttShare { get => _quickest ?? 0.4f; set => _quickest = value; }
const float HeadbuttSeconds = 73f / 30f;
const float ReleaseSeconds = 27f / 30f;
// ══ state ════════════════════════════════════════════════════════════════
ZombieAI _ai;
Health _hp;
SkinnedModelRenderer _body;
float _walk;
int _missed;
/// <summary>Misses since his last landed headbutt — what quickens the next one. A landed one clears it.</summary>
int _streak;
/// <summary>The rate the headbutt now in play runs at (1 normally).</summary>
float _rate = 1f;
/// <summary>How fast his next headbutt plays: 1 / (1 - <see cref="MissQuicken"/>)^misses, at most 1 / <see cref="MinHeadbuttShare"/>.</summary>
float NextRate => 1f / MathF.Max( Math.Clamp( MinHeadbuttShare, 0.1f, 1f ),
MathF.Pow( 1f - Math.Clamp( MissQuicken, 0f, 0.9f ), _streak ) );
bool _angry, _paceApplied, _deadHandled;
GameObject _victim;
float _headbuttUntil;
SoundHandle _breath;
float _nextBreathTry;
enum Beat { Grab, Swell, Land }
readonly List<(float At, Beat What)> _pending = new();
public bool Angry => _angry;
public int Misses => _missed;
float Size => MathF.Max( 0.1f, WorldScale.x );
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 );
if ( NZGame.IsClient || !_ai.IsValid() ) return;
// ⛔ THE HEADBUTT IS HIS ONLY ATTACK, and it is not a swing: the AI's own melee stays off
_ai.MeleeDisabled = true;
// ⛔ NONE OF THE WALKER'S CLIMB CLIPS ARE ON HIS MODEL (Oberon's note); his own window climb is
_ai.ClimbClipOverride = "nz_barricade_trav_walk_1";
_walk = Game.Random.Int( 0, 1 ) == 0 ? WalkSlow : WalkFast;
// upstream's `bo3_astronaut_pulse` where he arrives, with the flux the variant's `SpawnSound` plays
ShockRing.FireShared( WorldPosition + Vector3.Up * 20f, 160f * Size, AvogadroFx.Blue );
Log.Info( $"[nz-astro] spawned — {_hp?.Max ?? 0f:0} hp · walks at {_walk:0.#} u/s · headbutt reach {HeadbuttRange:0}"
+ $" · angry after {MissesToAnger} misses ({AngrySpeed:0} u/s) · hitboxes {_ai.HitboxCount}" );
}
protected override void OnUpdate()
{
if ( !_ai.IsValid() ) return;
if ( _ai.State == ZombieState.Dead )
{
OnDeath();
return;
}
TickBreath();
if ( _ai.IsPuppet ) return;
TickPace();
TickPending();
TickHeadbutt();
}
protected override void OnDestroy()
{
if ( _breath.IsValid() ) _breath.Stop();
}
// ══ every machine ════════════════════════════════════════════════════════
/// <summary>His breathing in the helmet, looped (upstream's `breath.wav`), when you are near enough to hear it.</summary>
void TickBreath()
{
if ( Time.Now < _nextBreathTry ) return;
_nextBreathTry = Time.Now + 1f;
if ( _breath.IsValid() && _breath.IsPlaying ) return;
if ( NZSound.Ear.Distance( WorldPosition ) > 1000f ) return;
_breath = NZSound.Play( "nz.astro.breath", Voice );
_ai.TrackVoice( _breath );
}
/// <summary>
/// Dead, on every machine: he is gone at once — hidden here, and on the host shrunk to nothing and cleared away (the corpse
/// timer to zero) — after the blast (`Blast`, the host's).
/// </summary>
void OnDeath()
{
if ( _deadHandled ) return;
_deadHandled = true;
_pending.Clear();
if ( _breath.IsValid() ) _breath.Stop();
MargwaFx.SetHidden( GameObject, true );
if ( _ai.IsPuppet ) return;
Blast();
_ai.CorpseLinger = 0f;
WorldScale = Vector3.One * 0.01f;
GameObject.Network.ClearInterpolation();
}
// ══ the host ═════════════════════════════════════════════════════════════
/// <summary>His rolled walk, handed to the animation once he is out of the ground (`RepickAnimations` pushes the agent's speed).</summary>
void TickPace()
{
if ( _paceApplied || _ai.State is not (ZombieState.Chasing or ZombieState.Attacking) ) return;
_paceApplied = true;
if ( !_angry ) SetSpeed( _walk );
}
void SetSpeed( float speed )
{
_ai.SpeedOverride = MathF.Max( 1f, speed * Size );
_ai.RepickAnimations();
}
void TickHeadbutt()
{
// facing his victim through the whole headbutt, upstream's `FaceEnemy`
if ( _ai.State == ZombieState.Special && Time.Now < _headbuttUntil && _victim.IsValid() )
{
Face( _victim.WorldPosition );
return;
}
if ( _ai.State != ZombieState.Chasing ) return;
var target = _ai.Target;
var p = target.IsValid() ? target.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
if ( !p.IsValid() || (p.IsDown || p.DownedNet) ) return;
if ( !InReach( p ) ) return;
StartHeadbutt( p );
}
bool InReach( NZPlayer p )
{
var d = p.WorldPosition - WorldPosition;
return d.WithZ( 0 ).Length <= HeadbuttRange * Size && MathF.Abs( d.z ) < 72f;
}
/// <summary>The grab, then the headbutt. THE HOST.</summary>
bool StartHeadbutt( NZPlayer p )
{
Face( p.WorldPosition );
var rate = NextRate;
if ( !PlayShared( "nz_astro_headbutt", HeadbuttSeconds / rate + 0.05f, rate ) ) return false;
_rate = rate;
_victim = p.GameObject;
_headbuttUntil = Time.Now + HeadbuttSeconds / rate;
NZNet.AstronautGrab( p.GameObject.Id, _angry ? AngryGrabDaze : GrabDaze );
Queue( 19f / 30f / rate, Beat.Grab );
Queue( 28f / 30f / rate, Beat.Swell );
Queue( (HeadbuttSeconds - 1f / 30f) / rate, Beat.Land );
return true;
}
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.Grab:
NZSound.PlayShared( "nz.astro.grab", Voice );
break;
case Beat.Swell:
NZSound.PlayShared( "nz.astro.swell", Voice );
break;
case Beat.Land:
{
var p = _victim.IsValid() ? _victim.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
if ( p.IsValid() && !(p.IsDown || p.DownedNet) && InReach( p ) )
{
NZSound.PlayShared( "nz.astro.headbutt", p.WorldPosition + Vector3.Up * 50f );
NZNet.AstronautHeadbutt( p.GameObject.Id, HeadbuttFloor );
Log.Info( $"[nz-astro] HEADBUTT on {p.GameObject.Name} — a perk taken, down to {HeadbuttFloor * 100f:0}% of base health"
+ $" · it took {HeadbuttSeconds / _rate:0.##}s{(_angry ? " · he calms down" : "")}" );
_streak = 0;
if ( _angry ) Calm();
break;
}
// ── missed: the release, and one more toward his temper ──
SwapClip( "nz_astro_headbutt_release" );
_ai.HoldSpecialFor( ReleaseSeconds / _rate );
_headbuttUntil = 0f;
_missed++;
_streak++;
Log.Info( $"[nz-astro] missed ({_missed}/{MissesToAnger}) — the next headbutt takes {HeadbuttSeconds / NextRate:0.##}s" );
if ( _missed >= MissesToAnger && !_angry ) Anger();
break;
}
}
}
/// <summary>Angry: the low-gravity sprint until a headbutt lands. THE HOST.</summary>
void Anger()
{
_angry = true;
SetSpeed( AngrySpeed );
NZSound.PlayShared( "nz.astro.amb", Voice );
Log.Info( $"[nz-astro] ANGRY — sprinting at {_ai.SpeedOverride:0} u/s until a headbutt lands" );
}
/// <summary>Calm again, the count wiped (upstream resets it only here). THE HOST.</summary>
void Calm()
{
_angry = false;
_missed = 0;
SetSpeed( _walk );
}
/// <summary>
/// Upstream's `Explode`: the ring and the throw on every machine (no damage to players), and every zombie within the radius
/// dead, paying no one — bosses spared. THE HOST.
/// </summary>
void Blast()
{
var at = WorldPosition + Vector3.Up * 50f * Size;
var radius = BlastRadius * Size;
NZNet.AvogadroShock( at, radius, BlastPush );
var n = 0;
foreach ( var z in Scene.GetAllComponents<ZombieAI>().ToList() )
{
if ( !z.IsValid() || z == _ai || z.State == ZombieState.Dead || (z.Variant?.IsBoss ?? false) ) continue;
if ( z.WorldPosition.Distance( WorldPosition ) > radius ) continue;
var hp = z.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
if ( !hp.IsValid() || hp.IsDead ) continue;
hp.ApplyUnpaid( hp.Current + 666f, GameObject );
n++;
}
Log.Info( $"[nz-astro] blew up — {n} zombie(s) within {radius:0} went with him" );
}
// ── clips ──
/// <summary>`ZombieAI.PlaySpecial`, and the same clip on every watching machine — `PlaySpecial` does not relay.</summary>
bool PlayShared( string clip, float seconds, float rate = 1f )
{
if ( !_ai.PlaySpecial( clip, seconds, rate ) ) return false;
if ( Networking.IsActive ) NZNet.ZombieClip( GameObject.Id, clip, rate );
return true;
}
/// <summary>The next clip of a move already running, written to the renderer the special owns, 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, _rate );
}
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;
}
// ══ diagnostics ══════════════════════════════════════════════════════════
static IEnumerable<AstronautBoss> All
=> Game.ActiveScene?.GetAllComponents<AstronautBoss>() ?? Enumerable.Empty<AstronautBoss>();
/// <summary>`nz_astro` — every Astronaut alive.</summary>
[ConCmd( "nz_astro" )]
public static void Report()
{
Log.Info( $"[nz-astro] reach {HeadbuttRange:0} · headbutt leaves {HeadbuttFloor * 100f:0}% of base health · grab daze"
+ $" {GrabDaze:0.#}/{AngryGrabDaze:0.#}s · angry after {MissesToAnger} misses at {AngrySpeed:0} u/s · blast {BlastRadius:0}u"
+ $" · each miss takes {MissQuicken * 100f:0}% off the next headbutt, down to {MinHeadbuttShare * 100f:0}% · a landed one takes a perk" );
var list = All.ToList();
Log.Info( $"[nz-astro] {list.Count} alive" );
foreach ( var a in list )
Log.Info( $"[nz-astro] hp {a._hp?.Current ?? 0f:0}/{a._hp?.Max ?? 0f:0} · state {a._ai?.State} · speed {a._ai?.MoveSpeed ?? 0f:0}"
+ $" u/s · walk {a._walk:0.#} · misses {a._missed} (streak {a._streak}: next headbutt {HeadbuttSeconds / a.NextRate:0.##}s)"
+ $"{(a._angry ? " · ANGRY" : "")} · hitboxes {a._ai?.HitboxCount ?? 0}" );
}
/// <summary>`nz_astro_anger` — every Astronaut angry now.</summary>
[ConCmd( "nz_astro_anger" )]
public static void AngerCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-astro] the host decides" ); return; }
foreach ( var a in All.Where( a => a._ai.IsValid() ) ) a.Anger();
}
/// <summary>`nz_astro_calm` — every Astronaut calm again.</summary>
[ConCmd( "nz_astro_calm" )]
public static void CalmCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-astro] the host decides" ); return; }
foreach ( var a in All.Where( a => a._ai.IsValid() ) ) a.Calm();
Log.Info( "[nz-astro] calm" );
}
/// <summary>`nz_astro_set <key> <value>` — retune him live.</summary>
[ConCmd( "nz_astro_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "range": HeadbuttRange = value; break;
case "floor": HeadbuttFloor = value; break;
case "grab": GrabDaze = value; break;
case "angrygrab": AngryGrabDaze = value; break;
case "misses": MissesToAnger = (int)value; break;
case "quicken": MissQuicken = value; break;
case "quickest": MinHeadbuttShare = value; break;
case "angry": AngrySpeed = value; break;
case "blast": BlastRadius = value; break;
case "push": BlastPush = value; break;
default:
Log.Info( "[nz-astro] nz_astro_set <range|floor|grab|angrygrab|misses|angry|blast|push|quicken|quickest> <value>" );
return;
}
Log.Info( $"[nz-astro] {key} = {value:0.###}" );
Report();
}
}