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