A Blazor-style Razor UI component for the Wunderfizz perk/augment menu in NZombies. It renders either the perk grid or the augment selection screen, shows prices, ownership and selection state, and forwards clicks/right-clicks to WunderfizzMenu methods.
@using Sandbox;
@using Sandbox.UI;
@using System.Linq;
@using NZombies;
@inherits PanelComponent
@*
⛔ THE NAMESPACE DIRECTIVE BELOW IS REQUIRED HERE AND NOWHERE ELSE IN THIS PROJECT. Every other
razor is attached through the SCENE FILE, so its generated class never has to
be named from C#. This one is created in code — WunderfizzMenu.EnsureHost does
Components.Create<WunderfizzPanel>() — and without this the generated type
lands in the GLOBAL namespace while the caller sits in NZombies, giving
"CS0246: type or namespace 'WunderfizzPanel' could not be found" on a file
that is plainly right there.
*@
@namespace NZombies
@*
DER WUNDERFIZZ — rebuilt from the GMod menu (perks/sh_fizzmenu.lua).
Layout is the original's, converted from its 1024x512 panel:
name (100, 20) 512 x 64
desc (100, 72) 860 x 64
cost (784, 36) · coin (842, 36) · price (876, 36)
icons from (100, 148), 64px, 24px gap, 72px rows
close (108, 428) · points (860, 428)
⚠️ Kept in the SAME PROPORTIONS rather than reinvented, so a player who knows
nZombies reads the same screen. The original scales by (ScrW()/1920 + 1)/2;
here it is a fixed 1024x512 box centred by CSS, which is the same result
without a manual scale factor.
⛔ <root> IS ALWAYS EMITTED and only its contents are conditional. An early
`return` short-circuits the whole render, so the root never exists and there
is nothing to draw or hover.
*@
<root class="@HudTheme.Class">
@* ⛔ TWO MODES, ONE PANEL. `InAugments` swaps the perk grid for the augment screen
rather than a second PanelComponent opening on top. See WunderfizzMenu.AugmentPerk
for why a second panel is the wrong shape here. *@
@if ( WunderfizzMenu.IsOpen && WunderfizzMenu.InAugments )
{
@AugmentScreen()
}
else if ( WunderfizzMenu.IsOpen )
{
<div class="fizz">
<div class="name">@WunderfizzMenu.Selected?.Name</div>
@* ⚠️ AN OWNED PERK SAYS WHAT THE CLICK DOES instead of repeating its blurb.
The original makes the same edit for the same reason — clicking an owned perk
used to be refused, so nothing on screen ever hinted there was a second
screen behind it. *@
<div class="desc">@(WunderfizzMenu.AlreadyOwned
? "Click to choose augments."
: WunderfizzMenu.Selected?.Short ?? "Pick a perk!")</div>
@* ⚠️ BOTH PRICES, ALWAYS AS NUMBERS (2026-10-05, the user: "add the current perk price and perk slot price to the
wunderfizz ui"). Both are the MACHINE price for this player: a perk costs the same whichever is picked and grows
with how many are owned, and a slot grows with how many were bought. Each turns red when it cannot be paid for.
⛔ THE COST LINE USED TO SAY OWNED / TOO EXPENSIVE INSTEAD OF ITS NUMBER, so the price vanished exactly when the
player needed it. Those words moved to the status line under the prices (WunderfizzMenu.PerkStatus). *@
<div class="prices">
<div class="price">
<div class="label">Next perk</div>
<div class="value @(WunderfizzMenu.CanAfford ? "" : "poor")">@WunderfizzMenu.SelectedPrice.ToString( "N0" )</div>
</div>
<div class="price">
<div class="label">Extra slot</div>
<div class="value @(WunderfizzMenu.CanAffordSlot ? "" : "poor")">@WunderfizzMenu.SlotPrice.ToString( "N0" )</div>
</div>
<div class="status @WunderfizzMenu.PerkStatusClass">@WunderfizzMenu.PerkStatus</div>
</div>
<div class="grid">
@foreach ( var (perk, i) in PerkRegistry.All.Select( ( p, i ) => (p, i) ) )
{
@* ⚠️ Two INDEPENDENT states, so a slot can be both. Owned is a
fact about the player; selected is where the cursor is. Folding
them into one class would make the highlight disappear the
moment you clicked something you already had. *@
@* ⚠️ HOVER PREVIEWS, CLICK BUYS. The name, price and description
follow the cursor so a perk can be read before it is paid for —
without that, click-to-buy means the only way to see what
something costs is to buy it. *@
<div class="slot @(WunderfizzMenu.Owns( perk.Id ) ? "owned" : "") @(i == WunderfizzMenu.Index ? "on" : "")"
onmouseover=@(() => WunderfizzMenu.Index = i)
onclick=@(() => WunderfizzMenu.ClickPerk( i ))>
@* ⚠️ The icon filename IS the perk id. The original resolves
iconPath .. perk .. ".png" the same way, so the two cannot
drift apart as perks are added. *@
<img src="ui/perks/@(perk.Id).png" class="icon" />
</div>
}
</div>
<div class="foot">
<div class="close" onclick=@(() => WunderfizzMenu.Close())>CLOSE</div>
@* ⛔ NO BUY BUTTON — clicking a perk buys it. The status it used to
carry still has to be said somewhere, so it moved to the cost line:
"OWNED" / "TOO EXPENSIVE" replace the price. Dropping it entirely
would leave a click that silently does nothing. *@
@* ⚠️ A BUTTON, not the click-anywhere the perk icons use — slots are
the one purchase here that buys no perk, so it has to say what it
does. Label ONLY: the count and price were on a second line and ran
together into "1/510000", which read as one nonsense number. *@
<div class="slotbuy @(WunderfizzMenu.CanAffordSlot ? "" : "poor")"
onclick=@(() => WunderfizzMenu.ClickSlot())>BUY EXTRA SLOT</div>
@* Grouped like the two prices above it, so 12,500 points and a 10,000 slot read as the same kind of number. *@
<div class="points">@WunderfizzMenu.PlayerPoints.ToString( "N0" )</div>
</div>
</div>
}
</root>
@code
{
// ⚠️ Selection, points and price all in here, or the panel would not repaint
// when you click a different perk.
//
// ⚠️ AND THE AUGMENT STATE, in a nested Combine because HashCode.Combine takes at
// most eight arguments. Leaving any of these out means clicking a row, or buying,
// changes state that the panel never redraws — which reads as the click not
// registering at all.
protected override int BuildHash() => System.HashCode.Combine(
WunderfizzMenu.IsOpen,
WunderfizzMenu.Index,
WunderfizzMenu.PlayerPoints,
WunderfizzMenu.OwnedCount,
WunderfizzMenu.SelectedPrice,
WunderfizzMenu.AlreadyOwned,
System.HashCode.Combine( WunderfizzMenu.CanAfford, WunderfizzMenu.SlotText, HudTheme.Class,
// ⚠️ THE SLOT PRICE AND THE STATUS LINE TOO (2026-10-05), both drawn now: a slot bought moves the price, and the
// status changes with power, rounds and doors that nothing else here hashes.
WunderfizzMenu.SlotPrice, WunderfizzMenu.CanAffordSlot, WunderfizzMenu.PerkStatus ),
System.HashCode.Combine(
WunderfizzMenu.AugmentPerk,
WunderfizzMenu.SelectedAug,
WunderfizzMenu.PlayerSalvage,
WunderfizzMenu.BuyAugLabel,
WunderfizzMenu.BuyAugClass,
WunderfizzMenu.AugCount( PerkAugments.AugmentTier.Major ),
WunderfizzMenu.AugCount( PerkAugments.AugmentTier.Minor ) ) );
// ── THE AUGMENT SCREEN ───────────────────────────────────────────────
//
// Layout is the original's (perks/sh_augments.lua OpenAugmentMenu), converted from
// its 900x620 VGUI panel to a fixed 1024x640 box:
//
// header 96px tall — perk icon, name, blurb, salvage, close
// left 54% MAJOR (4 rows) then MINOR (5 rows), each with PICK n pips
// right 46% the selected augment's badge, name, description and BUY
//
// ⚠️ TALLER THAN THE PERK GRID'S 512px, and it has to be: nine 40px rows plus two
// section headers plus the panel header do not fit in 512, and squeezing them would
// mean either scrolling — which hides half the choices behind a gesture — or rows
// too short to read a 22-character augment name in.
/// <summary>The perk's own colour, for every accent on the screen.</summary>
string Accent => WunderfizzMenu.AugmentAccent;
RenderFragment AugmentScreen() => @<div class="augment">
<div class="ahead">
<img src="ui/perks/@(WunderfizzMenu.AugmentPerk).png" class="aicon" />
<div class="atext">
<div class="atitle" style="color: @Accent">@WunderfizzMenu.AugmentPerkData?.Name</div>
<div class="asub">@WunderfizzMenu.AugmentPerkData?.Short</div>
</div>
@* ⚠️ SALVAGE, NOT POINTS, and the label says so. Augments are the first
thing in the game that spends salvage, so a bare number next to a perk
machine would be read as points by everyone. *@
<div class="asalvage">
<div class="alabel">Salvage</div>
<div class="avalue">@WunderfizzMenu.PlayerSalvage.ToString( "N0" )</div>
</div>
@* ⚠️ BACK, not a close X. The original's X shuts the whole thing; this
returns to the perk grid, because you reached the augment screen THROUGH
that grid and dumping the player out of the machine entirely is a worse
answer to "wrong perk". CLOSE still lives at the bottom. *@
<div class="aback" onclick=@(() => WunderfizzMenu.CloseAugments())>BACK</div>
</div>
<div class="abody">
<div class="acols">
@AugmentSection( "MAJOR AUGMENTS", PerkAugments.AugmentTier.Major,
PerkAugments.MajorsFor( WunderfizzMenu.AugmentPerk ) )
@AugmentSection( "MINOR AUGMENTS", PerkAugments.AugmentTier.Minor,
PerkAugments.MinorsFor( WunderfizzMenu.AugmentPerk ) )
</div>
<div class="adetail">
@if ( WunderfizzMenu.SelectedAugData is null )
{
@* ⚠️ AN EXPLICIT EMPTY STATE, the original's wording. A blank pane
next to nine clickable rows reads as the rows being broken. *@
<div class="aempty">
<div class="e1">Select an augment</div>
<div class="e2">to see what it does</div>
</div>
}
else
{
var aug = WunderfizzMenu.SelectedAugData;
var major = aug.Tier == PerkAugments.AugmentTier.Major;
<div class="dhead">
<div class="dbadge @(major ? "major" : "")"
@* ⛔️ THE MINOR CASE STATES ITS COLOUR rather than passing "". Same
defect the HUD's rarity tint had: an inline style is applied and
not replaced, so selecting a MAJOR augment and then a MINOR one
left the perk-coloured fill on the minor's badge. Mirrors
`.dbadge { background-color }` in the SCSS. *@
style="background-color: @(major ? Accent : "rgba(37,41,53,1)")">@aug.Id</div>
<div class="dtier">@(major ? "MAJOR AUGMENT" : "MINOR AUGMENT")</div>
</div>
<div class="dname" style="color: @(major ? Accent : "#e9c52c")">@aug.Name</div>
<div class="ddesc">@aug.Desc</div>
@* ⚠️ THE LABEL AND THE STATE CLASS BOTH COME FROM WunderfizzMenu, and
so does the click's outcome — all three derive from PerkAugments
rather than from each other. A button that reads EQUIPPED and still
charges is the §3 divergence this avoids. *@
@* ⚠️ A RIGHT CLICK HERE TAKES IT OFF TOO (2026-10-03): an equipped
augment's label says "RIGHT-CLICK REFUNDS", and this button is where a
player will try it. Only the LEFT click must never remove — see
WunderfizzMenu.RemoveAug. *@
<div class="abuy @WunderfizzMenu.BuyAugClass"
onclick=@(() => WunderfizzMenu.BuyAug())
onrightclick=@(() => WunderfizzMenu.RemoveAug( WunderfizzMenu.SelectedAug ))>@WunderfizzMenu.BuyAugLabel</div>
}
</div>
</div>
<div class="afoot">
<div class="close" onclick=@(() => WunderfizzMenu.Close())>CLOSE</div>
@* ⛔ SAYS OUT LOUD WHEN A PERK'S AUGMENTS DO NOTHING, and otherwise how to take one
off — a right click on its row since 2026-10-03 (a left click from 2026-09-27). It said "not wired yet" for every
perk, true when written and false once most were wired: a UI that lies by
omission either way. See WunderfizzMenu.AugmentNote. *@
<div class="anote">@WunderfizzMenu.AugmentNote</div>
</div>
</div>;
/// <summary>
/// One tier's section: its label, its PICK n pips, and its rows.
///
/// ⚠️ ONE FRAGMENT FOR BOTH TIERS rather than the markup twice. The two sections
/// differ only in label, tier and list — duplicating them is how the minor rows end
/// up styled from a copy of the major rules that stopped matching.
/// </summary>
RenderFragment AugmentSection( string label, PerkAugments.AugmentTier tier,
PerkAugments.Augment[] list )
=> @<div class="asect">
<div class="alabelrow">
<div class="aname">@label</div>
@* ⚠️ DOTS, NOT THE ORIGINAL'S DIAMONDS. Its diamond is a rotated
surface.DrawPoly, which has no cheap equivalent here — a filled dot
carries the same "n of m taken" reading without depending on
transform support this layout system may not have. *@
<div class="apips">
@for ( var i = 0; i < PerkAugments.LimitOf( tier ); i++ )
{
<div class="apip @(i < WunderfizzMenu.AugCount( tier ) ? "on" : "")"></div>
}
</div>
<div class="apick">PICK @PerkAugments.LimitOf( tier )</div>
</div>
@foreach ( var aug in list )
{
@* ⚠️ THREE INDEPENDENT STATES: equipped is a fact about the player,
selected is where the cursor last clicked, and hover is neither.
Folding equipped into selected would make the highlight vanish the
moment you clicked something you already owned — the same mistake the
perk grid documents avoiding.
⛔ LEFT SELECTS, RIGHT TAKES IT OFF (user, 2026-10-03). Both were the
left click from 2026-09-27, so reading an augment you owned threw it
away. The engine's own menus pair onclick and onrightclick the same way. *@
<div class="arow @(WunderfizzMenu.AugOwned( aug.Id ) ? "owned" : "") @(WunderfizzMenu.SelectedAug == aug.Id ? "on" : "")"
onclick=@(() => WunderfizzMenu.SelectAug( aug.Id ))
onrightclick=@(() => WunderfizzMenu.RemoveAug( aug.Id ))>
@* The accent strip. A child div rather than border-left, so it does
not depend on per-side border support. *@
<div class="astrip" style="background-color: @(WunderfizzMenu.SelectedAug == aug.Id ? Accent : "rgba(70,74,92,1)")"></div>
<div class="abadge">@aug.Id</div>
<div class="arowname">@aug.Name</div>
</div>
}
</div>;
protected override void OnUpdate()
{
// ⛔ POINTER EVENTS OFF WHILE SHUT. <root> covers the screen whether or
// not the menu is open, so leaving it clickable would swallow every mouse
// press in the game — invisible, and it would present as "shooting
// stopped working".
Panel.Style.PointerEvents = WunderfizzMenu.IsOpen
? PointerEvents.All
: PointerEvents.None;
}
}