Zombies/BrutusHelmet.cs

Component on the Brutus zombie that models the helmet mechanic and two-phase fight. It tracks helmet HP as a share of the boss health, scales incoming damage (head vs body, helmeted vs bare), plays sounds/animation when helmet is hit or broken, flips renderer bodygroup, and exposes console commands to inspect and tune values at runtime.

File Access
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// Brutus's helmet, and the two-phase fight it creates.
///
/// | | helmet on | helmet off |
/// |---|---|---|
/// | **head hit** | **×0.015** — and only head hits chip the helmet | **×0.5** |
/// | **body hit** | **×0.15** | **×0.15** |
///
/// ⛔ THOSE FOUR NUMBERS ARE UPSTREAM'S, STRAIGHT OUT OF `PostTookDamage`, AND THEY ARE THE WHOLE
/// DESIGN. `nz_zombie_boss_brutus.lua` measures the hit against the `j_head` bone (within 12 units
/// counts as a head hit) and then calls `dmginfo:ScaleDamage` with exactly these: 0.015 on a
/// helmeted head, 0.5 on a bare head, 0.15 on the body. Ported faithfully by request; retune later.
///
/// ⛔ SO PHASE ONE IS DELIBERATELY UNREWARDING AND THAT IS NOT A BUG. While the helmet holds, the
/// head is the WORST place to shoot for damage (1.5%) and the ONLY place to shoot for progress —
/// because only head hits decrement the helmet, and they do so with the RAW, unscaled damage. You
/// spend a magazine making almost no impression on his health. Then the helmet breaks, the head
/// becomes the best target (50% against the body's 15%), and every headshot multiplier you own
/// switches on at once.
///
/// ⛔ AND THE BODY AT 15% IS WHAT MAKES HIM A BOSS, far more than his health pool. It is
/// effectively a ×6.7 multiplier on top of `round × 500 + 1000 × (players × 0.5)`. Porting the
/// health without the scalars would give a Brutus who dies in seconds; porting the scalars without
/// the health would still take a while. Both, or neither.
///
/// ⚠️ THE HELMET IS ITS OWN MESH, WHICH IS WHY THE BREAK HAS A VISUAL. The QC declares
/// `$bodygroup "helmet" { studio ... blank }`, so bodygroup 0 choice 1 is empty geometry — exactly
/// what `SetBodygroup(0,1)` does upstream. Our `brutus.vmdl` reproduces that as a `BodyGroupList`,
/// and `--no-join` on the model export exists solely to keep the two meshes separate for it.
/// </summary>
public sealed class BrutusHelmet : Component
{
	// ══ tuning ═══════════════════════════════════════════════════════════════
	//
	// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
	// re-run. INSTRUCTIONS.md §1.

	static float? _headScaleHelmeted;
	/// <summary>Damage a head hit deals while the helmet holds. 0.015 — upstream's.</summary>
	public static float HeadScaleHelmeted
	{
		get => _headScaleHelmeted ?? 0.015f;
		set => _headScaleHelmeted = value;
	}

	static float? _headScaleBare;
	/// <summary>Damage a head hit deals once the helmet is gone. 0.5 — upstream's.</summary>
	public static float HeadScaleBare
	{
		get => _headScaleBare ?? 0.5f;
		set => _headScaleBare = value;
	}

	static float? _bodyScale;
	/// <summary>Damage a body hit deals, always. 0.15 — upstream's.</summary>
	public static float BodyScale { get => _bodyScale ?? 0.15f; set => _bodyScale = value; }

	static float? _helmetShare;
	/// <summary>
	/// The helmet's own HP, as a share of his max health. 0.75 — upstream's.
	///
	/// ⚠️ IT IS A SHARE OF HEALTH, SO IT SCALES WITH THE ROUND automatically. Upstream sets
	/// `self.HelmetHP = self:Health() * 0.75` once, at spawn, from a health that is already
	/// round-scaled — so a round-30 Brutus has a round-30 helmet without a second curve.
	///
	/// ⚠️ 2/3, ON REQUEST. Upstream is 0.75; this sat at 1/3 for one revision. Since his health is
	/// now 15× a normal zombie's rather than a flat 1000, the helmet tracks that automatically —
	/// there is still only one health curve in play.
	///
	/// ⛔ THE POOL TAKES **FULL** DAMAGE AND ALWAYS HAS, which is easy to misread from `ScaleFor`.
	/// `Health.Apply` passes the PRE-SCALE amount, so the helmet sees the shot the player actually
	/// fired — rarity, Pack-a-Punch, Double Tap, every tech node — and specifically NOT the 1.5%
	/// helmeted-head reduction, which is the returned multiplier rather than the argument. The two
	/// reductions in that method (0.015 helmeted head, 0.15 body) scale what the BOSS takes; they
	/// never touch what the HELMET takes.
	/// </summary>
	public static float HelmetShare
	{
		get => _helmetShare ?? (2f / 3f);
		set => _helmetShare = value;
	}

