A Boss AI component for the Brenner flamethrower boss in an NZombies game mode. Controls spawning arrival, pacing (walk/run), flamethrower start/stop, flame damage and burn application, stagger reactions, enrage behavior, and debug console commands.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// Brenner — CoD WWII's flamethrower brute, ported 2026-10-07 from GMod nZombies' `nz_zombie_boss_fireman` (GhostlyMoo's port),
/// the first of the third batch (the user: *"let's do the whole bunch"*).
///
/// • HE DROPS IN on the Panzer's arrival — his clips are the Panzer's mech animation base — or under a roof the short one.
/// • HE WALKS WHEN YOU ARE CLOSE AND RUNS WHEN YOU ARE FAR: in sight within <see cref="WalkRange"/> he walks (87.8 u/s), else
/// he runs (175.7). Upstream's own rule (`AI`).
/// • THE FLAMETHROWER, at <see cref="FlameMin"/>-<see cref="FlameMax"/> in sight while he chases: every 0.1 s one target — the
/// nearest in the jet — takes <see cref="FlameWeight"/> of a swing (upstream's 6 against 50, x1.3 since 2026-10-07); walking,
/// he slows to the flame walk (70.4). ⚠️ Walls stop it (upstream's hull ignored the world) and it is aimed at you
/// (`PanzerLook.FlameDir`).
/// ⚠️ AND IT SETS YOU ALIGHT (2026-10-07, the user: *"the brenner's fire should deal a bit more damage, and should also burn
/// the player like the panzer fire, dealing damage over time"*): every tick that reaches you starts or renews
/// <see cref="BurnSeconds"/> s of the Fire Margwa's burn (`NZNet.MargwaBurn`, through `BossFlame.Burn`) at
/// <see cref="BurnWeight"/> of his swing a second — renewed, never stacked; no regen while it lasts; never the last point.
/// ⚠️ The Panzer's own flame has no burn after it (what stays is the fire on your screen, `NZNet.PanzerScorch`, which this
/// flame already sent); the 3 s is upstream's, commented out in both their flames (`--tr.Entity:Ignite(3, 0)`).
/// • HIS SWING within 115 (the variant's range): `nz_soldat_melee_a/b` walking, the run attacks running.
/// • AT HALF HEALTH HE ENRAGES, once: `nz_fireman_stand_attack_berserk`, then the sprint (204.4) for good.
/// • EVERY HIT HAS A <see cref="StaggerChance"/> CHANCE TO STAGGER HIM (`nz_fireman_hit_react_*`, the flame out), then not
/// again for 3.5-10.5 s.
/// • HE TAKES x<see cref="Resist"/> FROM ANYTHING BUT BULLETS AND BLADES (upstream's: full damage only from bullet, club,
/// slash, crush, generic).
///
/// ⚠️ HOST AUTHORITY, EVERY MACHINE DRAWS: the jet and its sounds are `BossFlame` (`NZNet.BossFlameOn`).
/// ⛔ NO HIT OF HIS DOWNS ANYONE FROM FULL HEALTH (`BossBase.MaxHit`), Oberon's rule. The flame's own ticks can take the last point,
/// as the Panzer's do; the burn after it never does (`MargwaBurn` stops at 1).
/// </summary>
public sealed class BrennerBoss : BossBase
{
protected override string Tag => "[nz-brenner]";
protected override float VoiceHeight => 70f;
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED, INSTRUCTIONS.md §1.
static float? _flameMin, _flameMax, _flameReach, _flameHalf, _flameWeight, _walkRange, _resist;
/// <summary>He lights up for a target 145-335 away (upstream's `AttackRange + 30` to 335).</summary>
public static float FlameMin { get => _flameMin ?? 145f; set => _flameMin = value; }
public static float FlameMax { get => _flameMax ?? 335f; set => _flameMax = value; }
/// <summary>The jet's reach and half-width: 325 and 14 (upstream's box, 325 long and −8..20 wide).</summary>
public static float FlameReach { get => _flameReach ?? 325f; set => _flameReach = value; }
public static float FlameHalfWidth { get => _flameHalf ?? 14f; set => _flameHalf = value; }
/// <summary>
/// A tick of flame (every 0.1 s) against a swing: 0.156 — upstream's 6 against 50 (0.12), x1.3 since 2026-10-07 (the user:
/// *"the brenner's fire should deal a bit more damage"*): 7.8 a tick at round 12 (was 6), 11.7 at 20 (was 9), 14 at 31 (was 10.8).
/// </summary>
public static float FlameWeight { get => _flameWeight ?? 0.156f; set => _flameWeight = value; }
/// <summary>In sight and this near, he walks; else he runs: 275, upstream's.</summary>
public static float WalkRange { get => _walkRange ?? 275f; set => _walkRange = value; }
/// <summary>What he takes from anything but bullets and blades: 0.25, upstream's.</summary>
public static float Resist { get => _resist ?? 0.25f; set => _resist = value; }
static float? _burnSeconds, _burnWeight;
/// <summary>
/// The burn a tick of his flame sets (2026-10-07, the user: *"should also burn the player like the panzer fire, dealing damage
/// over time"*): it lasts <see cref="BurnSeconds"/> after the last tick that reached you — 3, upstream's own `Ignite(3, 0)`,
/// commented out in his flame and the Panzer's — at <see cref="BurnWeight"/> of his swing a second, 0.1 (the Krasny Soldat's
/// fire-pit burn): 5 a second at round 12, 7.5 at 20, 9 at 31. It is the Fire Margwa's burn (`MargwaBurn`): a tick every half
/// second, through armour, no regen while it lasts, renewed and never stacked, and ⛔ never the last point. 0 seconds or 0
/// weight, none.
/// ⚠️ THE PANZER'S FLAME HAS NO BURN TO COPY: his ticks stop when you step out, and what stays is the fire on your screen
/// (`NZNet.PanzerScorch`), which this flame already sent. This is the project's one burn on a player, shared, not a copy.
/// </summary>
public static float BurnSeconds { get => _burnSeconds ?? 3f; set => _burnSeconds = value; }
public static float BurnWeight { get => _burnWeight ?? 0.1f; set => _burnWeight = value; }
static float? _stagger, _staggerMin, _staggerMax, _enrageAt;
/// <summary>A hit's chance to stagger him: 0.04 (upstream's `math.Rand(1,100) <= 5`, ~4%); then 3.5-10.5 s before the next.</summary>
public static float StaggerChance { get => _stagger ?? 0.04f; set => _stagger = value; }
public static float StaggerGapMin { get => _staggerMin ?? 3.5f; set => _staggerMin = value; }
public static float StaggerGapMax { get => _staggerMax ?? 10.5f; set => _staggerMax = value; }
/// <summary>The share of his health that enrages him: 0.5.</summary>
public static float EnrageAt { get => _enrageAt ?? 0.5f; set => _enrageAt = value; }
/// <summary>His clips' own speeds — `brenner.zvar`'s tiers are picked by them.</summary>
const float FlameWalkSpeed = 70.4f, WalkSpeed = 87.8f, RunSpeed = 175.7f, SprintSpeed = 204.4f;
const float FlameTick = 0.1f;
// ══ state ════════════════════════════════════════════════════════════════
BossFlame _flame;
bool _flaming, _enraged, _walking, _paceSet;
float _nextBurn, _nextStagger, _nextThink;
enum Beat { Land }
public bool Enraged => _enraged;
public bool Flaming => _flaming;
protected override void BossStart()
{
// ⚠️ EVERY MACHINE: the jet is drawn on each (`tag_flamethrower_fx`, an offset in `j_elbow_ri`'s frame, from the QC)
_flame = Components.GetOrCreate<BossFlame>();
_flame.Bone = "j_elbow_ri";
_flame.Offset = new Vector3( -35f, 4f, -4.5f );
_flame.Reach = FlameReach;
}
protected override void HostStart()
{
Ai.MaxHitDamage = MaxHit;
// ⛔ NONE OF THE WALKER'S CLIMB CLIPS ON HIS MODEL (Oberon's note): the Panzer's mantle, in place
Ai.ClimbClipOverride = "nz_soldat_mantle_48";
_nextStagger = Time.Now + 1f;
// the boss round's roar, to everyone (upstream's `spawnfunc`)
NZSound.PlayShared( "nz.brenner.intro" );
// ⚠️ A ROOF ABOVE: the drop would come through it — the short arrival instead (upstream's hull up 750, the Panzer's rule)
var roof = Scene.Trace.Ray( WorldPosition + Vector3.Up * 16f, WorldPosition + Vector3.Up * 750f )
.WithoutTags( "zombie", "player", "trigger" ).Run();
var landAt = Frames( 28f );
if ( roof.Hit && Body.IsValid() )
{
Body.Sequence.Name = "nz_soldat_arrive_tomb_short";
Body.Sequence.Time = 0f;
if ( Networking.IsActive ) NZNet.ZombieClip( GameObject.Id, "nz_soldat_arrive_tomb_short", 1f );
landAt = Frames( 5f );
}
Queue( landAt, (int)Beat.Land );
Say( $"spawned — {Hp?.Max ?? 0f:0} hp · {(roof.Hit ? "short arrival (roof)" : "drop")} · flame {FlameMin:0}-{FlameMax:0}u"
+ $" · enrages at {EnrageAt * 100f:0}% · x{Resist:0.##} from all but bullets and blades · hitboxes {Ai.HitboxCount}" );
}
protected override void HostFrame()
{
Ai.MaxHitDamage = MaxHit;
if ( Ai.State == ZombieState.Spawning ) return;
// the flame is out the moment he does anything but chase (a swing, a stagger, the roar)
if ( _flaming && Ai.State != ZombieState.Chasing ) StopFlame();
TickEnrage();
if ( Time.Now >= _nextThink )
{
_nextThink = Time.Now + 0.25f;
TickPace();
var want = WantFlame();
if ( want && !_flaming ) StartFlame();
else if ( !want && _flaming ) StopFlame();
}
if ( !_flaming || Time.Now < _nextBurn ) return;
_nextBurn = Time.Now + FlameTick;
// ⚠️ AND IT SETS THEM ALIGHT (2026-10-07): the one it reaches catches the Fire Margwa's burn, renewed by every tick. Its
// ticks are half a second (`MargwaBurn`), so a second's worth is held under twice the cap: no tick of it over `MaxHit`
BossFlame.Burn( GameObject, _flame.Nozzle, _flame.Aim, FlameReach * Size, FlameHalfWidth * Size, SwingDamage( FlameWeight ),
"brenner flame", burnSeconds: BurnSeconds, burnDps: MathF.Min( SwingDamage( BurnWeight ), MaxHit * 2f ) );
}
protected override void Died( bool host )
{
if ( host ) StopFlame();
}
protected override void OnBeat( int beat )
{
if ( beat != (int)Beat.Land ) return;
// `mech_spawn_land`: the thud, the dust, the shake
NZSound.PlayShared( "nz.panzer.land", WorldPosition );
NZNet.ShakeAt( WorldPosition, 0.5f, 1000f );
ShockRing.FireShared( WorldPosition + Vector3.Up * 4f, 160f * Size, new Color( 0.7f, 0.62f, 0.5f ) );
}
// ══ the host ═════════════════════════════════════════════════════════════
/// <summary>Walk near, run far (in sight within <see cref="WalkRange"/>) — until he enrages. THE HOST.</summary>
void TickPace()
{
if ( _enraged ) return;
var p = TargetPlayer;
var near = IsUp( p ) && WorldPosition.Distance( p.WorldPosition ) <= WalkRange * Size && CanSee( p.GameObject );
if ( _paceSet && near == _walking ) return;
_paceSet = true;
_walking = near;
ApplyPace();
}
void ApplyPace() => SetSpeed( _enraged ? SprintSpeed : _walking ? (_flaming ? FlameWalkSpeed : WalkSpeed) : RunSpeed );
bool WantFlame()
{
if ( Ai.State != ZombieState.Chasing ) return false;
var p = TargetPlayer;
if ( !IsUp( p ) ) return false;
var d = WorldPosition.Distance( p.WorldPosition );
return d >= FlameMin * Size && d < FlameMax * Size && CanSee( p.GameObject );
}
void StartFlame()
{
if ( _flaming ) return;
_flaming = true;
_nextBurn = Time.Now + 0.2f;
NZNet.BossFlameOn( GameObject.Id, true );
ApplyPace();
}
void StopFlame()
{
if ( !_flaming ) return;
_flaming = false;
NZNet.BossFlameOn( GameObject.Id, false );
if ( !Dead ) ApplyPace();
}
void TickEnrage()
{
if ( _enraged || !Hp.IsValid() || Hp.Max <= 1f || Hp.Current > Hp.Max * EnrageAt ) return;
if ( Ai.State is not (ZombieState.Chasing or ZombieState.Attacking) ) return;
Enrage();
}
/// <summary>Enraged, once: the roar, then the sprint for good. THE HOST.</summary>
void Enrage()
{
if ( _enraged ) return;
_enraged = true;
StopFlame();
PlayShared( "nz_fireman_stand_attack_berserk", 1.33f );
NZSound.PlayShared( "nz.brenner.charge", Voice );
SetSpeed( SprintSpeed );
Say( $"ENRAGED — sprinting at {Ai.SpeedOverride:0} u/s" );
}
/// <summary>His table: x<see cref="Resist"/> from anything but bullets and blades, and every hit may stagger him. THE HOST, once a hit.</summary>
protected override float ScaleDamage( float amount, in DamageInfo damage )
{
var weapon = (damage.Tags?.Has( SWB.Shared.TagsHelper.Bullet ) ?? false) || Health.IsMelee( damage );
TryStagger();
return weapon ? 1f : Resist;
}
void TryStagger()
{
if ( Ai.State is ZombieState.Special or ZombieState.Spawning or ZombieState.Dead ) return;
if ( Time.Now < _nextStagger || Game.Random.Float( 0f, 1f ) >= StaggerChance ) return;
_nextStagger = Time.Now + Game.Random.Float( StaggerGapMin, StaggerGapMax );
StopFlame();
var pick = Game.Random.Int( 0, 2 );
var clip = pick == 0 ? "nz_fireman_hit_react_head" : pick == 1 ? "nz_fireman_hit_react_left" : "nz_fireman_hit_react_right";
var len = pick == 0 ? 1.97f : pick == 1 ? 1.57f : 1.6f;
if ( !PlayShared( clip, len ) ) return;
NZSound.PlayShared( "nz.brenner.pain", Voice );
Say( $"staggered ({clip})" );
}
// ══ diagnostics ══════════════════════════════════════════════════════════
static IEnumerable<BrennerBoss> All
=> Game.ActiveScene?.GetAllComponents<BrennerBoss>() ?? Enumerable.Empty<BrennerBoss>();
/// <summary>`nz_brenner` — every Brenner alive, and the tuning.</summary>
[ConCmd( "nz_brenner" )]
public static void Report()
{
Log.Info( $"[nz-brenner] flame {FlameMin:0}-{FlameMax:0}u, {FlameReach:0} long, x{FlameWeight:0.###} a swing every {FlameTick:0.##}s"
+ $" · burns {BurnSeconds:0.#}s after at x{BurnWeight:0.###} a swing a second (renewed, never the last point)"
+ $" · walks within {WalkRange:0} · x{Resist:0.##} from all but bullets/blades · stagger {StaggerChance * 100f:0.#}%"
+ $" ({StaggerGapMin:0.#}-{StaggerGapMax:0.#}s) · enrages at {EnrageAt * 100f:0}% · hit cap {MaxHit:0}" );
var list = All.ToList();
Log.Info( $"[nz-brenner] {list.Count} alive" );
foreach ( var b in list )
Log.Info( $"[nz-brenner] hp {b.Hp?.Current ?? 0f:0}/{b.Hp?.Max ?? 0f:0} · state {b.Ai?.State} · speed {b.Ai?.MoveSpeed ?? 0f:0}"
+ $"{(b._enraged ? " · ENRAGED" : "")}{(b._flaming ? " · FLAMING" : "")}{(b._walking ? " · walking" : " · running")}"
+ $" · flame {b.SwingDamage( FlameWeight ):0.#} a tick, burn {MathF.Min( b.SwingDamage( BurnWeight ), MaxHit * 2f ):0.#}"
+ " a second"
+ $" · hitboxes {b.Ai?.HitboxCount ?? 0}" );
}
/// <summary>`nz_brenner_enrage` — every Brenner enraged now.</summary>
[ConCmd( "nz_brenner_enrage" )]
public static void EnrageCmd()
{
if ( NZGame.IsClient ) { Log.Info( "[nz-brenner] the host decides" ); return; }
foreach ( var b in All.Where( b => b.Ai.IsValid() && !b.Dead ).ToList() ) b.Enrage();
}
/// <summary>`nz_brenner_set <key> <value>` — retune him live.</summary>
[ConCmd( "nz_brenner_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "flamemin": FlameMin = value; break;
case "flamemax": FlameMax = value; break;
case "reach": FlameReach = value; break;
case "width": FlameHalfWidth = value; break;
case "flame": FlameWeight = value; break;
case "burn": BurnWeight = value; break;
case "burnseconds": BurnSeconds = value; break;
case "walk": WalkRange = value; break;
case "resist": Resist = value; break;
case "stagger": StaggerChance = value; break;
case "enrage": EnrageAt = value; break;
default:
Log.Info( "[nz-brenner] nz_brenner_set <flamemin|flamemax|reach|width|flame|burn|burnseconds|walk|resist|stagger|enrage>"
+ " <value>" );
return;
}
Log.Info( $"[nz-brenner] {key} = {value:0.###}" );
Report();
}
}