UI/ArsenalPanel.razor

A Razor UI component (ArsenalPanel) in the NZombies namespace that renders the game's Arsenal menu with tabs for Armor, Weapon Rarity, Weapon Tech, Ammo Type and placeholders for unimplemented pages. It reads state from ArsenalMenu and HudTheme, builds interactive cards/grids, handles clicks and right-clicks via ArsenalMenu methods, and computes a BuildHash to force correct re-renders.

Http CallsFile Access
@using Sandbox;
@using Sandbox.UI;
@using System.Linq;
@using NZombies;
@inherits PanelComponent

@*
    ⛔ THE NAMESPACE DIRECTIVE BELOW IS REQUIRED, for the same reason WunderfizzPanel
    carries one. This panel is created in CODE — ArsenalMenu.EnsureHost does
    Components.Create<ArsenalPanel>() — and without it the generated type lands in
    the GLOBAL namespace while the caller sits in NZombies, giving "CS0246: type or
    namespace 'ArsenalPanel' could not be found" on a file that is plainly right
    there. Every other razor here is attached through the scene file and needs none.
*@
@namespace NZombies

@*
    THE ARSENAL — the four-tab shell, from the GMod original
    (`gamemode/other/cl_nzaug_arsenal.lua`).

    Tab row proportions are the original's, converted from its scaled layout:

        tabs      250 x 46, 20 gap, at y=150   (4 tabs = 1060 wide)
        content   below the tabs, centred

    ⚠️ ARMOR AND WEAPON RARITY ARE BUILT; THE OTHER TWO ARE PLACEHOLDERS that say
    what they are blocked on. A screen pretending to sell something that does not
    exist would be worse than one that admits it.

    ⛔ <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 — the trap WunderfizzPanel records.
*@

