Zombies/AstronautBoss.cs

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.

NetworkingFile Access
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 &lt;key&gt; &lt;value&gt;` — 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();
	}
}