Zombies/ShrekBoss.cs

Component attached to the Shrek joke boss. Controls summoning Helldonkey minions, an enrage roar, invulnerability during summons, tuning parameters, and diagnostic console commands.

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

namespace NZombies;

/// <summary>
/// SHREK — the joke boss nZombies Rezzurrection never finished (`nz_zombie_boss_shrek`; its model's .vtx was never shipped),
/// for whimsical maps: *"defocus is a very wimsy map full of color and kind of unserious, it's the correct map to use joke
/// bosses and specials"* (2026-10-01).
///
/// His body is Brutus's. `shrek.vmdl` is Brutus's skeleton, hitboxes and clips with the SuperSlam Shrek mesh rebuilt onto
/// them, so he walks, swings and dies as Brutus does, and `shrek.zvar` sings as he arrives and taunts as he comes. This adds
/// what the Lua adds on top: **"I SUMMON... DONKEY!"** Now and then, with his target near, he stops, sings, and two
/// Helldonkeys appear at his sides. He can't be hurt until the summon ends.
///
/// ⚠️ THE HOST'S ALONE. `ZombieAI.Target` is only ever set on the host (everyone else watches a puppet), so the host decides,
/// spawns the donkeys (`ZombieCommands.SpawnAt` network-spawns them) and sends the song with `PlayShared`.
/// ⚠️ THE TUNABLES ARE NULLABLE-BACKED (INSTRUCTIONS §1).
/// </summary>
public sealed class ShrekBoss : Component
{
	/// <summary>The summon: Brutus's own `summondogs`, which Shrek's body plays as it is.</summary>
	public const string SummonClip = "nz_base_zombie_cellbreaker_summondogs";

	/// <summary>The song he sings as he summons.</summary>
	public const string SongCue = "nz.shrek.song";

	/// <summary>The line he says as he arrives, over his song (the Lua's `taunt1` at spawn).</summary>
	public const string ArriveCue = "nz.shrek.arrive";

	/// <summary>The line he shouts as he enrages (the Lua's `taunt2`, once, when he is badly hurt).</summary>
	public const string EnrageCue = "nz.shrek.enrage";

	/// <summary>The enrage: Brutus's own `enrage_start` roar, 51 frames at 30 fps.</summary>
	public const string EnrageClip = "nz_base_zombie_cellbreaker_enrage_start";

	/// <summary>How long the enrage roar roots him.</summary>
	public static float EnrageSeconds { get => _enrageSeconds ?? 1.7f; set => _enrageSeconds = value; }
	static float? _enrageSeconds;

	/// <summary>
	/// The share of his health below which he enrages, once.
	///
	/// ⚠️ A SHARE, NOT THE LUA'S 2,000. Upstream's boss health is its own; ours scales with the round, so a fixed number
	/// would enrage him at once on a late round and never on an early one.
	/// </summary>
	public static float EnrageAt { get => _enrageAt ?? 0.35f; set => _enrageAt = value; }
	static float? _enrageAt;

	/// <summary>
	/// His speed once enraged, absolute: bare Brutus's 220. The Lua's 300 against its 150–180 was the same jump of a
	/// little under double.
	/// </summary>
	public static float EnragedSpeed { get => _enragedSpeed ?? 220f; set => _enragedSpeed = value; }
	static float? _enragedSpeed;

	/// <summary>How long the summon roots him, untouchable: the clip's 110 frames at 30 fps.</summary>
	public static float SummonSeconds { get => _summonSeconds ?? 3.6f; set => _summonSeconds = value; }
	static float? _summonSeconds;

	/// <summary>Seconds after he arrives before the first summon can come.</summary>
	public static float FirstSummonDelay { get => _firstDelay ?? 8f; set => _firstDelay = value; }
	static float? _firstDelay;

	/// <summary>The cooldown after a summon, rolled between these two: the Lua's 10–13 s.</summary>
	public static float CooldownMin { get => _cooldownMin ?? 10f; set => _cooldownMin = value; }
	static float? _cooldownMin;

	/// <summary>See <see cref="CooldownMin"/>.</summary>
	public static float CooldownMax { get => _cooldownMax ?? 13f; set => _cooldownMax = value; }
	static float? _cooldownMax;

	/// <summary>Once he has cooled down, the chance each second that he summons: the Lua's one in six.</summary>
	public static float ChancePerSecond { get => _chance ?? 1f / 6f; set => _chance = value; }
	static float? _chance;

	/// <summary>How near his target must be, in units, for him to bother.</summary>
	public static float Reach { get => _reach ?? 1000f; set => _reach = value; }
	static float? _reach;

