Zombies/BossBase.cs

Abstract Boss component for NZombies. It provides common AI/health/renderer wiring, per-frame hooks (EveryFrame, HostFrame), death handling, timed beats, shared animation/clip relay, facing/sight utilities, player targeting and damage helpers, and networking relay calls via NZNet for parts/clips/visibility.

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

namespace NZombies;

/// <summary>
/// What every boss of the third batch shares (2026-10-06, the user: *"let's do the whole bunch"* — the 14 picks after the
/// Panzer): its AI, health and body, the split of each frame into every machine / the host / once at death, special clips
/// relayed to every machine, timed beats, facing and sight, the player it is after, its hits on players (Oberon's cap), and
/// the round's swing damage.
///
/// ⚠️ THE FOUR BEFORE IT (Avogadro, the Astronaut, the Director, the Panzer) CARRY THEIR OWN COPIES of the same helpers;
/// they were written one at a time. Fourteen more copies would be fourteen places to fix one bug, so the batch starts here.
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS (the whole project's rule): <see cref="HostFrame"/> runs on the host only, where the
/// zombie is real; on a client it is a puppet and <see cref="EveryFrame"/> is all that runs. Anything a client must see goes
/// through `NZNet`.
/// </summary>
public abstract class BossBase : Component
{
	protected ZombieAI Ai { get; private set; }
	protected Health Hp { get; private set; }
	protected SkinnedModelRenderer Body { get; private set; }

	/// <summary>The log's prefix: "[nz-megaton]".</summary>
	protected abstract string Tag { get; }

	/// <summary>How high its voice and its eyes sit, at size 1.</summary>
	protected virtual float VoiceHeight => 64f;

	protected float Size => MathF.Max( 0.1f, WorldScale.x );
	protected Vector3 Voice => WorldPosition + Vector3.Up * VoiceHeight * Size;

	/// <summary>Where it faces, on the floor — its art may face anywhere (`ZombieAI.ModelTurn`).</summary>
	protected Vector3 Facing => Ai.IsValid() ? (WorldRotation * Ai.ModelTurn.Inverse).Forward.WithZ( 0 ).Normal : WorldRotation.Forward;

	/// <summary>Is this the machine that runs it (the host, or alone).</summary>
	protected bool IsHost => Ai.IsValid() && !Ai.IsPuppet;

	public bool Dead => _deadHandled;

	bool _deadHandled;
	readonly List<(float At, int What)> _beats = new();

	protected override void OnStart()
	{
		Ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		Hp = Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
		Body = Components.Get<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants );

		BossStart();
		if ( NZGame.IsClient || !Ai.IsValid() ) return;
		HostStart();
	}

	protected override void OnUpdate()
	{
		if ( !Ai.IsValid() ) return;

		if ( Ai.State == ZombieState.Dead )
		{
			if ( !_deadHandled )
			{
				_deadHandled = true;
				_beats.Clear();
				Died( !Ai.IsPuppet );
			}
			DeadFrame();
			return;
		}

		EveryFrame();
		if ( Ai.IsPuppet ) return;

		TickBeats();
		HostFrame();
	}

	// ══ the hooks ════════════════════════════════════════════════════════════

	/// <summary>Once, on every machine, when it starts (its look: lights, attachments).</summary>
	protected virtual void BossStart() { }

	/// <summary>Once, on the host, when it starts (its AI's settings, its timers).</summary>
	protected virtual void HostStart() { }

	/// <summary>Every frame while it lives, on every machine (what it looks and sounds like).</summary>
	protected virtual void EveryFrame() { }

	/// <summary>Every frame while it lives, on the host (what it decides).</summary>
	protected virtual void HostFrame() { }

	/// <summary>Once, on every machine, when it dies — <paramref name="host"/> on the one that runs it.</summary>
	protected virtual void Died( bool host ) { }

	/// <summary>Every frame after it died, on every machine (a corpse that blows up later).</summary>
	protected virtual void DeadFrame() { }

	/// <summary>A beat queued with <see cref="Queue"/> has come. THE HOST.</summary>
	protected virtual void OnBeat( int beat ) { }

	/// <summary>
	/// A part this boss draws itself, on THIS machine (`NZNet.BossPart` → `BossFx.SetPart`): true when it took the part, and the
	/// renderer's bodygroups are left alone. For a part that is not a bodygroup — the Thrasher's spore sacs are bones folded away
	/// (upstream's `ManipulateBoneScale`), his rage an aura (2026-10-07).
	/// </summary>
	public virtual bool ShowPart( string group, int choice ) => false;

	/// <summary>
	/// The multiplier for one hit on it, and whatever that hit sets off (a weak spot, a stagger, a phase) — 1 unless it has a
	/// table. THE HOST, ⛔ EXACTLY ONCE PER HIT (`Health.OnDamage` → <see cref="ScaleOn"/>), the helmet's rule: it may count.
	/// <paramref name="amount"/> is the hit before this scale.
	/// </summary>
	protected virtual float ScaleDamage( float amount, in DamageInfo damage ) => 1f;

	/// <summary>The scale for a hit on this object: its boss's table, or 1 when it is not one of these (`Health.OnDamage`).</summary>
	public static float ScaleOn( GameObject victim, float amount, in DamageInfo damage )
	{
		if ( !victim.IsValid() ) return 1f;
		var b = victim.Components.Get<BossBase>( FindMode.EverythingInSelfAndAncestors );
		return b.IsValid() && !b._deadHandled && b.Ai.IsValid() ? b.ScaleDamage( amount, damage ) : 1f;
	}

	// ══ beats ════════════════════════════════════════════════════════════════

	/// <summary>Run <see cref="OnBeat"/> with <paramref name="beat"/> after <paramref name="after"/> seconds. THE HOST.</summary>
	protected void Queue( float after, int beat ) => _beats.Add( (Time.Now + MathF.Max( 0f, after ), beat) );

	/// <summary>Forget every beat still waiting (a stagger cut the move short).</summary>
	protected void ClearBeats() => _beats.Clear();

	protected bool HasBeat( int beat ) => _beats.Any( b => b.What == beat );

	void TickBeats()
	{
		for ( int i = _beats.Count - 1; i >= 0; i-- )
		{
			if ( i >= _beats.Count ) continue;
			var b = _beats[i];
			if ( Time.Now < b.At ) continue;
			_beats.RemoveAt( i );
			OnBeat( b.What );
		}
	}

	// ══ clips ════════════════════════════════════════════════════════════════

	/// <summary>`ZombieAI.PlaySpecial`, and the same clip on every watching machine — `PlaySpecial` does not relay. THE HOST.</summary>
	protected bool PlayShared( string clip, float seconds, float rate = 1f )
	{
		if ( !Ai.IsValid() || !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. THE HOST.</summary>
	protected void SwapClip( string clip, float rate = 1f )
	{
		if ( !Ai.IsValid() || Ai.State != ZombieState.Special || !Body.IsValid() ) return;
		Body.Sequence.Name = clip;
		Body.Sequence.Time = 0f;
		if ( Networking.IsActive ) NZNet.ZombieClip( GameObject.Id, clip, rate );
	}

	/// <summary>Seconds a clip of <paramref name="frames"/> frames lasts at 30 fps — the rate every port here was made at.</summary>
	protected static float Frames( float frames ) => frames / 30f;

	// ══ facing, sight, targets ═══════════════════════════════════════════════

	protected 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;
	}

	protected bool FacingWithin( Vector3 at, float degrees )
	{
		var to = (at - WorldPosition).WithZ( 0 );
		if ( to.Length < 1f ) return true;
		return Vector3.GetAngle( Facing, to.Normal ) <= degrees;
	}

	/// <summary>A clear line from its eyes to <paramref name="target"/>'s chest (zombies and triggers in between do not count).</summary>
	protected bool CanSee( GameObject target, float targetHeight = 60f )
	{
		if ( !target.IsValid() ) return false;
		var to = target.WorldPosition + Vector3.Up * targetHeight;
		var tr = Scene.Trace.Ray( Voice, to ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "zombie", "trigger", "player" ).Run();
		return !tr.Hit || tr.HitPosition.Distance( to ) < 40f;
	}

	/// <summary>The player it is after, or null.</summary>
	protected NZPlayer TargetPlayer
	{
		get
		{
			var t = Ai.IsValid() ? Ai.Target : null;
			return t.IsValid() ? t.Components.Get<NZPlayer>( FindMode.EverythingInSelfAndAncestors ) : null;
		}
	}

	/// <summary>Up and in the round — someone it can still hurt. ⚠️ `DownedNet`: on the host a client's `IsDown` is always false.</summary>
	public static bool IsUp( NZPlayer p ) => p.IsValid() && !p.IsDown && !p.DownedNet && !p.IsOutOfRound && !p.OutOfRoundNet;

	/// <summary>Every player up within <paramref name="radius"/> of <paramref name="at"/> (to their chest).</summary>
	protected IEnumerable<NZPlayer> PlayersWithin( Vector3 at, float radius )
	{
		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
			if ( IsUp( p ) && (p.WorldPosition + Vector3.Up * 40f).Distance( at ) <= radius ) yield return p;
	}

	/// <summary>The nearest player up, within <paramref name="radius"/> (on the floor), or null.</summary>
	protected NZPlayer NearestPlayer( float radius = float.MaxValue )
	{
		NZPlayer best = null;
		var bestD = radius;
		foreach ( var p in Scene.GetAllComponents<NZPlayer>() )
		{
			if ( !IsUp( p ) ) continue;
			var d = (p.WorldPosition - WorldPosition).WithZ( 0 ).Length;
			if ( d >= bestD ) continue;
			bestD = d;
			best = p;
		}
		return best;
	}

	// ══ hurting players ══════════════════════════════════════════════════════

	/// <summary>The most one of its hits takes: two-thirds of the match's base health — Oberon's rule, no attack downs anyone from full.</summary>
	public static float MaxHit => MathF.Max( 1f, Difficulty.MaxHealth * (2f / 3f) );

	/// <summary>The round's zombie swing, times its variant's multiplier and the difficulty's, times <paramref name="weight"/>.</summary>
	protected float SwingDamage( float weight = 1f )
	{
		int round = Math.Max( 1, RoundManager.Instance?.Round ?? 1 );
		return ZombieStats.AttackDamageForRound( round ) * (Ai?.Variant?.DamageMultiplier ?? 1f) * Difficulty.ZombieDamage * weight;
	}

	/// <summary>One of its hits on <paramref name="p"/>, never more than <see cref="MaxHit"/>. THE HOST. False when nothing landed.</summary>
	protected bool HurtPlayer( NZPlayer p, float amount, bool blast = false, Vector3 at = default, bool tick = false, string source = null )
	{
		if ( !IsUp( p ) || amount <= 0f ) return false;
		var hp = p.Components.Get<Health>( FindMode.EverythingInSelfAndDescendants );
		if ( !hp.IsValid() || hp.IsDead ) return false;
		hp.Apply( MathF.Min( amount, MaxHit ), false, GameObject, blast: blast, blastAt: at, tick: tick, source: source );
		return true;
	}

	/// <summary>A snap to <paramref name="at"/> — the agent told, every other machine's interpolation cleared (Avogadro's warp). THE HOST.</summary>
	protected void Warp( Vector3 at )
	{
		WorldPosition = at;
		var agent = Components.Get<NavMeshAgent>( FindMode.EverythingInSelfAndDescendants );
		if ( agent.IsValid() ) agent.SetAgentPosition( at );
		GameObject.Network.ClearInterpolation();
	}

	/// <summary>A bodygroup, on every machine (`NZNet.BossPart`). THE HOST.</summary>
	protected void SetPart( string group, int choice ) => NZNet.BossPart( GameObject.Id, group, choice );

	/// <summary>Hidden or shown, on every machine (`NZNet.BossHidden`). THE HOST.</summary>
	protected void SetHidden( bool hidden ) => NZNet.BossHidden( GameObject.Id, hidden );

	/// <summary>Its speed, handed to the animation (`RepickAnimations` pushes the agent's speed). THE HOST.</summary>
	protected void SetSpeed( float speed )
	{
		if ( !Ai.IsValid() ) return;
		Ai.SpeedOverride = MathF.Max( 1f, speed * Size );
		Ai.RepickAnimations();
	}

	protected void Say( string message ) => Log.Info( $"{Tag} {message}" );
}