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.
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}" );
}