Zombies/BrennerBoss.cs

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.

NetworkingFile Access
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) &lt;= 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 &lt;key&gt; &lt;value&gt;` — 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();
	}
}