	static float? _bareSpeed;
	/// <summary>
	/// His ABSOLUTE ground speed once the helmet is off, in units/sec. ≈ 100.
	///
	/// ⛔ THE HELMET-ON SPEED IS THE VARIANT'S `FixedSpeed`, NOT A SECOND FIELD HERE. One number
	/// per place: the spawn speed is authored data and lives in `brutus.zvar`; this one is only
	/// reachable at runtime, so it lives in code. Two copies of the same speed in two files is how
	/// they drift.
	///
	/// ⛔ BOTH ARE ABSOLUTE, WHICH IS THE WHOLE POINT — A BOSS DOES NOT SPEED UP WITH THE ROUND.
	/// This was a multiplier on `ExtraSpeedMultiplier` first, which meant his speed was whatever the
	/// round's walk clip happened to run at times 2. Round 11 Brutus and round 71 Brutus have to be
	/// the same creature.
	///
	/// ⚠️ UPSTREAM'S 36 → 72 CANNOT BE USED, and this is not a style choice. `AgentSpeed` clamps
	/// any non-zero speed up to `MinAgentSpeed` (42) and the navmesh agent does not move at all
	/// below roughly 35 — measured, not assumed — so 36 is inside a dead band and would be
	/// delivered as 42 anyway. 50 → 100 keeps upstream's 2× ratio with the slow end clear of the
	/// floor: helmeted he is slower than a walker (which is what makes him read as heavy), bare he
	/// is faster than anything else on the map.
	/// ⚠️ RAISED ×1.3 FROM 85 → 170 ON REQUEST. The tier mapping is unchanged: 110 is still inside
	/// the run tier (60–130) and 220 still inside sprint (130+), so he runs helmeted and sprints
	/// bare exactly as before — only the playback rates move, to 0.71 and 1.25.
	///
	/// ⚠️ THE BREAK MAKING HIM FASTER IS THE COST OF PHASE ONE, not a bonus. You earn the ability
	/// to hurt him by making him twice as quick.
	/// </summary>
	public static float BareSpeed { get => _bareSpeed ?? 220f; set => _bareSpeed = value; }

	// ══ live state ═══════════════════════════════════════════════════════════

	/// <summary>Shortest gap between two armour pings. See `ScaleFor`.</summary>
	public static float PingInterval { get; set; } = 0.06f;

	float _nextPing;

	/// <summary>Is the helmet still on.</summary>
	public bool HasHelmet { get; private set; } = true;

	/// <summary>What is left of the helmet.</summary>
	public float HelmetHp { get; private set; }

	/// <summary>What it started with, for the report.</summary>
	public float HelmetMax { get; private set; }

	Health _hp;
	ZombieAI _ai;

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

		// ⚠️ READ FROM `Max`, NOT `Current`. Upstream reads `self:Health()` immediately after
		// setting it, so the two are the same there; here a status effect or a stray hit landing
		// between spawn and this OnStart would make `Current` lower and hand the player a cheaper
		// helmet. `Max` cannot drift.
		HelmetMax = MathF.Max( 1f, (_hp?.Max ?? 1000f) * MathF.Max( 0f, HelmetShare ) );
		HelmetHp = HelmetMax;

