Static UI state and controller for the Der Wunderfizz menu. Exposes the menu open state, selected perk/augment, pricing and afford checks, commands to open/close/buy, and ensures a Scene GameObject host with a ScreenPanel and WunderfizzPanel exists for the Razor UI to draw.
using System;
using Sandbox;
using System.Linq;
namespace NZombies;
/// <summary>
/// Open/close state for the Der Wunderfizz screen.
///
/// ⛔ STATE HERE, DRAWING IN THE RAZOR — the same split ToolPanelState uses. A
/// panel that owns its own open flag cannot be opened by a console command, and
/// every button in this project has to have one; a static that the razor merely
/// reads can be driven from anywhere.
/// </summary>
public static class WunderfizzMenu
{
/// <summary>The machine being used, or null when the menu is shut.</summary>
public static Wunderfizz Current { get; private set; }
public static bool IsOpen => Current.IsValid();
/// <summary>Which perk is highlighted. The roll lands on this.</summary>
public static int Index { get; set; }
/// <summary>The highlighted perk, or null.
///
/// ⚠️ CLAMPED, not trusted. Index is public and the perk list is a property
/// that could shrink on a hotload — an out-of-range read here would throw
/// inside a razor build, which surfaces as a panel that silently stops
/// drawing rather than as an error anyone can find.</summary>
public static PerkRegistry.Perk Selected
{
get
{
var all = PerkRegistry.All;
if ( all.Length == 0 ) return null;
var i = Index.Clamp( 0, all.Length - 1 );
return all[i];
}
}
/// <summary>The player using the machine.</summary>
public static NZPlayer User
=> NZPlayer.Local;
/// <summary>What the selected perk costs right now.
///
/// ⚠️ Asked of the MACHINE per frame rather than cached, because the price
/// moves the moment a perk is bought — a cached one would show the old cost
/// until the panel happened to rebuild.</summary>
public static int SelectedPrice
=> Current.IsValid() ? Current.PriceFor( User ) : 0;
/// <summary>Does the using player own this perk id?</summary>
public static bool Owns( string id )
{
var p = User;
return p.IsValid() && p.HasPerk( id );
}
/// <summary>How many perks the player owns. In the panel's BuildHash so the
/// grid repaints the instant one is bought.</summary>
public static int OwnedCount => User.IsValid() ? User.Perks.Count : 0;
/// <summary>True when the selected perk is already owned.</summary>
public static bool AlreadyOwned
{
get
{
var p = User;
var perk = Selected;
return p.IsValid() && perk is not null && p.HasPerk( perk.Id );
}
}
/// <summary>Can the player afford the selection?</summary>
public static bool CanAfford
=> User.IsValid() && User.Points >= SelectedPrice;
/// <summary>
/// The line under the panel's two prices: why clicking the selected perk would not buy it, or "" when it would.
///
/// ⛔ THE WORDS THE COST LINE USED TO SAY IN PLACE OF ITS NUMBER (moved 2026-10-05). The perk and slot prices are always
/// shown as numbers now — the user asked for both — and a click that buys nothing must still say why on screen, or it
/// reads as the machine ignoring the player. In the order `Wunderfizz.Buy` refuses in: the machine itself, owned, no free
/// slot, then the shortfall.
/// </summary>
public static string PerkStatus
{
get
{
var p = User;
var machine = Current;
if ( !p.IsValid() || !machine.IsValid() || Selected is null ) return "";
var blocked = machine.Unavailable( p );
if ( !string.IsNullOrEmpty( blocked ) ) return blocked;
if ( AlreadyOwned ) return "Owned";
if ( p.PerksFull ) return $"No free slot ({p.Perks.Count}/{p.PerkSlots}) · buy one";
if ( !CanAfford ) return $"{SelectedPrice - p.Points:N0} more needed";
return "";
}
}
/// <summary>The status line's colour: "owned" (green) for an owned perk, "poor" (red) for any refusal, none when it buys.</summary>
public static string PerkStatusClass
=> AlreadyOwned ? "owned" : string.IsNullOrEmpty( PerkStatus ) ? "" : "poor";
/// <summary>
/// Select a perk and then either buy it or open its augments.
///
/// ⚠️ SELECTS FIRST, THEN ACTS — so if the purchase is refused the panel is
/// left showing the thing that was refused, with its name, price and reason.
/// Buying without selecting would refuse something the player is not looking
/// at, which reads as the machine doing nothing.
///
/// ⛔ A PERK YOU ALREADY OWN OPENS ITS AUGMENT SCREEN INSTEAD OF RE-BUYING, which
/// is what the original does (`sh_fizzmenu.lua`'s DoClick, guarded by HasPerk). The
/// old behaviour was to refuse the click and show "OWNED" on the cost line — a dead
/// click on the one thing a returning player is most likely to press.
/// </summary>
public static void ClickPerk( int index )
{
Index = index;
var perk = Selected;
if ( perk is not null && Owns( perk.Id ) )
{
OpenAugments( perk.Id );
return;
}
BuySelected();
}
// ── AUGMENT SCREEN ───────────────────────────────────────────────
/// <summary>
/// The perk whose augment screen is showing, or null for the perk grid.
///
/// ⛔ A MODE ON THE SAME MENU, NOT A SECOND PANEL. The original replaces the fizz
/// menu with a new VGUI window; here one razor switches on this string. A second
/// PanelComponent would need its own host object, its own ScreenPanel and its own
/// EnsureHost — and two screen panels both claiming the cursor is a fight nobody
/// wins. The note on EnsureHost records how much that machinery already cost once.
/// </summary>
public static string AugmentPerk { get; private set; }
public static bool InAugments => !string.IsNullOrEmpty( AugmentPerk );
/// <summary>The perk being augmented, or null.</summary>
public static PerkRegistry.Perk AugmentPerkData => PerkRegistry.Find( AugmentPerk );
/// <summary>Its signature colour, for the header tint.</summary>
public static string AugmentAccent => PerkRegistry.AccentHex( AugmentPerk );
/// <summary>
/// Which augment row is highlighted, or null for none.
///
/// ⚠️ SEPARATE FROM <see cref="Index"/> on purpose. Index is the perk grid's
/// cursor and the augment screen needs its own; sharing one would mean opening an
/// augment screen scrambled which perk you came back to.
/// </summary>
public static string SelectedAug { get; set; }
/// <summary>The highlighted augment's data, or null.</summary>
public static PerkAugments.Augment SelectedAugData
=> PerkAugments.Find( AugmentPerk, SelectedAug );
/// <summary>Open the augment screen for a perk.</summary>
public static void OpenAugments( string perkId )
{
if ( string.IsNullOrEmpty( perkId ) ) return;
AugmentPerk = perkId;
// ⚠️ NO ROW PRESELECTED, matching the original's "Select an augment / to see
// what it does" empty state. Preselecting M1 would put a live BUY button under
// the cursor on open, one click from 1,500 salvage on an unread choice.
SelectedAug = null;
var pool = PerkAugments.PoolFor( perkId );
Log.Info( $"[nz-aug] {perkId} — {pool?.Major.Length ?? 0} major,"
+ $" {pool?.Minor.Length ?? 0} minor"
+ $" · equipped [{string.Join( "+", PerkAugments.EquippedOn( User, perkId ) )}]" );
}
/// <summary>Back to the perk grid.</summary>
public static void CloseAugments()
{
AugmentPerk = null;
SelectedAug = null;
}
/// <summary>
/// A row was LEFT-clicked: highlight it. Does NOT buy — the BUY button does — and since
/// 2026-10-03 does not remove either; see <see cref="RemoveAug"/>.
/// </summary>
public static void SelectAug( string augId ) => SelectedAug = augId;
/// <summary>
/// A row, or the detail pane's button, was RIGHT-clicked. An augment you have comes off, for
/// half its salvage back; any other is only highlighted.
///
/// ⛔ RIGHT CLICK, BY REQUEST (user, 2026-10-03): "removing perk augments, right now is done
/// by left clicking, change to right clicking". It was a left click from 2026-09-27 ("make it
/// so left clicking an augment i have in the wunderfizz removes it and refunds half the
/// salvage it cost"), which made the click that reads an augment's text the same click that
/// throws it away. It still selects, so the pane then shows the augment just taken off, with
/// its price to buy it back.
///
/// ⚠️ A LEFT CLICK ON THE BUY BUTTON DOES NOT REMOVE. An owned augment's button says EQUIPPED
/// and what a right click would refund, and a left click does nothing: a button that bought on
/// one click and removed on the next would make a double click cost half the price. A RIGHT
/// click on it lands here, as on the row — its label tells the player to right click, and that
/// button is where they will try it.
/// </summary>
public static void RemoveAug( string augId )
{
// ⚠️ The button passes the selection, which is null until a row is picked.
if ( string.IsNullOrEmpty( augId ) ) return;
SelectedAug = augId;
if ( !AugOwned( augId ) ) return;
var msg = PerkAugments.TryRemove( User, AugmentPerk, augId, out var refund );
Log.Info( msg is null
? $"[nz-aug] removed {AugmentPerk}/{augId} — {refund:N0} salvage back,"
+ $" {PlayerSalvage:N0} now"
: $"[nz-aug] not removed: {msg}" );
}
/// <summary>
/// The augment screen's footnote.
///
/// ⛔ IT SAID "Augment effects are not wired yet" FOR EVERY PERK, true when written and false
/// once most were wired (AugmentEffects.WiredPerks lists them). Now it says so only for a perk
/// whose augments still do nothing, and otherwise how to take one off, which a right click on
/// its row does (a left click until 2026-10-03) and nothing on screen said.
/// </summary>
public static string AugmentNote
=> AugmentEffects.IsWired( AugmentPerk )
? "Right-click an augment you have to take it off: half its salvage back."
: "This perk's augments do nothing yet. Right-click one you have to take it off: half its salvage back.";
/// <summary>Salvage the using player holds, for the header readout.</summary>
public static int PlayerSalvage => User.IsValid() ? User.Salvage : 0;
/// <summary>What the highlighted augment costs.</summary>
public static int SelectedAugPrice => PerkAugments.PriceOf( SelectedAugData );
/// <summary>Is the highlighted augment already equipped.</summary>
public static bool SelectedAugOwned
=> SelectedAug is not null && PerkAugments.Has( User, AugmentPerk, SelectedAug );
/// <summary>What taking the highlighted augment off would give back.</summary>
public static int SelectedAugRefund
=> SelectedAugOwned ? PerkAugments.RefundFor( User, AugmentPerk, SelectedAug ) : 0;
/// <summary>Is the highlighted augment holding a slot the player is using, so it cannot come off.</summary>
public static bool SelectedAugBlocked
=> SelectedAugOwned && PerkAugments.RemoveBlocker( User, AugmentPerk, SelectedAug ) is not null;
/// <summary>Is the highlighted augment's tier already full.</summary>
public static bool SelectedAugSlotFull
{
get
{
var aug = SelectedAugData;
return aug is not null && PerkAugments.SlotFull( User, AugmentPerk, aug.Tier );
}
}
/// <summary>Can the player afford the highlighted augment.</summary>
public static bool CanAffordAug
=> SelectedAugData is not null && Salvage.CanAfford( User, SelectedAugPrice );
/// <summary>Is an augment equipped. For the row styling.</summary>
public static bool AugOwned( string augId )
=> PerkAugments.Has( User, AugmentPerk, augId );
/// <summary>Equipped count in a tier, for the "PICK n" pips.</summary>
public static int AugCount( PerkAugments.AugmentTier tier )
=> PerkAugments.CountOf( User, AugmentPerk, tier );
/// <summary>
/// What the buy button says right now.
///
/// ⚠️ ONE PLACE DECIDES THE LABEL, ANOTHER DECIDES THE OUTCOME, and they must not
/// drift. The razor reads this and <see cref="BuyAugClass"/>; the click calls
/// <see cref="BuyAug"/>, which re-derives its refusal from PerkAugments. A button
/// that says EQUIPPED and still charges is exactly the duplicated-lookup divergence
/// INSTRUCTIONS.md §3 is about — so both sides ask PerkAugments, never each other.
/// </summary>
public static string BuyAugLabel
{
get
{
var aug = SelectedAugData;
if ( aug is null ) return "SELECT AN AUGMENT";
// ⚠️ SAYS WHAT A RIGHT CLICK ON ITS ROW WOULD DO: the refund, or that its slot is in use.
if ( SelectedAugOwned )
return SelectedAugBlocked
? "EQUIPPED · ITS SLOT IS IN USE"
: $"EQUIPPED · RIGHT-CLICK REFUNDS {SelectedAugRefund:N0}";
if ( SelectedAugSlotFull ) return $"{aug.Tier.ToString().ToUpper()} SLOT FULL";
return $"BUY - {SelectedAugPrice:N0}";
}
}
/// <summary>The buy button's state class: "none", "owned", "full", "poor" or "".</summary>
public static string BuyAugClass
{
get
{
if ( SelectedAugData is null ) return "none";
if ( SelectedAugOwned ) return SelectedAugBlocked ? "full" : "owned";
if ( SelectedAugSlotFull ) return "full";
return CanAffordAug ? "" : "poor";
}
}
/// <summary>Buy the highlighted augment. The refusal is logged either way.</summary>
public static void BuyAug()
{
if ( SelectedAug is null ) return;
// ⚠️ The price is read BEFORE the purchase, because a successful buy spends it
// and the log line would otherwise report what the player has left as what they
// paid.
var price = SelectedAugPrice;
var msg = PerkAugments.TryBuy( User, AugmentPerk, SelectedAug );
Log.Info( msg is null
? $"[nz-aug] bought {AugmentPerk}/{SelectedAug} — {price:N0} salvage,"
+ $" {PlayerSalvage:N0} left"
: $"[nz-aug] refused: {msg}" );
}
/// <summary>Buy the selection. The message is logged either way.</summary>
public static void BuySelected()
{
var machine = Current;
var player = User;
var perk = Selected;
if ( !machine.IsValid() || !player.IsValid() || perk is null ) return;
var msg = machine.Buy( player, perk );
if ( !string.IsNullOrEmpty( msg ) ) Log.Info( $"[nz-fizz] {msg}" );
// ⚠️ The menu STAYS OPEN. Buying one perk and being ejected would make
// buying three a chore of walking back into the machine, and the price
// readout updating in place is the clearest way to show it went up.
}
/// <summary>Slots used, for the menu footer.</summary>
public static string SlotText
{
get
{
var p = User;
return p.IsValid() ? $"{p.Perks.Count}/{p.PerkSlots}" : "-";
}
}
/// <summary>Price of one more perk slot at this machine.</summary>
public static int SlotPrice
// ⛔ THROUGH `SlotPriceFor`, NOT OFF `Spot.PerkSlotPrice`. The spot's field is only
// the price of the FIRST slot now — reading it directly would print the base
// forever while `BuySlot` charged the escalated figure, and the button would look
// like it was overcharging.
=> Current.IsValid() ? Current.SlotPriceFor( User ) : 0;
/// <summary>Can this player afford another slot.</summary>
public static bool CanAffordSlot
{
get
{
var p = User;
return p.IsValid() && p.Points >= SlotPrice;
}
}
/// <summary>Buy a slot from the menu footer.</summary>
public static void ClickSlot()
{
var machine = Current;
var player = User;
if ( !machine.IsValid() || !player.IsValid() ) return;
var msg = machine.BuySlot( player );
if ( !string.IsNullOrEmpty( msg ) ) Log.Info( $"[nz-fizz] {msg}" );
}
/// <summary>Buy a perk slot from the console: `nz_fizz_slot`.</summary>
[ConCmd( "nz_fizz_slot" )]
public static void SlotCmd()
{
var p = User;
if ( !p.IsValid() ) { Log.Warning( "[nz-fizz] no player" ); return; }
// ⚠️ Falls back to raising the cap directly when the player is not stood
// at a machine, so the cap can be tested without one placed on the map.
if ( !Current.IsValid() )
{
p.BonusPerkSlots++;
Log.Info( $"[nz-fizz] no machine — slot granted free, now {p.Perks.Count}/{p.PerkSlots}" );
return;
}
ClickSlot();
}
/// <summary>Buy from the console: `nz_fizz_buy`.</summary>
[ConCmd( "nz_fizz_buy" )]
public static void BuyCmd()
{
if ( !IsOpen ) { Log.Warning( "[nz-fizz] menu is not open" ); return; }
BuySelected();
}
/// <summary>Points the using player has, for the footer readout.</summary>
public static int PlayerPoints
{
get
{
var p = NZPlayer.Local;
return p.IsValid() ? p.Points : 0;
}
}
/// <summary>The runtime-created host for the razor panel.</summary>
static GameObject _host;
/// <summary>
/// Make sure something is actually drawing the menu.
///
/// ⛔ THE PANEL HAS NO HOME IN THE SCENE. Every other HUD here is a scene
/// object with a ScreenPanel on it (Survival HUD, Dev Menu, Lobby), and the
/// scene file must not be rewritten from a script — so this one has to build
/// its own. Without it the razor exists, compiles, and never renders: the menu
/// "opens" in state and nothing appears.
///
/// ⚠️ Rebuilt whenever the object is gone, not once. A GameObject created from
/// code does not survive a hotload, and a menu that silently stops appearing
/// after a code edit is the PowerupMusic failure all over again.
/// </summary>
static void EnsureHost()
{
if ( _host.IsValid() ) return;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
_host = scene.CreateObject();
_host.Name = "Wunderfizz UI";
_host.Flags |= GameObjectFlags.NotSaved;
_host.Components.Create<ScreenPanel>();
_host.Components.Create<WunderfizzPanel>();
Log.Info( "[nz-fizz] created the menu's screen panel" );
}
public static void Open( NZPlayer player, Wunderfizz fizz )
{
if ( !fizz.IsValid() ) return;
EnsureHost();
Current = fizz;
Index = 0;
CloseAugments();
// ⚠️ The cursor is what makes a menu usable, and the LOBBY already sets it
// — Noclip keys off Mouse.Visibility for exactly this reason, so a menu
// that does not set it leaves V toggling noclip under the player while
// they click.
Mouse.Visibility = MouseVisibility.Visible;
Log.Info( $"[nz-fizz] menu open — {fizz.Price} points, "
+ $"{PerkRegistry.Names.Length} perks" );
}
/// <summary>
/// How far you can get from the machine before the menu shuts itself.
///
/// ⛔ WIDER THAN `Wunderfizz.UseRange` (96), ON PURPOSE. Closing at exactly the range that opens
/// it means standing on the boundary flickers the menu — and with it the CURSOR — open and shut
/// every frame you shift your weight. The gap between 96 and this is hysteresis: you must
/// actually walk away, not merely stop being in range.
///
/// ⚠️ 1.5× rather than a bare number so the two stay related if UseRange is ever tuned.
/// </summary>
public static float CloseRange { get; set; } = Wunderfizz.UseRange * 1.5f;
/// <summary>
/// Shut the menu when the player walks away from the machine they opened.
///
/// ⛔ MEASURED TO `Current`, NOT TO THE NEAREST MACHINE. On a map with two Wunderfizzes,
/// nearest-machine would keep the menu alive as you walked from one to the other — browsing
/// machine A's screen while stood at machine B, buying from the wrong one.
///
/// ⚠️ A DESTROYED MACHINE ALSO CLOSES IT. `Current` is a component reference and the object can
/// go — a config reload or `nz_fizz_clear` — leaving a menu open with `SelectedPrice` reading 0
/// and BUY doing nothing, which looks like the menu breaking rather than the machine leaving.
///
/// ⚠️ CALLED FROM NZPlayer's TICK, beside the ESC handler, for the reason written there: the way
/// out of a modal must not depend on the modal working. A walk-away check living in the razor
/// would go down with the panel.
/// </summary>
public static void TickRange( NZPlayer player )
{
if ( !IsOpen ) return;
if ( !Current.IsValid() )
{
Log.Info( "[nz-fizz] machine gone — menu closed" );
Close();
return;
}
// ⚠️ No player means no distance to measure, so LEAVE IT OPEN. Closing on a null player
// would shut the menu during the frame a respawn swaps the object out.
if ( !player.IsValid() ) return;
var dist = player.WorldPosition.Distance( Current.WorldPosition );
if ( dist <= CloseRange ) return;
Log.Info( $"[nz-fizz] walked away — {dist:0} > {CloseRange:0} units, menu closed" );
Close();
}
/// <summary>
/// `nz_fizz_range [units]` — read or set the walk-away distance.
///
/// ⚠️ REFUSES TO GO BELOW UseRange. A close range under the open range is a menu that shuts the
/// instant it opens, which reads as the machine being broken rather than as a bad setting.
/// </summary>
[ConCmd( "nz_fizz_range" )]
public static void RangeCmd( float units = -1f )
{
if ( units >= 0f )
CloseRange = MathF.Max( units, Wunderfizz.UseRange );
Log.Info( $"[nz-fizz] walk-away range {CloseRange:0} units"
+ $" (opens within {Wunderfizz.UseRange:0})"
+ (units >= 0f && units < Wunderfizz.UseRange
? $" — {units:0} was raised to the open range" : "") );
}
public static void Close()
{
if ( !IsOpen ) return;
// ⚠️ The augment screen is a MODE, so shutting the menu has to leave it —
// otherwise the next player to walk into the machine opens straight onto the
// last one's augment screen, for a perk they may not even own.
CloseAugments();
Current = null;
Mouse.Visibility = MouseVisibility.Hidden;
Log.Info( "[nz-fizz] menu closed" );
}
/// <summary>
/// What the menu's UI is actually made of: `nz_fizz_ui`.
///
/// ⚠️ Reports each link SEPARATELY — host object, ScreenPanel, the panel
/// component itself, the open flag and the cursor. "No UI appears" has five
/// causes that look identical on screen, and tonight has already shown that
/// guessing between them costs more than printing them.
/// </summary>
[ConCmd( "nz_fizz_ui" )]
public static void UiState()
{
var scene = Game.ActiveScene;
var host = _host.IsValid() ? _host : scene?.Directory
.FindByName( "Wunderfizz UI" ).FirstOrDefault();
Log.Info( $"[nz-fizz-ui] host {(host.IsValid() ? "alive" : "MISSING")}"
+ $" · open {IsOpen}"
+ $" · cursor {Mouse.Visibility}" );
if ( !host.IsValid() )
{
Log.Warning( "[nz-fizz-ui] no host object — EnsureHost never ran or its "
+ "object was destroyed. nz_fizz_menu builds it." );
return;
}
var screen = host.Components.Get<ScreenPanel>( FindMode.EverythingInSelf );
var panel = host.Components.Get<WunderfizzPanel>( FindMode.EverythingInSelf );
Log.Info( $"[nz-fizz-ui] ScreenPanel {(screen.IsValid() ? (screen.Enabled ? "on" : "OFF") : "MISSING")}"
+ $" · WunderfizzPanel {(panel.IsValid() ? (panel.Enabled ? "on" : "OFF") : "MISSING")}"
+ $" · host enabled {host.Enabled}" );
if ( !panel.IsValid() )
Log.Warning( "[nz-fizz-ui] the razor component is not on the host — it did "
+ "not construct. That is a razor/compile problem, not a state one." );
}
/// <summary>Open the menu at the nearest machine: `nz_fizz_menu`.
///
/// ⚠️ Exists because a UI that can only be reached by standing in the right
/// spot and pressing E cannot be tested remotely — the rule every other
/// feature here follows.</summary>
[ConCmd( "nz_fizz_menu" )]
public static void Cmd()
{
if ( IsOpen ) { Close(); return; }
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-fizz] no player" ); return; }
// Nearest machine ANYWHERE, not just in use range — the command is for
// looking at the menu, not for playing.
var fizz = Wunderfizz.All.Where( w => w.IsValid() )
.OrderBy( w => w.WorldPosition.Distance( p.WorldPosition ) )
.FirstOrDefault();
if ( !fizz.IsValid() )
{
Log.Warning( "[nz-fizz] no machine placed — nz_fizz to drop one" );
return;
}
Open( p, fizz );
}
}