UI/WunderfizzPanel.razor

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.

File Access
@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;
    }
}