<root class="@HudTheme.Class">
    @if ( ArsenalMenu.IsOpen )
    {
    <div class="arsenal">

        <div class="title">ARSENAL</div>

        @* Tab row. Driven off ArsenalMenu.Modes so a fifth product appears here
           without touching this file. *@
        <div class="tabs">
            @foreach ( var mode in ArsenalMenu.Modes )
            {
                <div class="tab @(ArsenalMenu.Mode == mode ? "on" : "")
                                @(ArsenalMenu.Implemented( mode ) ? "" : "todo")"
                     onclick=@(() => ArsenalMenu.Select( mode ))>
                    @ArsenalMenu.NameFor( mode )
                </div>
            }
        </div>

        @* Page body. One branch per built page, with a shared placeholder for the
           rest — so an unbuilt tab cannot silently render nothing at all. *@
        <div class="content">
            @if ( ArsenalMenu.Mode == ArsenalMode.Armor )
            {
                @* ARMOR — one card per tier, from the original's buildArmor.
                   Layout is its 260x340 cards with a 46 gap, in proportion.

                   ⚠️ DRIVEN OFF ArmorTiers, not a hardcoded 1..3, so raising
                   MaxTier in the config adds a card instead of needing this file
                   edited — and the card row cannot disagree with what the machine
                   will actually sell. *@
                <div class="cards">
                    @foreach ( var tier in ArsenalMenu.ArmorTiers )
                    {
                        var state = ArsenalMenu.ArmorCardState( tier );
                        var label = ArsenalMenu.ArmorCardLabel( tier );

                        <div class="card @state" onclick=@(() => ArsenalMenu.ClickArmor( tier ))>
                            <div class="kind">BODY ARMOR</div>
                            <div class="tier">TIER @(HudTheme.TierNumeral( tier ))</div>
                            <div class="hp">@ArsenalMenu.ArmorCapFor( tier ).ToString( "0" ) ARMOR</div>

                            <div class="cost">
                                <span class="coin"></span>
                                <span class="amount">@ArsenalMenu.ArmorPrice( tier ).ToString( "N0" )</span>
                            </div>

                            @* ⚠️ THE STATUS SAYS WHY, and only when there is a why.
                               A buyable card carries none, which is what makes the
                               one you can actually afford stand out without needing
                               a "BUY" badge of its own. *@
                            @if ( !string.IsNullOrEmpty( label ) )
                            {
                                <div class="status">@label</div>
                            }
                        </div>
                    }
                </div>
            }
            else if ( ArsenalMenu.Mode == ArsenalMode.WeaponRarity )
            {
                @* WEAPON RARITY — the original's buildRarity. FIVE cards, Common
                   through Legendary, each a flat x1.5 damage step.

                   ⚠️ THE WEAPON IS NAMED AT THE TOP, because unlike armor this page
                   acts on one specific gun and the player may be holding either of
                   two. A ladder with no subject would leave you guessing which. *@
                @if ( !ArsenalMenu.RarityWeapon.IsValid() )
                {
                    @* ⚠️ ITS OWN MESSAGE, not five LOCKED cards. The original does the
                       same. "Hold a weapon" is one switch away; five locked cards say
                       "come back later", which is the wrong instruction. *@
                    <div class="placeholder">
                        <div class="head">WEAPON RARITY</div>
                        <div class="line">Hold a weapon to change its rarity.</div>
                    </div>
                }
                else
                {
                    <div class="subject">
                        <div class="wep">@ArsenalMenu.RarityWeaponName</div>
                        <div class="cur"
                             style="color: @ArsenalMenu.RarityHex( ArsenalMenu.RarityCurrent )">
                            @ArsenalMenu.RarityName( ArsenalMenu.RarityCurrent )
                        </div>
                    </div>

                    <div class="cards rarity @(ArsenalMenu.RarityTiers.Length > 5 ? "six" : "")">
                        @foreach ( var tier in ArsenalMenu.RarityTiers )
                        {
                            var state = ArsenalMenu.RarityCardState( tier );
                            var label = ArsenalMenu.RarityCardLabel( tier );
                            var hex = ArsenalMenu.RarityHex( tier );

                            @* ⚠️ THE TIER COLOUR IS SET INLINE, on the name and on the
                               bar, because it comes from Rarity.HexFor — one palette
                               shared with the weapon stats panel. Five per-tier CSS
                               classes here would be a second copy (§3). *@
                            <div class="card @state" onclick=@(() => ArsenalMenu.ClickRarity( tier ))>
                                <div class="kind">RARITY</div>
                                <div class="tier" style="color: @hex">@ArsenalMenu.RarityName( tier )</div>
                                <div class="hp">@ArsenalMenu.RarityMult( tier ).ToString( "0.00" )x DMG</div>

                                @* The original draws a solid bar in the rarity's
                                   colour — it is what makes the ladder readable at a
                                   glance without reading five words. *@
                                <div class="band" style="background-color: @hex"></div>

                                @* ⛔ THE BOTTOM GROUP IS WRAPPED, AND ONLY THE WRAPPER
                                   GETS margin-top:auto. The armor card puts that auto
                                   margin on `.cost` directly, which works there
                                   because a cost is ALWAYS drawn. Here a card at or
                                   below the current tier shows no price, so the auto
                                   margin would have to move to `.status` — and putting
                                   it on both is not a fallback: two auto margins SPLIT
                                   the free space between them, which is the same
                                   failure WeaponStatsPanel's `.wclass` note records
                                   from when a third header child appeared. One wrapper,
                                   one auto margin, identical alignment whether the
                                   price is there or not. *@
                                <div class="foot">
                                    @if ( ArsenalMenu.RarityShowsPrice( tier ) )
                                    {
                                        <div class="cost">
                                            <span class="coin"></span>
                                            <span class="amount">@ArsenalMenu.RarityPrice( tier ).ToString( "N0" )</span>
                                        </div>
                                    }

                                    @if ( !string.IsNullOrEmpty( label ) )
                                    {
                                        <div class="status">@label</div>
                                    }
                                </div>
                            </div>
                        }
                    </div>
                }
            }
            else if ( ArsenalMenu.Mode == ArsenalMode.WeaponTech )
            {
                @* WEAPON TECH — five tiers, each a band of nodes you may take a limited
                   number of. From the original's buildTech.

                   ⚠️ 41 NODES, SO THE PAGE SCROLLS. Tiers 4 and 5 carry eleven each and
                   cards wrap onto as many rows as they need; `.techscroll` owns the
                   overflow. Fitting them without scrolling would mean cards too small to
                   name, which is what the effect line is for. *@
                @if ( string.IsNullOrEmpty( ArsenalMenu.TechPrefab ) )
                {
                    @* ⚠️ ITS OWN MESSAGE, not 41 locked cards — the same reasoning as the
                       rarity page. "Hold a weapon" is one switch away. *@
                    <div class="placeholder">
                        <div class="head">WEAPON TECH</div>
                        <div class="line">Hold a weapon to buy tech for it.</div>
                    </div>
                }
                else
                {
                    @* ⛔ ONE TIER AT A TIME, NOT ALL FIVE. Showing the whole tree at once
                       meant 41 rows in a fixed area: the type had to shrink until it was
                       unreadable, and flex compressed every row below its stated height so
                       they drew on top of each other. A single tier is at most 11 rows,
                       which fits at a readable size with nothing to scroll or squash. *@
                    <div class="subject">
                        <div class="wep">@ArsenalMenu.RarityWeaponName</div>

                        @* Tier selector. Every tier is open (2026-10-03: no ladder), so it
                           only picks which one the page shows. *@
                        <div class="tierpick">
                            @foreach ( var t in ArsenalMenu.TechTiers )
                            {
                                var here = ArsenalMenu.TechTier == t.Index;

                                <div class="tbtn @(here ? "on" : "")"
                                     onclick=@(() => ArsenalMenu.SelectTechTier( t.Index ))>
                                    @(HudTheme.TierNumeral( t.Index ))
                                </div>
                            }
                        </div>
                    </div>

                    @* ⚠️ A PLAIN STATEMENT, NOT `@{ ... }`: this is already inside the `else` block's code, and the stricter
                       razor compiler (dotnet build) rejects `@{` there as RZ1010. Same meaning in both. *@
                    var shown = ArsenalMenu.TechShownTier;
                    @if ( shown is not null )
                    {
                        <div class="tierband">
                            @* ⚠️ EVERY PIECE OF THE HEADER IS ITS OWN SPAN, with no literal
                               text beside an @expression. Mixing them rendered as
                               "—TIER 1BASICS": razor swallowed the spaces around the dash
                               and moved it to the front. *@
                            <div class="tierhead">
                                <span class="tnum">TIER @(HudTheme.TierNumeral( shown.Index ))</span>
                                <span class="tname">@shown.Name.ToUpper()</span>
                                <span class="tpick">@ArsenalMenu.TechPickLabel( shown.Index )</span>
                                <span class="tcost">@shown.Cost.ToString( "N0" ) EACH</span>
                            </div>

                            @* ⚠️ The rows live in their own scrolling box so the tier header
                               above stays visible. Tiers 1-3 never scroll; 4 and 5 do. *@
                            <div class="technodes">
                                @* ⛔ THE HELD GUN'S OWN CARDS (2026-10-04): tiers 4 and 5 come from its
                                   class, action, magazine and reload sets, not one shared pool. *@
                                @foreach ( var node in ArsenalMenu.TechOffer( shown.Index ) )
                                {
                                    var state = ArsenalMenu.TechCardState( node.Id );
                                    var label = ArsenalMenu.TechCardLabel( node.Id );

                                    @* ⛔ LEFT BUYS, RIGHT TAKES AN OWNED NODE OFF for half its
                                       price (user, 2026-10-03), the Wunderfizz's augment rule. *@
                                    <div class="technode @state @ArsenalMenu.TechSetClass( node.Id )"
                                         onclick=@(() => ArsenalMenu.ClickTech( node.Id ))
                                         onrightclick=@(() => ArsenalMenu.RightClickTech( node.Id ))>
                                        <div class="nname">@node.Name.ToUpper()</div>
                                        <div class="neffect">@node.Effect</div>
                                        <div class="nstate">@label</div>
                                    </div>
                                }
                            </div>
                        </div>
                    }
                }
            }
            else if ( ArsenalMenu.Mode == ArsenalMode.AmmoType )
            {
                @* AMMO TYPE — the six AATs.

                   ⛔ UPSTREAM HAS NO PICKER HERE. Its `ammo_mod` machine rolls a
                   random mod and you take it. Six cards is a deliberate deviation: with
                   only a randomiser, testing one specific mod means paying 500 salvage
                   repeatedly and hoping. The RANDOM card keeps the real mechanic
                   reachable rather than replacing it.

                   ⚠️ THE WEAPON IS NAMED AT THE TOP for the same reason the rarity
                   page does it: the mod is fitted to ONE gun, and you may be holding
                   either of two. *@
                @if ( !ArsenalMenu.RarityWeapon.IsValid() )
                {
                    <div class="placeholder">
                        <div class="head">AMMO TYPE</div>
                        <div class="line">Hold a weapon to fit an ammo mod.</div>
                    </div>
                }
                else
                {
                    <div class="subject">
                        <div class="wep">@ArsenalMenu.RarityWeaponName</div>

                        @* ⚠️ THE FITTED MOD, OR "NONE". A blank here would read as the
                           panel failing to load rather than as an empty slot. *@
                        <div class="cur">
                            @(ArsenalMenu.AmmoFitted?.Name?.ToUpper() ?? "NO AMMO MOD")
                        </div>
                    </div>

                    @* ⛔ A GRID AND A DETAIL PANEL (2026-10-04). The user: "all the ammo mods in 5 rows of 4 on the left,
                       jsut the icons and name / and when i click one the right side shows me the mod and what it does
                       including cooldown and chance, and a button to buy". RANDOM first, then the six built mods, then the
                       designed ones, dimmed, which can't be bought until they're built (`AmmoMods.Planned`). A tile click
                       only selects; the panel's button buys. *@
                    <div class="ammopage">
                        <div class="ammogrid">
                            <div class="atile @(ArsenalMenu.AmmoSelectedId == "" ? "on" : "")"
                                 onclick=@(() => ArsenalMenu.SelectAmmo( "" ))>
                                <div class="ricon">?</div>
                                <div class="aname">RANDOM</div>
                            </div>

                            @foreach ( var mod in ArsenalMenu.AmmoCatalogue )
                            {
                                <div class="atile @ArsenalMenu.AmmoTileClass( mod )"
                                     onclick=@(() => ArsenalMenu.SelectAmmo( mod.Id ))>
                                    <img class="aicon" src="@ArsenalMenu.AmmoIcon( mod )" />
                                    <div class="aname">@mod.Name.ToUpper()</div>

                                    @* ⚠️ THE MOD'S UPGRADE LEVEL (2026-10-05): a pip a level in the corner once it has one, none before,
                                       so an untouched grid stays as it was. Five since IV and V (2026-10-06): `MaxLevel` counts them. *@
                                    @if ( ArsenalMenu.AmmoLevel( mod ) > 0 )
                                    {
                                        <div class="apips">
                                            @for ( var i = 1; i <= AmmoModUpgrades.MaxLevel; i++ )
                                            {
                                                <div class="@ArsenalMenu.AmmoPipClass( mod, i )"></div>
                                            }
                                        </div>
                                    }
                                </div>
                            }
                        </div>

                        <div class="ammodetail">
                            @if ( ArsenalMenu.AmmoSelected is null )
                            {
                                <div class="ricon big">?</div>
                                <div class="dname">RANDOM</div>
                                <div class="ddesc">Fits one of the built mods at random, never the one you rolled last. Cheaper than choosing one.</div>
                            }
                            else
                            {
                                @* ⚠️ THE ICON BESIDE THE NAME AND THE NUMBERS, NOT ABOVE THEM (2026-10-05): the upgrades below need the
                                   height the stacked layout spent on a 140px icon. *@
                                <div class="dhead">
                                    <img class="dicon" src="@ArsenalMenu.AmmoIcon( ArsenalMenu.AmmoSelected )" />

                                    <div class="dtitle">
                                        <div class="dname">@ArsenalMenu.AmmoSelected.Name.ToUpper()</div>

                                        <div class="drows">
                                            <div class="drow">
                                                <span class="k">TRIGGER</span>
                                                <span class="v">@ArsenalMenu.AmmoTriggerText( ArsenalMenu.AmmoSelected )</span>
                                            </div>
                                            <div class="drow">
                                                <span class="k">CHANCE</span>
                                                <span class="v">@ArsenalMenu.AmmoChanceText( ArsenalMenu.AmmoSelected )</span>
                                            </div>
                                            <div class="drow">
                                                <span class="k">COOLDOWN</span>
                                                <span class="v">@ArsenalMenu.AmmoCooldownText( ArsenalMenu.AmmoSelected )</span>
                                            </div>
                                        </div>
                                    </div>
                                </div>

                                <div class="ddesc tight">@ArsenalMenu.AmmoSelected.Short</div>

                                @if ( !ArsenalMenu.AmmoBuilt( ArsenalMenu.AmmoSelected ) )
                                {
                                    <div class="dsoon">DESIGNED, NOT BUILT YET</div>
                                }

                                @* ⛔ THE UPGRADES (2026-10-05). The user: "when i click an ammo mod i can buy it or i can also upgrade it up to
                                   3 times / each upgrade costing more and being permanent to that ammo mod, so i can equip on any weapon".
                                   A ladder like the armor cards: an owned level says OWNED, the next one carries its price and buys on a
                                   click, a later one says which comes first. Every piece is its own element, never literal text beside an
                                   @expression (the tech header's "—TIER 1BASICS" trap). Five rows since IV and V (2026-10-06), in the
                                   height three had: the stylesheet's ladder note has the sums. *@
                                @if ( ArsenalMenu.AmmoUpgrades.Length > 0 )
                                {
                                    <div class="dups">
                                        <div class="uhead">
                                            <span class="ulabel">UPGRADES</span>
                                            <span class="unote">FOLLOWS THE MOD TO ANY GUN</span>
                                        </div>

                                        @foreach ( var up in ArsenalMenu.AmmoUpgrades )
                                        {
                                            <div class="uprow @ArsenalMenu.AmmoUpgradeClass( up )"
                                                 onclick=@(() => ArsenalMenu.ClickAmmoUpgrade( up ))>
                                                <div class="unum">@up.Numeral</div>

                                                <div class="ubody">
                                                    <div class="uname">
                                                        <span class="un">@up.Name.ToUpper()</span>
                                                        @if ( !up.Built )
                                                        {
                                                            <span class="usoon">NOT BUILT YET</span>
                                                        }
                                                    </div>
                                                    <div class="ueff">@up.Effect</div>
                                                </div>

                                                <div class="uside">
                                                    @if ( ArsenalMenu.AmmoUpgradeShowsPrice( up ) )
                                                    {
                                                        <div class="cost">
                                                            <span class="coin"></span>
                                                            <span class="amount">@ArsenalMenu.AmmoUpgradePrice( up.Level ).ToString( "N0" )</span>
                                                        </div>
                                                    }
                                                    <div class="ustate">@ArsenalMenu.AmmoUpgradeLabel( up )</div>
                                                </div>
                                            </div>
                                        }
                                    </div>
                                }
                            }

                            <div class="dbuy">
                                @if ( ArsenalMenu.AmmoBuyState != "fitted" )
                                {
                                    <div class="cost">
                                        <span class="coin"></span>
                                        <span class="amount">@ArsenalMenu.AmmoSelectedPrice.ToString( "N0" )</span>
                                    </div>
                                }

                                <div class="buy @ArsenalMenu.AmmoBuyState"
                                     onclick=@(() => ArsenalMenu.BuyAmmoSelected())>
                                    @ArsenalMenu.AmmoBuyLabel
                                </div>
                            </div>
                        </div>
                    </div>
                }
            }
            else
            {
                <div class="placeholder">
                    <div class="head">@ArsenalMenu.NameFor( ArsenalMenu.Mode )</div>
                    <div class="line">Not built yet.</div>
                    <div class="line dim">@ArsenalMenu.BlockedBecause( ArsenalMenu.Mode )</div>
                </div>
            }
        </div>

        <div class="foot">
            <div class="close" onclick=@(() => ArsenalMenu.Close())>CLOSE</div>

            @* ⚠️ HOW TO TAKE A NODE OFF, on the tech page only — a right click is invisible
               until something says it exists. Between CLOSE and the salvage, which
               `space-between` centres it in. See ArsenalMenu.TechNote. *@
            @if ( !string.IsNullOrEmpty( ArsenalMenu.TechNote ) )
            {
                <div class="fnote">@ArsenalMenu.TechNote</div>
            }

            @* ⚠️ LABELLED "SALVAGE". The Wunderfizz footer shows a bare points
               number, and an unlabelled figure here would read as points — which
               would make a 5,000 armor tier look mispriced against a 950 box. *@
            <div class="salvage">
                <span class="amount">@ArsenalMenu.PlayerSalvage.ToString( "N0" )</span>
                <span class="label">SALVAGE</span>
            </div>
        </div>

    </div>
    }
</root>

@code
{
    // ⚠️ THE TAB IS IN HERE, or clicking one would not repaint and the menu would
    // look frozen on whichever page it opened. Salvage too, so the footer tracks a
    // purchase — the same reason WunderfizzPanel hashes its points.
    //
    // ⛔ AND THE ARMOR TIER. Buying one happens to change salvage as well, so the
    // cards WOULD repaint by luck — but nz_armor_tier changes the tier without
    // spending anything, and the OWNED/LOCKED states would then be stuck on the old
    // tier. A displayed value the hash cannot see never rebuilds; relying on a second
    // value moving with it is the same bug with a longer fuse.
    // ⛔ AND THE HELD WEAPON AND ITS RARITY TIER. The rarity page is about one
    // specific gun, so SWITCHING WEAPONS changes every card on it — and a weapon
    // switch moves neither salvage nor armor tier, so without this the page would keep
    // showing the previous gun's ladder until something else happened to repaint. The
    // tier is here for the same reason ArmorTier is: nz_rarity_set moves it without
    // spending anything.
    protected override int BuildHash() => System.HashCode.Combine(
        ArsenalMenu.IsOpen,
        ArsenalMenu.Mode,
        ArsenalMenu.PlayerSalvage,
        ArsenalMenu.User?.ArmorTier ?? 0,
        ArsenalMenu.RarityWeaponName,
        ArsenalMenu.RarityCurrent,
        // ⛔ THE OWNED-TECH COUNT. Buying a node spends salvage, which IS in the hash,
        // so the page would repaint by luck — until the first node that costs nothing, or
        // a console grant. Worse, the tier-unlock and TIER FULL states depend on the
        // count and not on the salvage, so relying on salvage means those two states are
        // one purchase behind. Same trap the ArmorTier line above records.
        ArsenalMenu.TechOwnedTotal,
        // ⚠️ AND WHICH TIER IS SHOWING. Clicking a tier button changes nothing else on
        // the panel, so without this the selector would highlight the new tier and keep
        // drawing the old one's nodes.
        // ⛔ NESTED, BECAUSE `HashCode.Combine` TAKES AT MOST EIGHT ARGUMENTS.
        // The ninth value — the fitted ammo mod — does not fit, and the compiler says so
        // as "no overload takes 9 arguments" rather than anything about hashing. Pairing the
        // last two keeps every value in the hash; DROPPING one to fit would silently stop the
        // page repainting for whichever value was cut.
        System.HashCode.Combine(
            ArsenalMenu.TechTier,
        // ⛔ AND THE FITTED AMMO MOD, BY ID. Buying one spends salvage, which IS in the
        // hash, so the page would repaint by luck — but `nz_ammomod_give` fits one for
        // free, and the FITTED state would then be stuck on the previous mod. The same trap
        // the ArmorTier and TechOwnedTotal lines above both record.
        //
        // ⚠️ THE ID, NOT THE RECORD. `AmmoMods.All` builds fresh records every read, so
        // hashing the object would differ every frame and rebuild the page continuously.
            ArsenalMenu.AmmoFittedId,
        // ⚠️ AND THE SELECTED AMMO TILE (2026-10-04): a click changes only the detail panel, and nothing else here moves.
            ArsenalMenu.AmmoSelectedId,
        // ⚠️ AND HOW MANY RARITY CARDS: basalt's Easter egg complete adds Godly's, and nothing else on the page moves when it does.
            ArsenalMenu.RarityTiers.Length,
        // ⚠️ AND THE HUD'S THEME, which sets the face (`HudTheme`).
            HudTheme.Class,
        // ⚠️ AND THE GUN BY PREFAB: tiers 4 and 5 are its own cards (2026-10-04), and two guns can share a name.
            ArsenalMenu.TechPrefab,
        // ⛔ AND EVERY AMMO MOD'S UPGRADE LEVEL, SUMMED (2026-10-05). A purchase spends salvage, which IS in the hash, but
        // `nz_ammomod_level` costs nothing, and the rows and the tiles' pips would stay a level behind: the ArmorTier trap again.
            ArsenalMenu.AmmoLevelTotal ) );

    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". WunderfizzPanel records the same trap.
        Panel.Style.PointerEvents = ArsenalMenu.IsOpen
            ? PointerEvents.All
            : PointerEvents.None;
    }
}