A Razor UI panel for the in-game Survival HUD. It renders health, armor, stamina, weapon/ammo, points and teammate roster, perk icons, various augment meters (focus, killstreak, trigger charge, place charge), portrait handling and mounts many auxiliary HUD panels; it also updates per-frame elements in OnUpdate and writes per-frame style values for animated fills.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using NZombies;
@inherits PanelComponent
@*
SURVIVAL HUD — the in-game overlay.
Ported in STRUCTURE from the original's default skin, which is "Black Ops 1"
(Settings.hudtype). ⚠️ That skin is in nzDisplay.reworkedHUDs, so its
elements come from cl_hud_t5.lua — NOT from cl_hud.lua, which is the
fallback path for the non-reworked skins and has a different layout.
Layout follows t5's own anchors:
bottom-LEFT round counter (10, h-115)
bottom-RIGHT points + icon (w-230, h-248)
health bar (w-260, h-232)
weapon / ammo (w-400, h-200)
The health bar is on the RIGHT because nz_hud_health_style defaults to 0.
Setting it to 1 is what moves it to the left in the original.
Deliberately NOT included, because they are off by default in the original:
stamina bar (nz_hud_show_stamina 0) and the alive-zombie counter
(nz_hud_show_alive_counter 0). Both have data ready if we want them behind a
toggle later.
⚠️ Default font and placeholder point icon. The real HUD needs the Black Ops
font and the tally/digit/banner textures out of the GMod addon, which cannot
be extracted from here.
*@
<root class="hud @HudTheme.Class">
@if ( Visible )
{
<div class="left">
@* KILLSTREAK — Vigor Rush's M4, topmost of the three augment bars.
⚠️ REUSES `.focus`'s STYLES rather than getting its own, because the two are
the same object: a streak bar with a multiplier label. A third near-identical
block of SCSS is how one of them drifts.
⚠️ THE ICON IS DIFFERENT, though — a filled square rather than chevrons — so
two streak bars stacked on top of each other are still tellable apart at a
glance. That is the only thing distinguishing them when both are owned. *@
@if ( HasKillstreak )
{
<div class="meter">
<div class="streak-icon"><div class="pip"></div></div>
<div class="focus">
<div @ref="StreakFill" class="fill"></div>
</div>
<div class="focus-mult">@StreakText</div>
</div>
}
@* FOCUS — Deadshot's M4 streak, above the trigger charge.
⚠️ SAME SPLIT AS THE TRIGGER BAR: presence in the hash, fill written per
frame. Focus moves far more slowly (one step per headshot kill rather than
continuously), so a hash term would arguably be affordable here — but two bars
driven two different ways in one file is how one of them ends up wrong, and
the style write costs nothing.
⚠️ ABOVE the trigger charge, so both augment bars sit together at the top and
the permanent meters below them never move. Whichever of the two is owned, the
health bar stays where muscle memory expects it. *@
@if ( HasFocus )
{
<div class="meter">
@* A chevron pair — a "stacking" mark, distinct from the charge ring. *@
<div class="focus-icon">
<div class="chev a"></div>
<div class="chev b"></div>
</div>
@* ⚠ NO NUMBER ON THIS BAR, deliberately. The bar is the full 256 the
health and stamina bars use and reads as one of them; a figure on top
was the only thing making this row different, and the colour already
carries the one fact that matters (red at the cap). `FocusText` still
exists for `nz_hud_focus` — it is a diagnostic now, not a display. *@
<div @ref="FocusBar" class="focus">
<div @ref="FocusFill" class="fill"></div>
</div>
</div>
}
@* TRIGGER DISCIPLINE — Double Tap's M3 charge, ABOVE armor.
⛔ ONLY DRAWN WHEN THE AUGMENT IS OWNED. Every other meter here is a
permanent part of the HUD; this one appears and disappears with a purchase,
which is why it is the topmost row — a bar that inserts itself between two
existing ones would shove health and stamina down the screen the moment you
bought an augment, and muscle memory for where the health bar sits is worth
more than tidiness.
⛔ THE FILL WIDTH IS WRITTEN FROM `OnUpdate`, NOT FROM THIS MARKUP, and that
is the whole reason this bar is affordable. The charge changes EVERY FRAME —
2 seconds from full to empty while firing — so a width in the markup would
need `BuildHash` to track it, and this panel's hash is deliberately coarse
precisely so a firefight cannot thrash it. `CherryShockHud` documents the same
split for the same reason: a style write handles a per-frame value, a hash
rebuild does not.
⚠️ SO ONLY ITS *PRESENCE* IS IN THE HASH, via `HasTriggerCharge`. That flips
a handful of times a game; the fill moves sixty times a second. *@
@if ( HasTriggerCharge )
{
<div class="meter">
@* A crosshair-ish mark, drawn from two blocks like the boot — the
shipped font has no suitable glyph and emoji fall back to a box. *@
<div class="charge-icon">
<div class="ring"></div>
<div class="dot"></div>
</div>
<div class="charge">
<div @ref="ChargeFill" class="fill"></div>
</div>
</div>
}
@* ARMOR — above health, because it is consumed first.
⚠️ THREE SEGMENTS THAT TOGETHER MATCH THE HEALTH BAR'S WIDTH, not three
bars in a row of their own. A tier you have not bought reads as a
locked, dimmer slot, so the bar shows the CEILING you could reach as
well as what you are carrying — which is the whole point of a tiered
vest and is invisible on a single bar that just gets longer.
⚠️ Hidden entirely when armor is switched off in the config, rather
than drawn as three permanently empty slots that never do anything. *@
@if ( ArmorEnabled )
{
<div class="meter">
<div class="armor-icon"><div class="shield"></div></div>
<div class="armor">
@for ( var i = 0; i < ArmorTierMax; i++ )
{
<div class="seg @(ArmorTier > i ? "unlocked" : "locked")">
<div class="fill" style="width: @(ArmorSegmentPercent( i ))%;"></div>
</div>
}
</div>
@* Plates in reserve, to the right of the bar.
⚠️ ALWAYS SHOWN once armor is on, unlike the grenade count which
hides at zero. "0 plates" is information you act on — it is the
difference between looking for loot and not — whereas a zero
grenade count only tells you what you already know from the
weapon you are holding. *@
<div class="plates @(ArmorPlates > 0 ? "" : "empty")">
<div class="plate-icon"></div>
<span class="plate-count">@ArmorPlates</span>
</div>
</div>
}
<div class="meter">
@* Medical cross, so the bar is identifiable as health rather
than a nameless meter. *@
<div class="hp-icon">+</div>
<div class="health @(HealthLow ? "low" : "")">
<div class="fill" style="width: @(HealthPercent)%;"></div>
@* ⚠️ LEECH'S OVERHEAL (2026-10-04): gold over the fill, from the left, on the bar's own scale. *@
@if ( OverPercent > 0f )
{
<div class="over" style="width: @(OverPercent)%;"></div>
}
</div>
</div>
<div class="meter">
@* Boot, drawn from two blocks rather than a glyph — the shipped
font has no boot, and emoji fall back to a blank box. *@
<div class="boot">
<div class="shaft"></div>
<div class="foot"></div>
</div>
<div class="stamina @(StaminaLow ? "low" : "")">
<div class="fill" style="width: @(StaminaPercent)%;"></div>
</div>
</div>
@* ⛔ ONLY WHEN THERE IS SOMETHING TO PLACE. Health and stamina are always true of a
player; this bar is only meaningful with Banana Colada AND a major equipped, and a
permanent empty bar would read as a broken meter rather than an unused one. Armor's
plate row above takes the same conditional shape for the same reason. *@
@if ( ShowPlaceCharge )
{
<div class="meter">
@* A banana, from two blocks — the shipped font has no banana and emoji fall
back to a blank box, which is what the boot above already works around. *@
<div class="banana">
<div class="peel"></div>
</div>
<div class="place-charge @(PlaceReady ? "ready" : "") @(PlacePaused ? "paused" : "")">
<div class="fill" style="width: @(PlaceChargePercent)%;"></div>
</div>
</div>
}
</div>
<div class="right">
@* ⛔ THE ROSTER GOES ABOVE THE SCORE, AND THAT IS NOT A LAYOUT PREFERENCE.
`PointsPopupHud` is a SEPARATE PANEL with hardcoded offsets, and its own
stylesheet opens by saying they have to match this column's:
// so it cannot be positioned relative to `.points` — these offsets
// have to match ... .right { right: 40px; bottom: 48px; gap: 62px; }
`.right` is anchored to the BOTTOM and grows upward, so putting these rows
UNDER `.points` pushed the score up while the popups stayed where they were
— which is exactly what "+10" appearing down beside a team-mate's face was.
Above it, nothing between `.points` and the bottom changes, so the popups
still land on the number they belong to. *@
@foreach ( var s in Scores )
{
@if ( !s.Mine )
{
<div class="mate">
@* ⚠️ FADED OUT BY A KEYFRAME, NOT BY A TIMER. This panel only
rebuilds when its hash changes, so nothing would come back to
remove a "+50" once it appeared. The element is created on the
rebuild the award causes and the animation takes it away. *@
@if ( s.Gain > 0 )
{
<div class="gain">+@s.Gain</div>
}
@* ⚠️ NO NAME. The face already says who this is, and a Steam name is
the widest and least stable thing that could sit in a fixed HUD
column. Asked for directly: "we do not need names on the points
hud, the icon is enough." *@
<div class="portrait">
<img class="face" src="@s.Avatar" />
</div>
<div class="pts">@s.Points</div>
</div>
}
}
<div class="points">
<div class="portrait">
<img @ref=PortraitImg class="face" src="@PortraitSrc" />
</div>
<div class="value">@Points</div>
</div>
@* Round sits in the SAME ROW as the weapon, to its left. *@
<div class="lower">
@* ⚠️ Classes rather than an inline opacity. Driving opacity from C#
would need a rebuild every frame, and this panel's BuildHash is
deliberately coarse so a firefight cannot thrash it. The phases
change a handful of times a round; the ANIMATION runs in CSS. *@
<div class="round @(RoundIsRoman ? "roman" : RoundIsTally ? "tally" : "digits") @RoundPhaseClass">@RoundText</div>
<div class="weapon">
@* ⚠️ RARITY TINTS THE NAME, and Common deliberately does NOT —
WeaponNameStyle paints the HUD's own neutral colour at tier 0.
It used to return "" and let the SCSS stand, which did not work:
an inline style is applied, never cleared, so the previous
weapon's tint survived the switch. See WeaponNameStyle.
This is the original's own rule (`nzRarity.NameColor` returns the
caller's fallback for Common), and it matters more here than
anywhere: every weapon is Common until it is not, so tinting tier
0 would recolour the HUD permanently and the colour would stop
carrying information.
⚠️ Note this is the OPPOSITE choice from WeaponStatsPanel, which
shows COMMON in grey on purpose. That panel is opened to ask what
a gun is, so silence there is ambiguous; the HUD is always on
screen, so silence is the default state. *@
@* THE AMMO MOD BADGE SITS ON THE NAME ROW, not on its own line.
⚠️ IT REPLACED A TEXT CHIP that spelled the mod name out under
the ammo count. The name is the one thing the player already knows
(they just bought it) and the row it occupied was a whole line of
HUD for it. The badge is the upstream icon at name height.
⚠️ `ready` AND `unwired` MOVED WITH IT and still mean what they
meant: dimmed while the cooldown runs, so a 10%-per-hit mod can be
told from an unlucky streak; and marked when the effect is a
catalogue entry rather than something that can fire. *@
<div class="name-row">
<div class="name" style="@WeaponNameStyle">@WeaponName</div>
@if ( AmmoModIcon is not null )
{
<img class="mod-badge @(AmmoModReady ? "ready" : "") @(AmmoModWired ? "" : "unwired")"
src="@AmmoModIcon" title="@AmmoModName" />
}
</div>
@* SALVAGE SITS ON THE AMMO ROW, to the LEFT of the clip count.
⚠️ FIRST CHILD OF A RIGHT-ALIGNED ROW, which is what keeps the
ammo numbers still. `.weapon` is `align-items: flex-end`, so this row
is pinned by its RIGHT edge and grows leftward " — adding salvage
extends the left edge and the clip count does not move. Appended after
the numbers instead, it would have pushed them left the moment you
picked up your first salvage.
⚠️ IT WAS ITS OWN ROW UNDER THE AMMO before this. Folding it onto
the ammo line gives the column one line back, and salvage is a small
number that does not need a line of its own.
⚠️ STILL HIDDEN AT ZERO, for the reason it always was: nothing
spends salvage until the Arsenal exists, so a permanent "0" is a nag
for a currency with no use, and its APPEARANCE is the feedback that
the first pickup landed. *@
<div class="ammo">
@if ( SalvageEnabled && Salvage > 0 )
{
<div class="salvage">
<span class="salvage-icon"></span>
<span class="salvage-count">@Salvage</span>
</div>
}
@* ⚠️ THE WEAPON TECH TAG (2026-10-04): Select Fire's mode (AUTO / BURST / SEMI), the cylinder's bonus, the
grenade's charge… beside the clip, on the guns that have one. It flashes on a switch. *@
@if ( !string.IsNullOrEmpty( TechTag ) )
{
<span class="techtag @TechTagClass">@TechTag</span>
}
<span class="clip">@Clip</span>
<span class="sep">/</span>
<span class="reserve">@Reserve</span>
</div>
@if ( Reloading )
{
<div class="reloading">RELOADING</div>
}
</div>
@* Grenades, under the ammo — equipment rather than a third weapon
slot, so it reads as a separate line and not as another gun.
⚠️ HIDDEN AT ZERO. A permanent "✚ 0" is a nag; the count only
matters when you have some, and its reappearance IS the feedback
that a refill landed. *@
@if ( Grenades > 0 )
{
<div class="grenades @(Cooking ? "cooking" : "")">
<span class="nade-icon">✚</span>
<span class="nade-count">@Grenades</span>
</div>
}
</div>
</div>
}
@* "Press E - Clear Debris [Cost: 1,000]".
⚠️ OUTSIDE the Visible block on purpose. The HUD hides itself in Creative
and behind the lobby, but the prompt is how you find out a barrier can be
bought — and in Creative it is how you check the wording of the one you
just placed. It is a child Panel rather than its own PanelComponent so it
needs no GameObject and no scene edit; the last version was added to the
scene at runtime, never saved, and disappeared on restart. *@
<UsePromptPanel />
@* ── NAPALM IGNITION — below the crosshair ──────────────────────────────
The original draws hud_ignitionicon.png at (ScrW/2-32, ScrH/2-32) 64x128
and fades it over 2s (perks/sh_hooks.lua:1217).
⚠️ WE DO NOT MATCH THE ORIGINAL'S PLACEMENT, BY REQUEST. Dead centre puts
the icon over the thing you are shooting at the instant it fires. Ours is
half the size and sits at 66% of screen height — `.ignite` in the .scss
owns both numbers, so change them there, not here.
⚠️ STANDING IN WITH THE PERK'S OWN SHIELD. The real icon lives in a GMod
workshop pack and GMod is not installed on this machine, so it could not
be extracted — swap `ui/perks/fire.png` for `hud_ignitionicon.png` once
that content is reachable. Size and position come from the stylesheet
either way. *@
@if ( Player.IsValid() && Player.NapalmFlash > 0f )
{
<div class="ignite" @[email protected]>
<img src="ui/perks/fire.png" />
</div>
}
@* ── PERKS — bottom centre ──────────────────────────────────────────────
One row, centred, growing outward as they are bought. Same icons as the
Wunderfizz (ui/perks/<id>.png) at HUD size.
⚠️ The row is only rendered when there is something in it, so an empty
bar never reserves space at the bottom of the screen. *@
@if ( PerkIds.Count > 0 )
{
<div class="perks">
@foreach ( var id in PerkIds )
{
<img src="ui/perks/@(id).png" class="perk" />
}
</div>
}
</root>
@code
{
// ── source data ──────────────────────────────────────────────────────────
/// <summary>
/// MY player. Not "a" player.
///
/// ⛔ THIS WAS `GetAllComponents<NZPlayer>().FirstOrDefault()`, which in co-op is whichever
/// body the scene happens to list first — so a client could be shown the HOST's points, health,
/// ammo and perks while playing its own game. `AddPoints` has carried a warning about exactly
/// this pairing since before there were two players: *"NOT LOCAL-PLAYER GUARDED, and correct
/// today only because the HUD is not either... in co-op both need the same fix at the same
/// time."* This is that fix; `AddPoints` got its half in the same build.
///
/// ⚠️ `PlayerPresence.Find` IS THE PROJECT'S ONE ANSWER to "which body is mine", used by
/// `nz_see`, `nz_arms`, the spawner and the net tick. A second way of deciding it is a second
/// thing that can disagree.
/// </summary>
static NZPlayer Player
{
get
{
var go = PlayerPresence.Find();
return go.IsValid() ? go.Components.Get<NZPlayer>( FindMode.EverythingInSelf ) : null;
}
}
/// <summary>
/// Everyone's points — name, total, and whether it is me.
///
/// ⚠️ READ FROM `NZNet`, NOT FROM THE BODIES IN THE SCENE. `NZPlayer.Points` is a plain
/// component property, so this machine's copy of somebody else's body holds whatever it was
/// when it arrived and never changes again. The table is published by each owner on every
/// change; it is the only place another player's score is true.
///
/// ⚠️ MY OWN ROW COMES FROM MY OWN PLAYER rather than from the table, so it cannot lag a frame
/// behind my own award on my own screen.
/// </summary>
static List<(string Name, int Points, bool Mine, string Avatar, int Gain)> Scores
{
get
{
var list = new List<(string, int, bool, string, int)>();
if ( !Networking.IsActive ) return list;
var meId = Connection.Local?.Id ?? default;
foreach ( var c in Connection.All )
{
if ( c is null ) continue;
var mine = c.Id == meId;
var pts = mine ? (Player?.Points ?? 0) : NZNet.PointsOf( c.Id );
// ⚠️ THE SAME `avatar:` SCHEME `PortraitSrc` USES for my own face — the engine
// resolves it, so there is no second mechanism to keep working.
var avatar = c.SteamId == 0 ? "" : $"avatar:{c.SteamId}";
list.Add( (c.DisplayName ?? "?", pts, mine, avatar, GainFor( c.Id )) );
}
return list;
}
}
/// <summary>What each connection's total was last time we LOOKED, and its last gain.</summary>
///
/// ⛔ UPDATED ONCE A FRAME FROM `OnUpdate`, NEVER FROM A GETTER. The first version compared
/// and updated inside `Scores`, which `BuildHash` calls every frame — so the hash evaluation
/// consumed the change, `_seen` was already current by the time the tree was built, and the
/// "+N" the rebuild existed to show could never render. A getter that mutates is a getter
/// whose answer depends on who asked first.
static readonly Dictionary<Guid, int> _seen = new();
static readonly Dictionary<Guid, (int Amount, float When)> _gain = new();
/// <summary>
/// Notice what everyone's total did since last frame. Called from `OnUpdate` and nowhere else.
///
/// ⛔ THE DIFFERENCE IS RECONSTRUCTED HERE BECAUSE ONLY THE TOTAL CROSSES THE NETWORK.
/// `NZNet.PointsAre` carries a balance, not an award — deliberately, because a balance cannot
/// be lost or double-counted the way a stream of deltas can. What a player wants to SEE is the
/// difference, so it is worked out on the machine that is going to draw it.
///
/// ⚠️ GAINS ONLY. A drop is somebody spending, and "-1000" over a team-mate's face every time
/// they buy a door is noise, not information.
///
/// ⚠️ FIRST SIGHT IS NOT A GAIN, or a player joining with their starting points would flash
/// "+500" at everyone the moment they appeared.
/// </summary>
static void TrackGains()
{
if ( !Networking.IsActive ) return;
var meId = Connection.Local?.Id ?? default;
foreach ( var c in Connection.All )
{
if ( c is null ) continue;
var total = c.Id == meId ? (Player?.Points ?? 0) : NZNet.PointsOf( c.Id );
if ( !_seen.TryGetValue( c.Id, out var prev ) ) { _seen[c.Id] = total; continue; }
if ( total == prev ) continue;
_seen[c.Id] = total;
if ( total > prev ) _gain[c.Id] = (total - prev, RealTime.Now);
}
}
/// <summary>How much this player gained recently, or 0. Pure — see <see cref="TrackGains"/>.</summary>
static int GainFor( Guid id )
=> _gain.TryGetValue( id, out var g ) && RealTime.Now - g.When < 1.4f ? g.Amount : 0;
/// <summary>Perks owned, in purchase order — the HUD row's contents.</summary>
static List<string> PerkIds
{
get
{
var p = Player;
return p.IsValid() && p.Perks is not null ? p.Perks : new List<string>();
}
}
/// <summary>Perk row identity for the hash.
///
/// ⛔ THE JOINED IDS, NOT THE COUNT. Losing one perk and buying another in the
/// same frame leaves the count identical while the row is completely wrong —
/// and a displayed value the hash cannot see never rebuilds at all.</summary>
static string PerkKey => string.Join( ",", PerkIds );
/// <summary>Ignition count, for the hash.
///
/// ⛔ THE COUNT, NOT THE REMAINING TIME. The fade is a CSS animation, so the
/// tree needs rebuilding ONCE per ignition — hashing the countdown would
/// rebuild this panel every frame the icon was up, in the middle of a
/// firefight, which is exactly what this hash is kept coarse to avoid.</summary>
static int NapalmFlashes => Player.IsValid() ? Player.NapalmFlashCount : 0;
// ── PORTRAIT ─────────────────────────────────────────────────────────────
//
// The player's face beside their points. The original does this too —
// nz_hud_show_player_portrait, default 1, and it draws a TEXTURE there.
//
// ⚠️ A flat image, NOT a ScenePanel. This was a live render of a dressed
// body parked off-map; it worked, but it cost a scene render every frame
// for a 52px square and dragged a hidden GameObject, two lights and a
// clothing container along with it. A HUD icon does not need any of that.
Sandbox.UI.Image PortraitImg;
/// <summary>
/// Portrait art. Leave empty to fall back to the player's Steam avatar,
/// which is a real image and needs no asset shipped with the game.
/// </summary>
[Property] public string PortraitImage { get; set; } = "";
/// <summary>
/// ⚠️ Re-checked every frame rather than set once in OnTreeFirstBuilt. The
/// HUD root is behind an @if, so the img is DESTROYED and rebuilt whenever
/// visibility flips (entering the lobby and back). A one-shot setup leaves
/// the rebuilt element with no texture — an empty frame that reads as a
/// broken portrait.
///
/// ⛔ SET IN THE MARKUP, NOT FROM CODE. Three attempts failed before this and
/// all three were the same mistake — assigning `PortraitImg.Texture` from
/// OnUpdate and assuming the element would repaint.
///
/// `nz_hud_portrait` settled it. With the texture being re-applied every
/// single frame it reported:
///
/// Game.SteamId: 76561198210864564 <- id is fine
/// LoadAvatar -> 184x184 <- texture is fine
/// portrait element has a texture: True <- it IS on the element
///
/// ...and the portrait was still blank. So the id was never the problem, the
/// avatar was never the problem, and applying it more often could not have
/// helped: an imperative `.Texture =` does not make the panel repaint, and
/// only a tree rebuild did. That is the whole reason reloading "fixed" it —
/// Clip is in BuildHash.
///
/// Putting the source in the markup makes the texture part of the build, so
/// there is nothing left to repaint out of sync. `PortraitSrc` feeds
/// BuildHash, so the tree rebuilds by itself the moment the id arrives.
///
/// ⚠️ The @ref is kept for `nz_hud_portrait` only. Nothing reads it to draw.
/// </summary>
/// <summary>
/// The portrait's image source, resolved in the MARKUP.
///
/// Custom art if one is set, otherwise the Steam avatar via the engine's
/// `avatar:` scheme — the same string form `PortraitImage` already used, so
/// no new mechanism. Empty until the id exists, and because this feeds
/// BuildHash the tree rebuilds by itself the moment it does.
/// </summary>
string PortraitSrc
{
get
{
if ( !string.IsNullOrWhiteSpace( PortraitImage ) ) return PortraitImage;
long id = HudState.LocalSteamId;
return id == 0L ? "" : $"avatar:{id}";
}
}
protected override void OnUpdate()
{
// ⚠️ The weapon stats card is attached HERE rather than added to the
// scene file. A PanelComponent needs a ScreenPanel ancestor and this
// GameObject already has one, so borrowing it costs nothing — and
// editing countdown.scene from a script is how scenes get corrupted.
// GetOrCreate is idempotent, so running it every frame is a no-op after
// the first.
// ⚠️ ONCE A FRAME, BEFORE ANYTHING READS `Scores`. See `TrackGains` for why this cannot
// live in the getter that uses it.
TrackGains();
Components.GetOrCreate<WeaponStatsPanel>();
Components.GetOrCreate<DamageNumbersHud>();
// ⚠️ THE ZOMBIE HEALTH BARS (2026-10-06), mounted here like the rest: the brain (`ZombieHealthBars`, which keeps out of the
// lobby by its own rule and writes what to draw each frame) and the panels that draw it (`ZombieHealthBarsHud`).
Components.GetOrCreate<ZombieHealthBars>();
Components.GetOrCreate<ZombieHealthBarsHud>();
Components.GetOrCreate<DamageOverlay>();
// ⚠️ ATTACHED HERE LIKE THE BLOOD VIGNETTE, and for the same reason — it borrows this
// GameObject's ScreenPanel so it needs no scene edit, and it sits on its own layer behind
// the HUD rather than inside it.
Components.GetOrCreate<AshOverlay>();
// ⚠️ MOUNTED BESIDE THE ASH FOR THE SAME REASON: a PanelComponent has to exist before it
// can draw, and this one is silent until a napalm zombie is near — so there is nothing
// to gate it on. `nz_napalm_overlay 0` turns it off.
Components.GetOrCreate<NapalmOverlay>();
// ⛔ THE SHRIEKER'S OVERLAY WAS BUILT AND NEVER MOUNTED, so it could not have drawn a
// single frame. A PanelComponent that nothing creates is not a disabled feature, it is an
// absent one — and it looks exactly like a working feature until somebody goes to use it.
Components.GetOrCreate<SonicDazeOverlay>();
Components.GetOrCreate<DownedHud>();
// ⚠️ The revive markers over downed teammates, attached the same way — it borrows this
// GameObject's ScreenPanel so it needs no scene edit. It must draw independently of this
// HUD's `Visible` for the same reason the powerup banner does: a teammate on the floor is
// not less urgent because the survival HUD happens to be hidden.
Components.GetOrCreate<ReviveHud>();
Components.GetOrCreate<PlayerTagsHud>();
// ⚠️ Attached here like the rest — it borrows this GameObject's ScreenPanel
// so it needs no scene edit. It draws itself independently of this HUD's
// `Visible`, so a powerup taken in creative still announces itself.
Components.GetOrCreate<PowerupBannerHud>();
Components.GetOrCreate<PowerupTimerHud>();
// ⚠️ Elemental Pop's discharge, attached the same way — it borrows this
// GameObject's ScreenPanel so it needs no scene edit either. Like the powerup
// banner it draws independently of this HUD's `Visible`, because the perk can
// fire in creative where the survival HUD is hidden.
Components.GetOrCreate<CherryShockHud>();
// ⚠️ The teleport transit overlay, attached the same way and for the same reason — it
// borrows this GameObject's ScreenPanel, and it must draw in creative too, where a map
// author is riding their own pads with this HUD hidden.
//
// ⚠️ It plays CherryShockHud's frames. Separate panel, shared assets — see its header.
Components.GetOrCreate<TeleportOverlay>();
// ⚠️ BASALT'S BOSS BAR, attached the same way — its own panel, drawn whatever this HUD's `Visible` says, since the fight
// can be played in creative.
Components.GetOrCreate<BossBarHud>();
// ⚠️ THE ROUND BAR, attached the same way — this round's zombies killed of how many, top centre, where the boss bar
// takes its place while basalt's fight is on (`RoundBarHud`).
Components.GetOrCreate<RoundBarHud>();
// ⚠️ THE ROOM NAME, attached the same way — top left, the name of the flag whose doorway this player last walked through
// (`RoomNameHud`). It also does the following (`RoomNames.Tick`), so it is mounted whatever this HUD's `Visible` says.
Components.GetOrCreate<RoomNameHud>();
// ⚠️ BASALT'S ROUND, CARVED IN, attached the same way — a new round's numeral struck in the middle of the screen for a
// moment (`RoundCarveHud`); nothing on any other map.
Components.GetOrCreate<RoundCarveHud>();
// ⚠️ THE OPENING CARD, attached the same way — the map's name, its place and the round, typed in the lower left as a game
// fades up (`IntroCardHud`, 2026-09-28).
Components.GetOrCreate<IntroCardHud>();
// ⚠️ BASALT'S EASTER EGG STEP BANNER, attached the same way — a step done, its number and name in the upper middle of
// every screen (`EggStepBannerHud`, 2026-09-29); nothing on any other map.
Components.GetOrCreate<EggStepBannerHud>();
// Diagnostics only — nothing here loads the portrait any more.
HudState.RefreshPortrait = () => { };
HudState.PortraitLoaded = () => PortraitImg?.Texture is not null;
HudState.PortraitElement = () => PortraitImg is not null;
TickRoundNumber();
TickTriggerCharge();
TickFocus();
TickKillstreak();
}
/// <summary>
/// Drive Vigor Rush's Killstreak bar.
///
/// ⚠️ THE COLOUR BANDS ARE DIFFERENT FROM FOCUS'S, and deliberately so. Focus turns RED
/// at the cap because a capped Focus is fragile — the next body-shot kill throws it away.
/// Killstreak is fragile the WHOLE time (any hit wipes it), so there is no moment worth
/// singling out; it just gets hotter as it climbs.
/// </summary>
void TickKillstreak()
{
if ( StreakFill is null ) return;
var pct = Player.IsValid()
? VigorAugments.KillstreakProgress( Player ) * 100f
: 0f;
StreakFill.Style.Width = Length.Percent( pct );
StreakFill.Style.BackgroundColor = pct >= 66f
? new Color( 1f, 0.45f, 0.15f )
: pct >= 25f
? new Color( 0.95f, 0.65f, 0.3f )
: new Color( 0.55f, 0.5f, 0.45f );
}
/// <summary>
/// Drive Deadshot's Focus bar.
///
/// ⚠ THE FILL IS WRITTEN HERE, NOT THROUGH THE HASH, and now that the bar carries no
/// number that is the only path there is: `HasFocus` in `BuildHash` decides whether the bar
/// EXISTS, and this decides how full it is and what colour. Putting the fill in the hash
/// would rebuild the whole tree on every headshot to move one width.
/// </summary>
void TickFocus()
{
if ( FocusFill is null ) return;
var pct = Player.IsValid()
? DeadshotAugments.FocusProgress( Player ) * 100f
: 0f;
FocusFill.Style.Width = Length.Percent( pct );
// ⚠️ THREE BANDS, matching the charge bar's convention so the two read as one
// family. Red at the cap because a capped Focus is the moment to be careful — the
// next body-shot kill throws all 27 away.
FocusFill.Style.BackgroundColor = pct >= 99f
? new Color( 1f, 0.35f, 0.3f )
: pct >= 40f
? new Color( 1f, 0.72f, 0.25f )
: new Color( 0.6f, 0.55f, 0.45f );
}
/// <summary>
/// Drive the trigger-charge bar.
///
/// ⛔ A STYLE WRITE, NOT A HASH TERM. The charge moves every frame; at 60fps a hash
/// that tracked it would rebuild this whole tree — portrait, weapon name, round tally
/// and all — sixty times a second while firing. The bar's own comment in the markup
/// records the same split `CherryShockHud` uses.
///
/// ⚠️ NULL-CHECKED EVERY FRAME rather than cached, because the element genuinely does
/// not exist most of the time: the markup omits it entirely until M3 is bought, and it
/// vanishes again on a down that costs the perk.
///
/// ⚠️ THE COLOUR IS WRITTEN TOO, not left to a class. A class would need the hash to
/// change to be applied, which is the exact cost this method exists to avoid — so the
/// full/empty tint is set here alongside the width.
/// </summary>
void TickTriggerCharge()
{
if ( ChargeFill is null ) return;
var pct = TriggerChargePercent;
ChargeFill.Style.Width = Length.Percent( pct );
// ⚠️ THREE BANDS, NOT A GRADIENT ACROSS THE RANGE. The player needs to answer "is
// it worth firing yet" at a glance, which is a threshold question; a continuous
// hue makes every value look like a different answer.
ChargeFill.Style.BackgroundColor = pct >= 99f
? new Color( 1f, 0.84f, 0.2f ) // full — spend it
: pct >= 40f
? new Color( 0.45f, 0.78f, 1f ) // usable
: new Color( 0.42f, 0.46f, 0.56f ); // spent, rebuilding
}
/// <summary>
/// Hidden in the lobby, and hidden while the lobby overlay is up.
///
/// The original gates every element on ply:IsNZMenuOpen() for the same
/// reason — a HUD burned into a menu screenshot reads as a bug.
/// </summary>
bool Visible
{
get
{
if ( NZGame.Mode == GameMode.Lobby ) return false;
if ( LobbyState.IsOpen?.Invoke() == true ) return false;
return Player.IsValid();
}
}
int Round => RoundManager.Instance?.Round ?? 0;
int Points => Player?.Points ?? 0;
// ── round counter ────────────────────────────────────────────────────────
/// <summary>
/// Rounds 1-5 are TALLY MARKS, 6+ are digits — the CoD convention, and the
/// original implements it literally as string.rep("i", n) clamped to 5
/// (cl_hud.lua AddStroke). Crossing 5→6 wipes and rebuilds as digits.
///
/// "iiiii" here rather than a texture: the real HUD draws a stroke image per
/// mark. Roman-ish strokes in the default font are the honest stand-in until
/// the textures are extracted.
/// </summary>
// ⚠️ Reads the DISPLAYED number, not the live one — otherwise the tally/digits
// styling would switch a third of a second before the glyph it styles, and
// round 6 would briefly render "IIIII" at digit size.
bool RoundIsTally => _shown >= 1 && _shown < 6;
/// <summary>
/// Basalt's carved HUD reads rounds I to IX in Roman numerals and digits from round 10 (`HudTheme.RomanFor`). ⚠️ THE
/// DISPLAYED NUMBER TOO, for the reason `RoundIsTally` gives: the class and the glyph must change in the same frame, at the
/// bottom of the cross-fade.
/// </summary>
bool RoundIsRoman => HudTheme.RomanFor( _shown );
// ── the round number's phases ────────────────────────────────────────────
//
// ⛔ THE DISPLAYED NUMBER LAGS THE REAL ONE ON PURPOSE. A cross-fade needs the
// OLD number to still be on screen while it fades out — reading `Round` directly
// would swap the glyph instantly and fade in the new one from nothing, which
// looks like a flicker rather than a transition.
int _shown = -1;
bool _fadingOut;
bool _fadingIn;
TimeSince _sinceFade;
/// <summary>Half of the cross-fade: out, then in. 0.35s each.</summary>
const float FadeTime = 0.35f;
void TickRoundNumber()
{
// First frame — adopt the number without animating. Fading in from nothing
// on spawn would make the HUD look like it was mid-transition on arrival.
if ( _shown < 0 ) { _shown = Round; return; }
if ( !_fadingOut && !_fadingIn && _shown != Round )
{
_fadingOut = true;
_sinceFade = 0f;
}
else if ( _fadingOut && _sinceFade >= FadeTime )
{
// ⚠️ The swap happens at FULL TRANSPARENCY, which is the whole point of
// splitting the fade in two — the glyph changing must not be visible.
_shown = Round;
_fadingOut = false;
_fadingIn = true;
_sinceFade = 0f;
}
else if ( _fadingIn && _sinceFade >= FadeTime )
{
_fadingIn = false;
}
}
/// <summary>Between rounds — the number goes white and breathes.</summary>
bool Prepping => RoundManager.Instance?.State == RoundState.Prep && _shown >= 1;
/// <summary>
/// Which visual state the number is in.
///
/// ⚠️ THE CROSS-FADE OUTRANKS THE PULSE. Both can be true at the moment a round
/// starts — prep is ending as the number changes — and running the pulse
/// animation at the same time as the fade would have two rules fighting over
/// opacity, which in practice means whichever CSS wins, not what was intended.
/// </summary>
string RoundPhaseClass =>
_fadingOut ? "fade-out"
: _fadingIn ? "fade-in"
: Prepping ? "prep"
: "";
string RoundText => _shown < 1
? ""
// ⚠️ BASALT READS ROUNDS I TO IX IN ROMAN NUMERALS, then digits — its own hex slots number theirs I to IV
: RoundIsRoman ? HudTheme.ToRoman( _shown )
: _shown < 6 ? new string( 'I', _shown ) : _shown.ToString();
// ── vigor killstreak ─────────────────────────────────────────────────────
/// <summary>The bar's fill element. Written per frame, never rebuilt.</summary>
Panel StreakFill { get; set; }
/// <summary>
/// Is the bar drawn — does this player own Vigor Rush's M4.
///
/// ⚠️ ASKS THE AUGMENT, not the streak, for the reason `HasFocus` records: a
/// streak-based test would hide the bar at zero, which is exactly when the player most
/// needs to see that being hit wiped it.
/// </summary>
bool HasKillstreak
=> Player.IsValid() && PerkAugments.Has( Player, "vigor", "M4" );
/// <summary>The multiplier, for the label.</summary>
string StreakText
=> Player.IsValid()
? $"×{VigorAugments.KillstreakScale( Player ):0.##}"
: "";
// ── deadshot focus ───────────────────────────────────────────────────────
/// <summary>The bar's fill element. Written per frame, never rebuilt.</summary>
Panel FocusFill { get; set; }
/// <summary>
/// The Focus bar's own panel, for `nz_hud_focus`.
///
/// ⛔ A REF PURELY TO MEASURE IT. "The multiplier shows but there is no bar" has at
/// least four causes that look identical in a screenshot - a zero-width box, a box drawn
/// off the row, a transparent background, or a panel that is not in the tree at all - and
/// §6 in INSTRUCTIONS.md is specifically about not trying to tell those apart by eye.
/// `Box.Rect` answers it outright.
/// </summary>
Panel FocusBar { get; set; }
/// <summary>
/// `nz_hud_focus` - the Focus row's real geometry, as the layout engine resolved it.
/// </summary>
[ConCmd( "nz_hud_focus" )]
public static void FocusBoxCmd()
{
var hud = Game.ActiveScene?.GetAllComponents<SurvivalHud>().FirstOrDefault();
if ( !hud.IsValid() ) { Log.Warning( "[nz-hud] no HUD component" ); return; }
Log.Info( $"[nz-hud] HasFocus {hud.HasFocus} · text {hud.FocusText}" );
if ( hud.FocusBar is null ) { Log.Warning( "[nz-hud] focus bar panel is NULL - not in the tree" ); return; }
var r = hud.FocusBar.Box.Rect;
Log.Info( $"[nz-hud] bar rect x {r.Left:0} y {r.Top:0} w {r.Width:0} h {r.Height:0}" );
if ( hud.FocusFill is not null )
{
var f = hud.FocusFill.Box.Rect;
Log.Info( $"[nz-hud] fill rect x {f.Left:0} y {f.Top:0} w {f.Width:0} h {f.Height:0}" );
}
// ⚠ A SIBLING THAT IS KNOWN TO RENDER, for comparison. An absolute rect means
// nothing on its own; the question is whether this bar sits where the stamina bar
// sits and is the same size.
// ⛔ THE RESOLVED STYLE, NOT THE AUTHORED ONE. The stylesheet says `width: 256px`
// and the box measures 26 logical - so the question is whether the rule is reaching
// this panel at all, and only the engine can answer that. Guessing at it burned four
// rounds.
Log.Info( $"[nz-hud] classes [{string.Join( " ", hud.FocusBar.Class )}]" );
Log.Info( $"[nz-hud] computed width {hud.FocusBar.ComputedStyle?.Width}"
+ $" · height {hud.FocusBar.ComputedStyle?.Height}"
+ $" · position {hud.FocusBar.ComputedStyle?.Position}" );
// ⛔ EVERY BAR CLASS IN STYLESHEET ORDER. `.stamina` resolves and `.focus` does not,
// and both are nested identically inside `.hud` with balanced braces - so the rule is
// being DROPPED somewhere between them. Printing each class in file order puts the
// cliff between two known line numbers instead of leaving it "somewhere in the file".
foreach ( var (name, line) in new[]
{
("armor", 288), ("health", 379), ("stamina", 403), ("weapon", 426),
("charge", 564), ("focus", 627),
} )
{
var p = hud.Panel?.Descendants?.FirstOrDefault( d => d.HasClass( name ) );
Log.Info( $"[nz-hud] scss:{line,4} .{name,-12}"
+ (p is null
? " (no such panel on screen)"
: $" width {p.ComputedStyle?.Width}") );
}
var stam = hud.Panel?.Descendants?.FirstOrDefault( p => p.HasClass( "stamina" ) );
if ( stam is not null )
{
var s2 = stam.Box.Rect;
Log.Info( $"[nz-hud] stamina x {s2.Left:0} y {s2.Top:0} w {s2.Width:0} h {s2.Height:0}"
+ " <- the bar this one should match" );
Log.Info( $"[nz-hud] stamina computed width {stam.ComputedStyle?.Width}" );
}
}
/// <summary>
/// Is the bar drawn — does this player own Deadshot's M4.
///
/// ⚠️ ASKS THE AUGMENT, not the streak. A streak-based test would hide the bar at zero,
/// which is exactly when the player most needs to see that they lost it.
/// </summary>
bool HasFocus
=> Player.IsValid() && PerkAugments.Has( Player, "deadshot", "M4" );
/// <summary>The multiplier, for the label.</summary>
string FocusText
=> Player.IsValid()
? $"×{DeadshotAugments.FocusMultiplier( Player ):0.##}"
: "";
// ── trigger discipline ───────────────────────────────────────────────────
/// <summary>The bar's fill element. Written per frame, never rebuilt.</summary>
Panel ChargeFill { get; set; }
/// <summary>
/// The weapon whose charge is being shown.
///
/// ⚠️ THE HELD WEAPON, and the charge is per weapon — so under Mule Kick this bar
/// follows whatever is in your hands rather than showing a total. That is correct: the
/// number it reflects is the one that will be spent by the next trigger pull.
/// </summary>
SWB.Base.Weapon ChargeWeapon
=> Player?.Components.Get<SWB.Base.Weapon>( FindMode.EverythingInDescendants );
/// <summary>
/// Is the bar drawn at all — does this player own Double Tap's M3.
///
/// ⚠️ ASKS THE AUGMENT, NOT THE WEAPON. `Weapon.TriggerCharge` ticks whether or not M3
/// is equipped (deliberately, so buying it mid-fight does not hand you an empty
/// charge), so a weapon-side test would draw a full bar for every player in the game.
/// </summary>
bool HasTriggerCharge
=> Player.IsValid() && PerkAugments.Has( Player, "dtap", "M3" );
/// <summary>0-100, for the fill width.</summary>
float TriggerChargePercent
{
get
{
var w = ChargeWeapon;
return w.IsValid() ? w.TriggerCharge * 100f : 0f;
}
}
// ── armor + salvage ──────────────────────────────────────────────────────
/// <summary>Drawn at all? Follows the config's master switch.</summary>
static bool ArmorEnabled => ActiveConfig.Armor.Enabled;
/// <summary>How many segments to draw — the highest buyable tier.</summary>
static int ArmorTierMax => System.Math.Max( 1, ActiveConfig.Armor.MaxTier );
int ArmorTier => Player?.ArmorTier ?? 0;
int ArmorPlates => Player?.ArmorPlates ?? 0;
/// <summary>
/// How full one segment is, 0-100.
///
/// ⚠️ ARMOR IS ONE POOL, DRAWN ACROSS SEGMENTS — it is not one pool per tier.
/// Segment i covers the band [i*barSize, (i+1)*barSize), so an armor value part-way up
/// fills whole segments and part of the next. Giving each segment its own
/// pool would need three counters on the player and would drain in an order
/// nothing else in the system agrees with.
/// </summary>
float ArmorSegmentPercent( int index )
{
var p = Player;
if ( !p.IsValid() ) return 0f;
// ⚠️ Armor.BarSize, NOT a config field. A bar is now per-TIER (tier 3's bars are bigger
// than tier 1's) and includes Jugg m3, so only Armor can answer how wide a segment is.
var per = Armor.BarSize( p );
if ( per <= 0f ) return 0f;
return MathX.Clamp( (p.Armor - index * per) / per * 100f, 0f, 100f );
}
static bool SalvageEnabled => ActiveConfig.Salvage.Enabled;
int Salvage => Player?.Salvage ?? 0;
// ── health ───────────────────────────────────────────────────────────────
float HealthPercent
{
get
{
var hp = Player?.Hp;
if ( !hp.IsValid() || hp.Max <= 0f ) return 0f;
return MathX.Clamp( hp.Current / hp.Max * 100f, 0f, 100f );
}
}
/// <summary>Turns red near death. The original's cue is a screen-edge blood
/// overlay rather than bar colour, but we have no overlay yet and an
/// unchanging bar gives no warning at all.</summary>
bool HealthLow => HealthPercent <= 35f;
/// <summary>
/// Leech's overheal (`Health.Over`) as a share of the max, so it reads on the bar's own scale: +50 on a 150 max is a third.
/// </summary>
float OverPercent
{
get
{
var hp = Player?.Hp;
if ( !hp.IsValid() || hp.Max <= 0f ) return 0f;
return MathX.Clamp( hp.Over / hp.Max * 100f, 0f, 100f );
}
}
// ── stamina ──────────────────────────────────────────────────────────────
//
// ⚠️ ON by default here, OFF in the original (nz_hud_show_stamina 0). That
// is a deliberate difference, not an oversight: s&box has no stamina at
// all, so ours is a system we added and a hidden bar would leave the player
// with no idea why sprinting stopped.
static Stamina Meter => Player?.Components.Get<Stamina>();
float StaminaPercent => MathX.Clamp( (Meter?.Fraction ?? 0f) * 100f, 0f, 100f );
/// <summary>
/// Is there a placeable to charge toward.
///
/// ⚠️ IT ASKS `KindFor`, NOT `HasPerk`. The perk alone grants longer slides and no placeable, so
/// a player on base Banana Colada has nothing this bar could describe — and
/// `BananaAugments.OnZombieKilled` refuses to bank charge in exactly that case, so the bar and
/// the meter agree about when they exist rather than showing a bar that can never move.
/// </summary>
static bool ShowPlaceCharge
{
get
{
var p = Player;
return p.IsValid() && BananaAugments.KindFor( p ) is not null;
}
}
float PlaceChargePercent
=> MathX.Clamp( (Player?.PlaceCharge ?? 0f) * 100f, 0f, 100f );
/// <summary>
/// Full, so B will actually do something.
///
/// ⚠️ THE THRESHOLD IS THE SAME `>= 1f` `WhyCannotPlace` USES. A bar that looked ready while the
/// key still refused would be the worst version of this feature, so both read the one number
/// rather than one comparing against 0.99.
/// </summary>
bool PlaceReady => (Player?.PlaceCharge ?? 0f) >= 1f;
/// <summary>
/// One of yours is out, so kills do not charge the next (2026-10-04). The bar dims rather than looking stuck.
///
/// ⚠️ ONLY ASKED WHEN THE BAR IS SHOWN, so a player without the perk pays nothing for it.
/// </summary>
bool PlacePaused => ShowPlaceCharge && BananaAugments.HasOneOut( Player );
/// <summary>Flags the state that actually matters — sprint is blocked, and
/// the player needs to know that rather than guess.</summary>
bool StaminaLow => Meter?.Exhausted ?? false;
// ── weapon ───────────────────────────────────────────────────────────────
/// <summary>
/// The active weapon, via the inventory rather than GetInChildren — a player
/// can carry several and only the active one belongs on the HUD.
/// </summary>
static NZWeapon ActiveWeapon
{
get
{
var p = Player;
if ( !p.IsValid() ) return null;
return p.Components.GetAll<NZWeapon>( FindMode.EverythingInSelfAndDescendants )
.FirstOrDefault( w => w.IsActive );
}
}
/// <summary>
/// The SWB weapon, which is what a ported gun is.
///
/// ⚠️ BOTH LOOKUPS EXIST ON PURPOSE, and it is not "two weapon systems".
/// NZWeapon is referenced by countdown.scene and a .scene is never rewritten
/// from a script, so it stays until the object is deleted by hand in the
/// editor. Every readout below prefers SWB and falls back — so a migrated
/// scene shows the real gun and an un-migrated one keeps working.
/// </summary>
static SWB.Base.Weapon SwbWeapon
{
get
{
var p = Player;
if ( !p.IsValid() ) return null;
return p.Components.GetAll<SWB.Base.Weapon>( FindMode.EverythingInSelfAndDescendants )
.FirstOrDefault( w => w.IsValid() && w.Active );
}
}
string WeaponName
{
get
{
var swb = SwbWeapon;
if ( swb.IsValid() )
return string.IsNullOrWhiteSpace( swb.DisplayName ) ? "WEAPON" : swb.DisplayName.ToUpper();
var w = ActiveWeapon;
if ( !w.IsValid() ) return "";
return string.IsNullOrWhiteSpace( w.DisplayName ) ? "WEAPON" : w.DisplayName.ToUpper();
}
}
/// <summary>
/// Inline colour for the weapon name, from its rarity. Empty at Common.
///
/// ⚠️ RETURNS AN EMPTY STRING RATHER THAN THE COMMON GREY. An empty style
/// attribute leaves the stylesheet's own colour in place, which is what "Common
/// does not tint" has to mean — writing #AFAFAF would override the HUD's chosen
/// name colour with a grey nobody picked.
/// </summary>
/// <summary>
/// The weapon name's colour when the gun is Common, i.e. untinted.
///
/// ⚠️ MUST MATCH `.weapon .name { color: ... }` IN THE SCSS, which is still the fallback for
/// the frame before the first build. It is deliberately NOT Rarity.ColorFor(0) -- that grey
/// is the palette's Common swatch, for panels that name a tier out loud (see
/// WeaponStatsPanel); the HUD's name is not a rarity label and should read as ordinary text.
/// </summary>
const string NeutralNameColor = "rgba(225,225,225,0.9)";
/// <summary>A Common weapon's name: the HUD's own neutral, bone on basalt's carved HUD (`HudTheme`).</summary>
static string NeutralName => HudTheme.Basalt ? "rgba(228,217,195,0.95)" : NeutralNameColor;
string WeaponNameStyle
{
get
{
var swb = SwbWeapon;
if ( !swb.IsValid() ) return "";
var p = NZPlayer.Local;
var tier = Rarity.TierOf( p, swb );
// ⛔️ ALWAYS EMITS A COLOUR. This returned "" at tier 0 so the SCSS colour would
// stand -- and that is not what an empty style string does. An inline style is
// APPLIED, not replaced: once the panel has been painted `color: #5AA5F0` for a Rare
// weapon, handing it "" simply stops setting the property and the blue stays. Switch
// from a Rare gun to a Common one and the HUD kept the old tint, which is exactly how
// this was reported.
//
// ⚠️ THE INTENT WAS RIGHT AND IS PRESERVED: Common is not TINTED, it is painted the
// HUD's own neutral name colour, so nothing about how Common looks changes. What
// changes is that the colour is now stated rather than left to a clear that never
// happened.
return $"color: {(tier > 0 ? Rarity.HexFor( tier ) : NeutralName)}";
}
}
/// <summary>Rounds in the magazine. Clip1 is -1 when the weapon has no
/// magazine at all, which is not a number to show a player.</summary>
string Clip
{
get
{
// ⚠️ `ShootInfo.Ammo` IS THE MAGAZINE, not the reserve — the reserve
// is what IPlayerBase.AmmoCount returns. Reading it as reserve puts
// 8 where 56 belongs and looks almost right.
var swb = SwbWeapon;
if ( swb.IsValid() && swb.Primary is not null )
return swb.Primary.Ammo.ToString();
var c = ActiveWeapon?.Clip1 ?? 0;
return c < 0 ? "—" : c.ToString();
}
}
/// <summary>
/// The reserve pool, or ∞.
///
/// ⚠️ Ammo1 really is int.MaxValue when PrimaryAmmoType is null — that is
/// the engine's "bottomless reserve", not a bug. Printing it raw put
/// "2147483647" on the HUD.
/// </summary>
string Reserve
{
get
{
// ⚠️ THROUGH THE PLAYER, NOT THE WEAPON. The reserve is not on the
// weapon component at all — IPlayerBase.AmmoCount is the accessor,
// and ours resolves it to the active weapon's NZAmmo (per-weapon
// reserve; see NZPlayer.SWB.cs). Reading a weapon field for it gives
// 0 while reloading works perfectly, which is exactly how this was
// first seen.
var swb = SwbWeapon;
if ( swb.IsValid() && swb.Primary is not null )
{
var sp = Player;
return sp.IsValid() ? sp.AmmoCount( swb.Primary.AmmoType ).ToString() : "0";
}
var w = ActiveWeapon;
if ( !w.IsValid() ) return "0";
return w.HasInfiniteReserve ? "∞" : w.Ammo1.ToString();
}
}
bool Reloading => SwbWeapon?.IsReloading ?? ActiveWeapon?.IsReloading ?? false;
/// <summary>The per-class weapon tech's tag for the gun in hand (2026-10-04). "" on most guns.</summary>
string TechTag => SwbWeapon is { } w && w.IsValid() ? w.ClassTechTag : "";
/// <summary>"flash" just after a Select Fire switch, "ready" with a grenade charged.</summary>
string TechTagClass => (SwbWeapon is { } w && w.IsValid() ? w.ClassTechTagState : 0) switch
{
1 => "flash",
2 => "ready",
_ => "",
};
/// <summary>
/// Grenades in hand.
///
/// ⚠️ `Get`, NOT `GetOrCreate`. The HUD READS state — creating a component from a
/// render path would hand a spectator or a between-rounds player an inventory they
/// never asked for, and would retry it every frame the lookup missed.
/// </summary>
NZombies.Grenade Nades => Player?.Components
.Get<NZombies.Grenade>( FindMode.EverythingInSelf );
// ══ Ammo mod ════════════════════════════════════════════════
// ⚠️ ONE LOOKUP, THREE READERS. The name, the ready state and the wired flag all
// come off the same `Mod` record, so a frame cannot show one mod's name beside
// another's cooldown — which is exactly what three independent `AmmoMods.Held`
// calls would allow on the frame a weapon is swapped.
AmmoMods.Mod HeldMod => Player is null ? null : AmmoMods.Held( Player );
/// <summary>The held weapon's mod name, or null when it has none.</summary>
string AmmoModName => HeldMod?.Name;
/// <summary>
/// The held mod's badge image, or null when there is no mod.
///
/// ⚠️ THE NAME IS STILL THE `title`, not gone. The icons are the upstream
/// artwork and none of them is self-explanatory — a player who has not seen
/// Dead Wire before cannot read a lightning glyph as "chains a shock to 7 zombies".
/// Hovering is where that answer lives now.
/// </summary>
string AmmoModIcon => HeldMod?.Icon;
/// <summary>Is the effect actually implemented.</summary>
bool AmmoModWired => HeldMod?.Built ?? false;
/// <summary>
/// Can the mod proc right now.
///
/// ⚠️ READS THE SAME PER-PREFAB COOLDOWN THE PROC READS, not a copy. `AmmoMods`
/// stamps `AmmoModReady[prefab]`, and this asks that dictionary — so the chip cannot
/// disagree with whether a shot would actually fire the effect.
///
/// ⚠️ AN UNBUILT MOD IS NEVER "ready", because it can never proc. Showing it lit
/// would promise something the mod cannot do.
/// </summary>
bool AmmoModReady
{
get
{
if ( Player is null || !AmmoModWired ) return false;
var wep = VultureAugments.HeldWeapon( Player );
if ( !wep.IsValid() ) return false;
var prefab = Rarity.PrefabOf( wep );
if ( string.IsNullOrEmpty( prefab ) ) return true;
return !Player.AmmoModReady.TryGetValue( prefab, out var t ) || t <= 0f;
}
}
int Grenades => Nades?.Count ?? 0;
/// <summary>Cooking one right now.</summary>
bool Cooking => Nades?.Cooking ?? false;
/// <summary>
/// The other players' scores, folded to one number for the build hash.
///
/// ⚠️ A DISPLAYED VALUE THE HASH CANNOT SEE NEVER REBUILDS THE TREE — this panel already
/// carries four separate warnings saying so, about the portrait, the grenades, the round phase
/// and the weapon tint. Somebody else's score would have frozen at whatever it read when one of
/// MY values last changed.
///
/// ⚠️ MY OWN ROW IS EXCLUDED, because `Points` is already in the hash on its own.
/// </summary>
static int ScoresHash
{
get
{
var h = 0;
foreach ( var s in Scores )
if ( !s.Mine ) h = System.HashCode.Combine( h, s.Name, s.Points );
return h;
}
}
/// <summary>Rebuild when any displayed value changes. Health is bucketed to
/// whole percent so a regen tick doesn't rebuild the panel every frame.</summary>
/// ⚠️ Nested, because HashCode.Combine takes at most 8 arguments and the
/// HUD now shows 9 values.
// ⚠️ PortraitSrc MUST be in here. It is empty until Steam has an id, and if
// the hash cannot see it change the tree never rebuilds to pick the avatar
// up — which is the original bug wearing a different hat.
protected override int BuildHash() => System.HashCode.Combine(
// ⚠️ THE THEME RIDES WITH `Visible` AS ONE TUPLE — the outer Combine is at its eight-argument limit (see below). Its
// class and its Roman counter both, so `nz_hud_theme` shows at once.
(Visible, HudTheme.Class, HudTheme.Roman), Round, Points, ((int)HealthPercent, (int)OverPercent), (int)StaminaPercent,
// ⛔ GRENADES BELONG IN THE HASH. A displayed value the hash cannot see never
// rebuilds the tree — the count would freeze at whatever it was when
// something else last changed, which is the PortraitSrc bug noted below
// wearing a third hat.
// ⚠️ `RoundPhaseClass`, not the fade booleans — it is the value actually
// rendered, and a phase the hash cannot see would leave the class stuck on
// whatever it was when something else last changed.
// ⚠️ WeaponNameStyle IS IN THE HASH. Buying rarity changes the name's COLOUR
// and nothing else on the HUD — not the name text, not the ammo — so without
// this the tint would not appear until the next reload or round change happened
// to repaint. A displayed value the hash cannot see repaints only by luck.
// ⚠️ FOLDED INTO THE INNER Combine, not added as a ninth argument —
// HashCode.Combine takes at most EIGHT, and the outer call was already full.
// Adding one more compiles nowhere and fails as CS1501 in generated razor code,
// which points at a line number in a file you did not write.
WeaponName,
System.HashCode.Combine( Clip, Reserve, Reloading, Grenades, Cooking,
RoundPhaseClass, WeaponNameStyle, ScoresHash ),
// ⚠️ POINTS POPUPS ARE DELIBERATELY ABSENT. They live on their own
// panel now (PointsPopupHud) precisely so this hash — which changes
// several times a second in a firefight — cannot rebuild them.
// ⛔ ARMOR AND SALVAGE BELONG IN HERE. A displayed value the hash cannot see
// never rebuilds the tree, so the bar would freeze at whatever it read when
// something else last changed — the same trap this hash already carries three
// separate warnings about. (int)Armor rather than the float, so a fractional
// change cannot rebuild the panel every frame during sustained damage.
// ⛔ ONLY THE BAR'S PRESENCE, NEVER ITS FILL. `HasTriggerCharge` flips a handful
// of times a game and has to be here or the bar would not appear on purchase; the
// FILL is written from OnUpdate because it changes every frame. Adding the charge
// itself here would rebuild this entire tree sixty times a second while firing,
// which is precisely what the three warnings above are about.
System.HashCode.Combine( PortraitSrc, PerkKey, NapalmFlashes,
ArmorTier, (int)(Player?.Armor ?? 0f), ArmorPlates, Salvage,
// ⚠ ALL THREE AUGMENT BARS IN ONE NESTED Combine, because the outer call is at
// its eight-argument limit.
//
// ⛔ `FocusText` IS NO LONGER IN THE HASH, because nothing displays it. It used
// to cover both the Focus bar's presence and its label; with the label gone
// `HasFocus` covers presence on its own, and leaving the text in would rebuild this
// whole tree on every headshot in order to redraw nothing.
System.HashCode.Combine( HasTriggerCharge, HasFocus,
HasKillstreak, StreakText,
// ⛔ THE PLACE-CHARGE BAR BELONGS IN HERE, and this is the fourth warning on this
// hash saying the same thing: a displayed value the hash cannot see never rebuilds
// the tree, so the bar would freeze wherever it stood when something else last
// changed — and since it fills on kills, "something else" is usually the points
// counter, which would make it look like it worked while being off by a kill.
//
// ⚠️ `(int)` ON THE PERCENT, deliberately, matching `(int)Armor` two lines up: the
// charge is a float and a fractional change would rebuild the whole panel on every
// kill's worth of easing rather than once per visible step.
//
// ⚠️ AND `ShowPlaceCharge` SEPARATELY FROM THE VALUE, because the bar appearing at
// all is its own change — equipping a major with 0% charge alters the tree while
// the percentage stays exactly where it was.
ShowPlaceCharge, (int)PlaceChargePercent, (PlaceReady, PlacePaused),
// ⚠️ AND THE WEAPON TECH TAG, ITS TEXT AND ITS FLASH (2026-10-04) — the last free slot here.
(TechTag, TechTagClass) ) ) );
}