		Log.Info( $"[nz-brutus] spawned — {_hp?.Max ?? 0f:0} hp,"
			+ $" helmet {HelmetMax:0} ({HelmetShare * 100f:0}%)"
			+ $" · head x{HeadScaleHelmeted:0.###} until it breaks, then x{HeadScaleBare:0.##}"
			+ $" · body x{BodyScale:0.##}" );
	}

	/// <summary>
	/// The damage multiplier for one incoming hit, and the helmet's share of it.
	///
	/// ⛔ IT MUTATES, SO IT MUST BE CALLED EXACTLY ONCE PER HIT. The helmet is decremented here
	/// rather than in a separate call, because two callers would mean either double-chipping or a
	/// scale computed against a helmet that has already absorbed the same bullet.
	///
	/// ⚠️ THE HELMET TAKES THE RAW DAMAGE, NOT THE SCALED DAMAGE, and that is upstream's order:
	/// `self.HelmetHP = self.HelmetHP - damage` runs BEFORE `dmginfo:ScaleDamage(0.015)`. If it
	/// took the scaled 1.5% instead, breaking a helmet worth 75% of his health would take about
	/// fifty times as long as intended — which would read as the helmet being invincible.
	/// </summary>
	public float ScaleFor( float raw, bool headshot )
	{
		// ⚠️ A BODY HIT IS 15% WHETHER OR NOT THE HELMET IS ON, and it never chips it. That is what
		// makes the head the only route through phase one.
		if ( !headshot ) return MathF.Max( 0f, BodyScale );

		if ( !HasHelmet ) return MathF.Max( 0f, HeadScaleBare );

		// ⛔ THE ARMOUR PING, AND IT IS THE ONLY FEEDBACK THAT YOU ARE HITTING THE HELMET. Without it
		// a helmeted headshot and a body shot are indistinguishable — both just chip a huge number
		// down invisibly. Upstream fires `MetalImpactSounds` here for exactly this reason.
		//
		// ⚠️ HIS GEAR CUE, NOT A METAL ONE. Upstream uses HL2's `physics/metal/*` which s&box does
		// not have; `nz.brutus.hit` is already mapped to `brutus_gear_00..04`, his own armour
		// rustle, which is the right material and is the sound this project actually owns. It is
		// also his melee-impact cue — one set serving both, rather than a fake.
		//
		// ⚠️ THROTTLED, because this runs per BULLET. Upstream has no limit and does not need one;
		// a fast weapon here would fuse the samples into a flat tone.
		if ( Time.Now >= _nextPing )
		{
			_nextPing = Time.Now + PingInterval;
			NZSound.Play( NZSound.BrutusHelmetHit, WorldPosition + Vector3.Up * 70f );
		}

		HelmetHp -= MathF.Max( 0f, raw );

		if ( HelmetHp <= 0f )
			Break();

		// ⚠️ THE HIT THAT BREAKS IT IS STILL SCALED AS HELMETED. Upstream's branch does the same:
		// the frame that finds `HelmetHP <= 0` runs the BREAK and returns, so the breaking bullet
		// deals no real damage. The reward is the next shot, not this one.
		return MathF.Max( 0f, HeadScaleHelmeted );
	}

	/// <summary>
	/// Knock the helmet off: hide the mesh, make him angry, tell the player.
	///
	/// ⚠️ BODYGROUP 0 CHOICE 1, matching `SetBodygroup(0,1)` upstream and the `blank` second state
	/// in the QC. `BodyGroups` is a packed bitmask on the renderer, and with one bodygroup of two
	/// choices the value for "off" is 1.
	/// </summary>
	void Break()
	{
		if ( !HasHelmet ) return;

		HasHelmet = false;
		HelmetHp = 0f;

		SetHelmetVisible( false );

		// ⛔ AN ABSOLUTE SPEED, NOT A MULTIPLIER. `SpeedOverride` short-circuits the whole
		// derivation — clip speed, the variant, every multiplier and `MinMoveSpeed` — so bare
		// Brutus moves at exactly `BareSpeed` on every round. A multiplier here composed with the
		// round curve, which is precisely what a boss must not do. Writing `MoveSpeed` is not an
		// option either: it is a computed property.
		//
		// ⚠️ AND IT NEEDS A REFRESH TO TAKE EFFECT. `_baseMoveSpeed` is only recomputed when
		// animations are re-picked, so setting the field alone would do nothing until the next tier
		// change. `RepickAnimations` exists for exactly this.
		if ( _ai.IsValid() )
		{
			_ai.SpeedOverride = MathF.Max( 1f, BareSpeed );
			_ai.RepickAnimations();
		}

		// ⛔ `gasattack`, NOT `enrage_start`. His helmet IS a gas mask — the clip's own sound events
		// are `cellbreaker_gas_rip` at frames 12 and 17 — and upstream's break block plays exactly
		// this: `self:DoSpecialAnimation("nz_base_zombie_cellbreaker_gasattack")`.
		//
		// ⚠️ `enrage_start` BELONGS TO SOMETHING ELSE AND IS DEAD UPSTREAM. It is the windup to the
		// rally/summon-dogs ability, which sets `AllowRallyAbility = false` at spawn and never sets
		// it true. It is not the helmet break, however much the name suggests it.
		//
		// ⚠️ AFTER the speed change, so the 1.17s he spends tearing it off is already at sprint
		// speed when `TickSpecial` hands him back.
		_ai?.PlaySpecial( "nz_base_zombie_cellbreaker_gasattack", 1.17f );

		// ⛔ 2D, NOT POSITIONAL — upstream plays both halves of this through `nzSounds:PlayFile`,
		// which is a CLIENT-SIDE 2D file, not `EmitSound`. It is a boss beat: you are meant to hear
		// the helmet come off wherever you are standing, the same way the spawn roar is 2D.
		//
		// ⚠️ AND IT IS WHY THIS COULD GO UNHEARD. Positionally it ran through a cue with
		// `DistanceAttenuation: false` and a 3600 range, so at any real distance it was there but
		// thin. Nothing was broken; it was simply the wrong kind of sound.
		Sound.Play( NZSound.BrutusHelmetBreak );

		Log.Info( $"[nz-brutus] HELMET BROKEN — head damage x{HeadScaleHelmeted:0.###}"
			+ $" → x{HeadScaleBare:0.##}, speed {BareSpeed:0} u/s" );
	}

	/// <summary>
	/// Show or hide the helmet mesh.
	///
	/// ⛔ `SetBodyGroup( name, choice )`, NEVER `BodyGroups = n`. That property is a packed `UInt64`
	/// whose layout is not one-bit-per-group — a freshly spawned Brutus reads **5**, not 0 — and
	/// writing a raw integer into it addresses whatever those bits happen to mean. Measured in the
	/// editor: 0, 1, 4 and 5 all render identically, so the raw write was not selecting a choice at
	/// all. The named call resolves the group and the choice itself; `swb_base/attachments` has
	/// been using it successfully all along.
	///
	/// ⚠️ THE GROUP AND CHOICE NAMES COME FROM THE `.vmdl`, and the model now mirrors the original
	/// QC's two bodygroups — `helmet` (`on` / `off`) and `body` (`default`). A single bodygroup with
	/// the body left loose compiled without warnings and still did nothing.
	/// </summary>
	public void SetHelmetVisible( bool on )
	{
		foreach ( var r in Components
			.GetAll<SkinnedModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
			r.SetBodyGroup( "helmet", on ? 0 : 1 );
	}

	/// <summary>
	/// `nz_brutus_helmet &lt;0|1&gt;` — show/hide the helmet on every Brutus, without breaking it.
	///
	/// ⚠️ SEPARATE FROM `nz_brutus_break`, which also flips the damage table, changes his speed and
	/// plays the gas-rip animation. This changes ONE thing, so what you see is attributable.
	/// </summary>
	[ConCmd( "nz_brutus_helmet" )]
	public static void HelmetCmd( int on = 1 )
	{
		var n = 0;

		foreach ( var b in Game.ActiveScene?.GetAllComponents<BrutusHelmet>()
			?? Enumerable.Empty<BrutusHelmet>() )
		{
			b.SetHelmetVisible( on != 0 );
			n++;
		}

		Log.Info( $"[nz-brutus] helmet {(on != 0 ? "ON" : "OFF")} on {n} Brutus(es)" );
	}

	/// <summary>
	/// The scale to apply to a hit on this object, or 1 when it is not a Brutus.
	///
	/// ⚠️ A STATIC LOOKUP SO `Health.Apply` NEED NOT KNOW WHAT A BRUTUS IS. Every other victim-side
	/// modifier in that method reads a component off the victim; this matches them.
	/// </summary>
	public static float ScaleOn( GameObject victim, float raw, bool headshot )
	{
		if ( !victim.IsValid() ) return 1f;

		var h = victim.Components.Get<BrutusHelmet>( FindMode.EverythingInSelfAndAncestors );

		return h.IsValid() ? h.ScaleFor( raw, headshot ) : 1f;
	}

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

	/// <summary>`nz_brutus` — every Brutus alive, and the resolved damage table.</summary>
	[ConCmd( "nz_brutus" )]
	public static void Report()
	{
		Log.Info( $"[nz-brutus] table — head x{HeadScaleHelmeted:0.###} helmeted"
			+ $" / x{HeadScaleBare:0.##} bare · body x{BodyScale:0.##} always"
			+ $" · helmet {HelmetShare * 100f:0}% of health · bare speed {BareSpeed:0} u/s" );

		var all = Game.ActiveScene?.GetAllComponents<BrutusHelmet>().ToList()
			?? new System.Collections.Generic.List<BrutusHelmet>();

		Log.Info( $"[nz-brutus] {all.Count} alive" );

		foreach ( var b in all )
		{
			var hp = b.Components.Get<Health>( FindMode.EverythingInSelfAndAncestors );

			// ⚠️ RESOLVED OUT OF LINE, not inside the interpolation. `{a ? b : c:0}` does not compile:
			// the ':' ends the hole rather than starting a format specifier.
			var told = (b._ai?.SpeedOverride ?? 0f) > 0f
				? b._ai.SpeedOverride
				: b._ai?.Variant?.FixedSpeed ?? 0f;

			Log.Info( $"[nz-brutus]   hp {hp?.Current ?? 0f:0}/{hp?.Max ?? 0f:0}"
				+ $" · helmet {(b.HasHelmet ? $"{b.HelmetHp:0}/{b.HelmetMax:0}" : "BROKEN")}"
				+ $" · speed {b._ai?.MoveSpeed ?? 0f:0} u/s"
					// ⛔ THE HITBOX COUNT IS IN THE REPORT BECAUSE ZERO IS SILENT. The helmet only takes
					// damage on a headshot, and a headshot only exists because a hitbox folds a
					// "head" tag into the DamageInfo — so a model with no hitbox set makes the
					// helmet literally unbreakable while every number here still reads correct.
					// Brutus shipped with 0 and it presented as "shooting the helmet does nothing".
					+ $" · hitboxes {b._ai?.HitboxCount ?? 0}"
						+ ((b._ai?.HitboxCount ?? 0) == 0 ? " ⛔ NO HEADSHOTS POSSIBLE" : "")
					+ $" (told {told:0}"
					+ $", agent {b._ai?.AgentSpeed ?? 0f:0})" );
		}

		// ⚠️ THE WORKED EXAMPLE IS PRINTED BECAUSE THE TABLE IS COUNTER-INTUITIVE — a player who
		// aims for the head and sees 1.5% will assume something is broken. Spelling out that this
		// is the intended phase-one experience is cheaper than re-deriving it every time.
		Log.Info( "[nz-brutus]   phase 1: shoot the HEAD to break the helmet (raw damage chips it,"
			+ " but only 1.5% reaches him). phase 2: the head becomes the best target." );
	}

	/// <summary>`nz_brutus_break` — knock every helmet off, to test phase two.</summary>
	[ConCmd( "nz_brutus_break" )]
	public static void BreakCmd()
	{
		var n = 0;

		foreach ( var b in Game.ActiveScene?.GetAllComponents<BrutusHelmet>()
			?? Enumerable.Empty<BrutusHelmet>() )
		{
			if ( !b.HasHelmet ) continue;
			b.Break();
			n++;
		}

		Log.Info( $"[nz-brutus] broke {n} helmet(s)" );
	}

	/// <summary>`nz_brutus_set &lt;key&gt; &lt;value&gt;` — retune the table live.</summary>
	[ConCmd( "nz_brutus_set" )]
	public static void SetCmd( string key = "", float value = 0f )
	{
		switch ( key.ToLowerInvariant() )
		{
			case "headon": HeadScaleHelmeted = value; break;
			case "headoff": HeadScaleBare = value; break;
			case "body": BodyScale = value; break;
			case "share": HelmetShare = value; break;
			// ⚠️ THE LIVE ONES TOO, so a retune is visible without respawning him. The variant's
			// `FixedSpeed` is shared by every Brutus, which is what you want when tuning.
			case "speedon":
				foreach ( var z in Game.ActiveScene?.GetAllComponents<BrutusHelmet>()
					?? Enumerable.Empty<BrutusHelmet>() )
				{
					if ( z._ai?.Variant is not null ) z._ai.Variant.FixedSpeed = value;
					if ( z.HasHelmet ) z._ai?.RepickAnimations();
				}
				break;

			case "speedoff":
				BareSpeed = value;
				foreach ( var z in Game.ActiveScene?.GetAllComponents<BrutusHelmet>()
					?? Enumerable.Empty<BrutusHelmet>() )
				{
					if ( z.HasHelmet ) continue;
					z._ai.SpeedOverride = value;
					z._ai.RepickAnimations();
				}
				break;

			default:
				Log.Info( "[nz-brutus] nz_brutus_set <headon|headoff|body|share|speedon|speedoff> <value>" );
				Log.Info( "[nz-brutus]   upstream: headon 0.015, headoff 0.5, body 0.15,"
					+ " share 0.75 (ours 0.333), speed 36 → 72 (we use 50 → 100, see BareSpeed)" );
				return;
		}

		Log.Info( $"[nz-brutus] {key} = {value:0.###}" );
		Report();
	}
}