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.
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 <key> <value>` — 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();
}
}