	/// <summary>How far to each side the donkeys appear: the Lua's 65.</summary>
	public static float Spread { get => _spread ?? 65f; set => _spread = value; }
	static float? _spread;

	/// <summary>
	/// The most of HIS donkeys alive at once.
	///
	/// ⚠️ NOT IN THE LUA, which has no cap. It is here so a long fight cannot fill the map: a summon that would pass it is
	/// skipped, and its cooldown starts anyway.
	/// </summary>
	public static int MaxDonkeys { get => _maxDonkeys ?? 6; set => _maxDonkeys = value; }
	static int? _maxDonkeys;

	ZombieAI _ai;
	Health _hp;
	float _cooldown;
	float _nextRoll;
	float _invulnerableUntil;
	bool _enraged;
	TimeSince _alive;
	readonly List<GameObject> _donkeys = new();

	protected override void OnStart()
	{
		_ai = Components.Get<ZombieAI>( FindMode.EverythingInSelfAndAncestors );
		_hp = Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );
		_cooldown = FirstSummonDelay;
		_alive = 0f;

		// ⚠️ THE HOST SAYS IT FOR EVERYONE, so a client's copy of this component (`ZombieAI` attaches it on every
		// machine) must not say it again.
		if ( NZGame.IsHost )
			NZSound.PlayShared( ArriveCue, WorldPosition + Vector3.Up * 60f );
	}

	protected override void OnDestroy()
	{
		if ( _hp.IsValid() && _invulnerableUntil > 0f ) _hp.Invulnerable = false;
	}

	protected override void OnUpdate()
	{
		if ( !_ai.IsValid() || !_hp.IsValid() ) return;

		// ⛔ NEVER LEFT UNTOUCHABLE. However the summon ended — played out, cancelled, the clip refused — the flag comes off
		// on time.
		if ( _invulnerableUntil > 0f && Time.Now >= _invulnerableUntil )
		{
			_hp.Invulnerable = false;
			_invulnerableUntil = 0f;
		}

		if ( NZGame.IsClient || _hp.IsDead || _ai.InSpecial ) return;

		// ⚠️ THE ENRAGE WAITS FOR A SUMMON TO END (the `InSpecial` return above), so the roar can never cut the song
		// short or free him from the summon's pose early.
		// ⚠️ NOT IN HIS FIRST SECONDS: his health is filled in as he spawns, and a reading taken before that would enrage
		// him on arrival.
		if ( !_enraged && _alive > 2f && _hp.Max > 0f && _hp.Current < _hp.Max * EnrageAt )
		{
			Enrage();
			return;
		}

		if ( _cooldown > 0f )
		{
			_cooldown -= Time.Delta;
			return;
		}

		if ( Time.Now < _nextRoll ) return;
		_nextRoll = Time.Now + 1f;

		var target = _ai.Target;
		if ( !target.IsValid() ) return;
		if ( _ai.WorldPosition.Distance( target.WorldPosition ) > Reach ) return;
		if ( Game.Random.Float() > ChancePerSecond ) return;

		Summon();
	}

	/// <summary>
	/// "I SUMMON... DONKEY!" — now, target or no target.
	///
	/// ⚠️ SPLIT OUT SO `nz_shrek_summon` CAN REACH IT. In Creative nothing gives him a target, so without the command the
	/// summon could only be seen by fighting him properly.
	/// </summary>
	public void Summon()
	{
		if ( NZGame.IsClient || !_ai.IsValid() || !_hp.IsValid() || _hp.IsDead ) return;

		_cooldown = Game.Random.Float( CooldownMin, MathF.Max( CooldownMin, CooldownMax ) );

		_donkeys.RemoveAll( d => !d.IsValid() );
		var room = MaxDonkeys - _donkeys.Count;
		if ( room <= 0 )
		{
			Log.Info( $"[nz-shrek] summon skipped — {_donkeys.Count} of his donkeys still alive (cap {MaxDonkeys})" );
			return;
		}

		// ⚠️ IF THE CLIP WILL NOT PLAY THE DONKEYS STILL COME. `PlaySpecial` refuses on a model that lacks the clip and
		// says so; losing the animation should cost the pose, not the summon.
		_ai.PlaySpecial( SummonClip, SummonSeconds );
		_hp.Invulnerable = true;
		_invulnerableUntil = Time.Now + SummonSeconds;

		NZSound.PlayShared( SongCue, WorldPosition + Vector3.Up * 60f );

		var variant = SpecialEnemies.VariantFor( SpecialEnemies.Helldonkey );
		var scene = Scene;
		var came = 0;
		if ( variant is not null && scene.IsValid() )
		{
			foreach ( var side in new[] { 1f, -1f } )
			{
				if ( came >= room ) break;

				var pos = WorldPosition + WorldRotation.Right * Spread * side;
				var z = ZombieCommands.SpawnAt( scene, pos, variant );
				if ( z is null ) continue;

				z.WorldRotation = WorldRotation;
				z.GameObject.Name = SpecialEnemies.Helldonkey;
				_donkeys.Add( z.GameObject );
				came++;
			}
		}
		else
			Log.Warning( "[nz-shrek] no Helldonkey to summon — zombies/helldonkey.zvar did not load" );

		Log.Info( $"[nz-shrek] I SUMMON... DONKEY! — {came} came, untouchable for {SummonSeconds:0.#}s,"
			+ $" next in {_cooldown:0.#}s" );
	}

	/// <summary>
	/// The enrage, once: he roars, shouts, and runs at <see cref="EnragedSpeed"/> from then on.
	///
	/// ⛔ AN ABSOLUTE SPEED THROUGH `SpeedOverride`, AS BRUTUS'S HELMET BREAK SETS HIS, and `RepickAnimations` to make
	/// it take: the speed is only derived when animations are picked (see `BrutusHelmet.Break`).
	/// </summary>
	public void Enrage()
	{
		if ( NZGame.IsClient || _enraged || !_ai.IsValid() || !_hp.IsValid() || _hp.IsDead ) return;

		_enraged = true;

		// ⚠️ IF THE CLIP WILL NOT PLAY HE STILL SPEEDS UP: losing the roar should cost the pose, not the enrage.
		_ai.PlaySpecial( EnrageClip, EnrageSeconds );
		NZSound.PlayShared( EnrageCue, WorldPosition + Vector3.Up * 60f );

		_ai.SpeedOverride = MathF.Max( 1f, EnragedSpeed );
		_ai.RepickAnimations();

		Log.Info( $"[nz-shrek] ENRAGED at {_hp.Current:0} of {_hp.Max:0} — {EnragedSpeed:0} u/s from now on" );
	}

	// ══ diagnostics ══════════════════════════════════════════════════════════

	/// <summary>`nz_shrek` — every Shrek alive, and the summon's tuning.</summary>
	[ConCmd( "nz_shrek" )]
	public static void Report()
	{
		Log.Info( $"[nz-shrek] summon every {CooldownMin:0.#}–{CooldownMax:0.#}s, then 1 in {1f / MathF.Max( ChancePerSecond, 0.001f ):0.#}"
			+ $" a second within {Reach:0}u · {SummonSeconds:0.#}s untouchable · 2 at ±{Spread:0}u, at most {MaxDonkeys} of his alive"
			+ $" · enrages below {EnrageAt * 100f:0}% health, then {EnragedSpeed:0} u/s" );

		var all = Game.ActiveScene?.GetAllComponents<ShrekBoss>().ToList() ?? new List<ShrekBoss>();
		Log.Info( $"[nz-shrek] {all.Count} alive" );
		foreach ( var s in all )
		{
			s._donkeys.RemoveAll( d => !d.IsValid() );
			Log.Info( $"[nz-shrek]   {s.GameObject.Name} · health {s._hp?.Current ?? 0:0} · cooldown {MathF.Max( s._cooldown, 0f ):0.#}s"
				+ $" · {s._donkeys.Count} donkey(s) alive{(s._invulnerableUntil > 0f ? " · SUMMONING" : "")}"
				+ (s._enraged ? " · ENRAGED" : "") );
		}
	}

	/// <summary>`nz_shrek_enrage` — every Shrek alive enrages now (host).</summary>
	[ConCmd( "nz_shrek_enrage" )]
	public static void EnrageCmd()
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-shrek] the host decides the enrage — run it on the host" ); return; }

		var all = Game.ActiveScene?.GetAllComponents<ShrekBoss>().ToList() ?? new List<ShrekBoss>();
		if ( all.Count == 0 ) { Log.Info( "[nz-shrek] no Shrek alive — nz_special_spawn shrek, or a boss round" ); return; }
		foreach ( var s in all ) s.Enrage();
	}

	/// <summary>`nz_shrek_summon` — every Shrek alive summons now (host).</summary>
	[ConCmd( "nz_shrek_summon" )]
	public static void SummonCmd()
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-shrek] the host decides the summon — run it on the host" ); return; }

		var all = Game.ActiveScene?.GetAllComponents<ShrekBoss>().ToList() ?? new List<ShrekBoss>();
		if ( all.Count == 0 ) { Log.Info( "[nz-shrek] no Shrek alive — nz_special_spawn shrek, or a boss round" ); return; }
		foreach ( var s in all ) s.Summon();
	}
}