Zombies/SawrunnerBoss.cs

A Boss NPC component for the Sawrunner enemy. Controls spawning effects, movement speeds (walk/run), an enrage sprint behavior with audio and screen shake, damage scaling, perk distribution on death, and console commands for reporting and live retuning.

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

namespace NZombies;

/// <summary>
/// The Sawrunner — Cry of Fear's chainsaw man, ported 2026-10-07 from GMod nZombies' `nz_zombie_boss_sawrunner` (Laby's cut-down
/// copy of the Director), the third batch.
///
///   • HE ARRIVES in a strike of lightning (upstream's `driese_tp_arrival`), revving the saw (`appear`), with one of his three
///     chase songs following him, and the engine idling for as long as he lives.
///   • HE WALKS (one of two walks, 95.7 or 113.9 u/s) UNTIL HE SEES YOU within <see cref="EnrageRange"/>: then he sprints
///     (365.7) for <see cref="RageSeconds"/>, every swing of the saw x<see cref="RageSwing"/> (upstream's 135 against 75), and
///     stops for <see cref="CalmSeconds"/> to rev it again (`appear`) — at least <see cref="EnrageGap"/> s before the next.
///   • HE TAKES x<see cref="Resist"/> FROM EVERYTHING.
///   • HIS DEATH GIVES EVERY PLAYER A PERK (upstream's Perk Bottle: a random one each, if a slot is free — the Director's own
///     grant, `NZNet.DirectorPerk`).
///
/// ⚠️ UPSTREAM PLAYS ITS ENRAGE AND DEATH ON CLIPS FROM A FILE NOBODY HAS (`jump_3`, `die_spin`): no enrage clip, and the
/// model's own falls.
/// </summary>
public sealed class SawrunnerBoss : BossBase
{
	protected override string Tag => "[nz-saw]";

	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1.

	static float? _enrageRange, _rageSeconds, _calmSeconds, _enrageGap, _rageSwing, _resist;
	/// <summary>He sprints at someone he sees within 650, upstream's.</summary>
	public static float EnrageRange { get => _enrageRange ?? 650f; set => _enrageRange = value; }
	/// <summary>How long a sprint lasts: 10 s; the rev after it 3 s (`appear`); then 10 s before he can sprint again.</summary>
	public static float RageSeconds { get => _rageSeconds ?? 10f; set => _rageSeconds = value; }
	public static float CalmSeconds { get => _calmSeconds ?? 3f; set => _calmSeconds = value; }
	public static float EnrageGap { get => _enrageGap ?? 10f; set => _enrageGap = value; }
	/// <summary>His swing while sprinting, against his walking one: 1.8 (upstream's 135 against 75).</summary>
	public static float RageSwing { get => _rageSwing ?? 1.8f; set => _rageSwing = value; }
	/// <summary>What he takes from anything: 0.5, upstream's.</summary>
	public static float Resist { get => _resist ?? 0.5f; set => _resist = value; }

	const float WalkSlow = 95.7f, WalkFast = 113.9f, RunSpeed = 365.7f;

	// ══ state ════════════════════════════════════════════════════════════════

	bool _enraged;
	float _nextEnrage, _calmAt, _nextLook;
	SoundHandle _loop, _music;
	float _nextLoopTry;

	public bool Enraged => _enraged;

	protected override void BossStart()
	{
		// ⚠️ EVERY MACHINE: his chase song, from where he stands and following him (upstream's, on the boss itself)
		_music = NZSound.Play( "nz.saw.music", Voice );
	}

	protected override void HostStart()
	{
		Ai.MaxHitDamage = MaxHit;
		_nextEnrage = Time.Now + 7f;
		SetSpeed( Game.Random.Int( 0, 1 ) == 0 ? WalkSlow : WalkFast );

		// upstream's `driese_tp_arrival`: a strike of blue lightning where he lands, and the rubble
		BossFx.BurstShared( WorldPosition + Vector3.Up * 20f, 160f * Size, new Color( 0.55f, 0.65f, 1f ), 12 );
		NZSound.PlayShared( "nz.saw.rubble", WorldPosition );
		NZNet.ShakeAt( WorldPosition, 0.4f, 1200f );

		Say( $"spawned — {Hp?.Max ?? 0f:0} hp (x{Resist:0.##} from everything) · sprints at anyone in sight within {EnrageRange:0}"
			+ $" for {RageSeconds:0}s · hitboxes {Ai.HitboxCount}" );
	}

	protected override void EveryFrame()
	{
		if ( _music.IsValid() ) _music.Position = Voice;

		// the saw idling, looped, when you are near enough to hear it (the Astronaut's breath)
		if ( Time.Now < _nextLoopTry ) { if ( _loop.IsValid() ) _loop.Position = WorldPosition + Vector3.Up * 40f; return; }
		_nextLoopTry = Time.Now + 1f;
		if ( _loop.IsValid() && _loop.IsPlaying ) return;
		if ( NZSound.Ear.Distance( WorldPosition ) > 1500f ) return;
		_loop = NZSound.Play( "nz.saw.loop", WorldPosition + Vector3.Up * 40f );
	}

	protected override void HostFrame()
	{
		Ai.MaxHitDamage = MaxHit;
		if ( Ai.State == ZombieState.Spawning ) return;

		if ( _enraged )
		{
			if ( Time.Now >= _calmAt ) Calm();
			return;
		}

		if ( Time.Now < _nextEnrage || Time.Now < _nextLook || Ai.State != ZombieState.Chasing ) return;
		_nextLook = Time.Now + 0.25f;
		var p = TargetPlayer;
		if ( !IsUp( p ) || WorldPosition.Distance( p.WorldPosition ) > EnrageRange * Size || !CanSee( p.GameObject ) ) return;
		Enrage();
	}

	protected override void Died( bool host )
	{
		if ( _music.IsValid() ) _music.Stop();
		if ( _loop.IsValid() ) _loop.Stop();
		if ( !host ) return;

		Ai.SwingScale = 1f;

		// upstream's Perk Bottle: every player a random perk they do not have — on their own machine, where perks live
		var n = 0;
		foreach ( var p in Scene.GetAllComponents<NZPlayer>().ToList() )
		{
			if ( !IsUp( p ) ) continue;
			NZNet.DirectorPerk( p.GameObject.Id );
			n++;
		}
		Say( $"killed — a perk for each of {n} player(s)" );
	}

	protected override void OnDestroy()
	{
		if ( _music.IsValid() ) _music.Stop();
		if ( _loop.IsValid() ) _loop.Stop();
	}

	protected override float ScaleDamage( float amount, in DamageInfo damage ) => Resist;

	// ══ the host ═════════════════════════════════════════════════════════════

	/// <summary>The sprint: the scream, the shake, the run, the heavy saw. THE HOST.</summary>
	void Enrage()
	{
		_enraged = true;
		_calmAt = Time.Now + RageSeconds;
		Ai.SwingScale = RageSwing;
		SetSpeed( RunSpeed );
		NZSound.PlayShared( "nz.saw.angry", Voice );
		NZNet.ShakeAt( WorldPosition, 0.8f, 1000f );
		Say( $"ENRAGED — sprinting at {Ai.SpeedOverride:0} u/s for {RageSeconds:0}s" );
	}

	/// <summary>The rev after a sprint (`appear`, standing), then a walk again. THE HOST.</summary>
	void Calm()
	{
		_enraged = false;
		_nextEnrage = Time.Now + CalmSeconds + EnrageGap;
		Ai.SwingScale = 1f;
		PlayShared( "appear", CalmSeconds );
		NZSound.PlayShared( "nz.saw.swing", Voice );
		SetSpeed( Game.Random.Int( 0, 1 ) == 0 ? WalkSlow : WalkFast );
	}

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

	static IEnumerable<SawrunnerBoss> All
		=> Game.ActiveScene?.GetAllComponents<SawrunnerBoss>() ?? Enumerable.Empty<SawrunnerBoss>();

	/// <summary>`nz_saw` — every Sawrunner alive, and the tuning.</summary>
	[ConCmd( "nz_saw" )]
	public static void Report()
	{
		Log.Info( $"[nz-saw] sprints within {EnrageRange:0} for {RageSeconds:0}s (swing x{RageSwing:0.##}), revs {CalmSeconds:0.#}s, then"
			+ $" {EnrageGap:0}s · x{Resist:0.##} from everything · hit cap {MaxHit:0}" );
		var list = All.ToList();
		Log.Info( $"[nz-saw] {list.Count} alive" );
		foreach ( var s in list )
			Log.Info( $"[nz-saw]   hp {s.Hp?.Current ?? 0f:0}/{s.Hp?.Max ?? 0f:0} · state {s.Ai?.State} · speed {s.Ai?.MoveSpeed ?? 0f:0}"
				+ $"{(s._enraged ? $" · ENRAGED ({MathF.Max( 0f, s._calmAt - Time.Now ):0.#}s left)" : "")}" );
	}

	/// <summary>`nz_saw_enrage` — every Sawrunner sprints now.</summary>
	[ConCmd( "nz_saw_enrage" )]
	public static void EnrageCmd()
	{
		if ( NZGame.IsClient ) { Log.Info( "[nz-saw] the host decides" ); return; }
		foreach ( var s in All.Where( s => s.Ai.IsValid() && !s.Dead && !s._enraged ).ToList() ) s.Enrage();
	}

	/// <summary>`nz_saw_set &lt;key&gt; &lt;value&gt;` — retune him live.</summary>
	[ConCmd( "nz_saw_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "range": EnrageRange = value; break;
			case "rage": RageSeconds = value; break;
			case "calm": CalmSeconds = value; break;
			case "gap": EnrageGap = value; break;
			case "swing": RageSwing = value; break;
			case "resist": Resist = value; break;
			default:
				Log.Info( "[nz-saw] nz_saw_set <range|rage|calm|gap|swing|resist> <value>" );
				return;
		}
		Log.Info( $"[nz-saw] {key} = {value:0.###}" );
		Report();
	}
}