A Razor UI panel for an in-game developer menu. It renders tabs, collapsible categories and tool buttons that invoke various in-game commands and toggles for spawning, diagnostics, placeables and settings, and it handles opening/closing with the Menu key and mouse visibility.
@using Sandbox;
@using Sandbox.UI;
@using System;
@using System.Linq;
@using System.Collections.Generic;
@using NZombies;
@inherits PanelComponent
@*
DEV MENU — press Q.
Designed in Docs/ui-design/devmenu-artifact.html and ported here once
settled. Iterating in the browser is a page refresh; iterating here is a
compile + hotload + play + screenshot, so the design work happens there and
only the finished layout lands here.
Structure: three tabs, collapsible categories, one vertical column of tools.
One category open at a time — Placeables alone has 45 tools, which is
unusable as a flat grid.
*@
<root class="devmenu">
@if ( Open )
{
<div class="frame">
<div class="titlebar">
<div class="title">nZombies — Dev</div>
<div class="hint">Q to close</div>
</div>
<div class="tabs">
@foreach ( var t in Tabs )
{
<div class="tab @(Tab == t ? "active" : "")" onclick=@( () => Tab = t )>@t</div>
}
</div>
<div class="body">
@foreach ( var cat in Cats.Where( c => c.Tab == Tab ) )
{
var isOpen = OpenCat == cat.Name;
<div class="cat @(isOpen ? "open" : "")">
<div class="cat-head" onclick=@( () => OpenCat = isOpen ? "" : cat.Name )>
<div class="dot" style="background-color: @cat.Color;"></div>
<div class="cat-name">@cat.Name</div>
<div class="count">@cat.Tools.Count</div>
</div>
@if ( isOpen )
{
<div class="tools">
@foreach ( var tool in cat.Tools )
{
@* ⚠️ Copied into a local before the closure.
A lambda capturing the loop variable itself is
the classic way to end up invoking the wrong
entry — belt and braces alongside the Cats
fix, because both produce the same symptom. *@
var t = tool;
<div class="tool @(t.Todo ? "todo" : "")"
onclick=@( () => Run( t ) )>
<div class="dot" style="background-color: @cat.Color;"></div>
<div class="tool-name">@t.Label</div>
@if ( !string.IsNullOrEmpty( t.Keys ) )
{
<div class="keys">@t.Keys</div>
}
</div>
}
</div>
}
</div>
}
</div>
</div>
@* The settings column, beside the frame. A child panel rather than its own
PanelComponent, so it needs no GameObject and no scene change. *@
<ToolPanel />
}
</root>
@code
{
/// <summary>
/// Closed on start — Q opens it.
///
/// [Property] so it can also be toggled remotely over MCP, which is the
/// only way to see this menu while driving the editor from another
/// machine. ⚠️ Setting it that way WRITES INTO THE SCENE, so a debugging
/// session can leave the menu opening on launch for everyone. If it starts
/// appearing unbidden, that is why — clear it on the component and re-save.
///
/// </summary>
[Property] public bool Open { get; set; }
[Property] public string Tab { get; set; } = "Dev";
string OpenCat = "Spawning";
static readonly string[] Tabs = new[] { "Dev", "Placeables", "Settings", "Easter egg" };
public record Tool( string Label, Action Act = null, bool Todo = false, string Keys = "" );
public record Cat( string Tab, string Name, string Color, List<Tool> Tools );
const string PLACE = "LMB place · RMB remove";
const string CORNER = "LMB corners · R reset";
/// <summary>Barricades take exactly two clicks and no height — the height is
/// fixed so the thing stays vaultable.</summary>
const string BARRICADE = "LMB 2 points · RMB remove · R reset";
// Colour per CATEGORY, not per tool: the label says what a tool is, the
// colour says which group it came from. That is what keeps a tool
// identifiable once you have scrolled past its header.
//
// ⚠️ A PROPERTY, NOT A `static readonly` FIELD. s&box hotload copies static
// values forward BY NAME, so a menu edited during a session kept serving the
// list built by an EARLIER compile — new labels rendered from the new code
// while the click handlers still pointed into the old one. That is how
// "Debris (buyable door)" came to arm the electricity switch.
//
// Third time this trap has cost real time today (AmbientBudget,
// WalkerFootsteps). Rebuilding is cheap: razor only re-renders when
// BuildHash changes, so this runs a handful of times, not per frame.
static List<Cat> Cats => new()
{
new( "Dev", "Spawning", "#7ad17a", new() {
new( "Spawn 1", () => ZombieCommands.Spawn() ),
new( "Horde 5", () => ZombieCommands.Horde( 5 ) ),
new( "Horde 35", () => ZombieCommands.Horde( 35 ) ),
// ⚠️ 6 is the original's FLOOR for a dog round, not a round number
// picked to look tidy: clamp(round x players, 6, 24). One hound is
// for looking at; six is what the round actually feels like.
new( "Hound 1", () => HoundCheck.SpawnReal( 1 ) ),
new( "Hound pack 6", () => HoundCheck.SpawnReal( 6 ) ),
// ⚠️ ONE, AND THAT IS NOT LAZINESS. A napalm zombie is a two-second fuse and a 200-unit
// blast; two of them arriving together is not "more of the same", it is a corner of the
// map you cannot stand in. `nz_napalm <n>` is there when a crowd is actually wanted.
new( "Napalm 1", () => NapalmZombie.SpawnCmd( 1 ) ),
new( "Shrieker 1", () => ShriekerZombie.SpawnCmd( 1 ) ),
new( "Pest 3", () => ZombieCommands.SpawnSpecial( SpecialEnemies.Pest, 3 ) ),
new( "Clear", () => ZombieCommands.Clear() ),
// ⛔ IT DROPS ONE IN FRONT OF YOU AND IGNORES PLACED BOSS SPAWNS. A dev button is for
// looking at the thing, and the placed-spawn version refuses outright on a map with no
// boss points — which is every map until someone places one. `nz_boss_spawn` is still
// the command that exercises the config data and the eligibility gating.
new( "Spawn boss", () => BossCommands.SpawnHere() ),
// ⚠️ NAMED, BECAUSE THE BUTTON ABOVE IS NOT. `SpawnHere()` with no argument falls back
// to `SpecialEnemies.BossFallback`, which is the FIRST id in `Names` that is a boss —
// Brutus today, and silently somebody else the moment that list is reordered. A button
// for looking at one particular boss has to say which one.
new( "Oberon", () => BossCommands.SpawnHere( SpecialEnemies.Oberon ) ) } ),
new( "Dev", "Diagnostics", "#5aa9f0", new() {
// View toggle — see the map as a player does, without leaving
// Creative and resetting the game.
new( "Preview mode", () => ToolCommands.Preview() ),
// ⚠️ IN DIAGNOSTICS, NOT SPAWNING. It turns and resizes whatever is already standing
// there; it spawns nothing, and a button that reads as a spawner in the spawn list is
// one click from confusing.
new( "Model tuner", () => ModelTuner.ModelCmd() ),
new( "Nav state", () => NavCommands.Nav() ),
@* The engine's own navmesh overlay, plus the marker toggle. Both are
toggles — click again to turn off. Console: nz_nav_draw, nz_markers. *@
@* DrawMesh is editor-only and renders nothing in play — see NavDots. *@
new( "Show navmesh (editor)", () => NavCommands.NavDraw() ),
new( "Navmesh live build", () => NavCommands.NavEditor() ),
new( "Sample navmesh (dots)", () => NavCommands.NavDots() ),
@* Grid-probed wireframe — reads as a surface where dots read as noise. *@
new( "Navmesh grid", () => NavCommands.NavGrid() ),
new( "Clear navmesh grid", () => NavCommands.NavGridClear() ),
@* Removes the knife's viewmodel camera, which hides every marker. *@
new( "Clear viewmodel cam", () => NavCommands.VmClear() ),
new( "Show markers", () => NavCommands.Markers() ),
new( "Nav links", () => NavLinkCommands.List() ),
new( "Nav link markers", () => NavCommands.NavLinkVis() ),
new( "Links", () => DoorCommands.Links() ),
new( "Status", () => ZombieCommands.Status() ),
new( "Animations", () => ZombieCommands.Anims() ),
new( "Collision", () => ZombieCommands.Collide() ),
new( "Physics", () => ZombieCommands.PhysicsDump() ),
new( "Player", () => ZombieCommands.PlayerStatus() ),
// Reads the RESOLVED model and sequence off each live zombie, so it
// answers "is that actually a hound" rather than "was one asked for".
new( "Hound info", () => HoundCheck.Info() ) } ),
// ⚠️ ALL THREE GO THROUGH `DevActions`, which are ConCmds — a button whose logic
// lives in a razor lambda cannot be run from the console or scripted.
//
// ⛔ "Kill all" IS NOT "Clear". Clear DESTROYS the objects; this kills them through
// Health.Apply so points, drops, ragdolls and every on-kill augment fire. Both are
// worth having and which you want depends on what you are testing.
new( "Dev", "Zombie", "#e0685f", new() {
new( "Kill all", () => DevActions.KillAll() ),
new( "Freeze AI", () => DevActions.ToggleFreeze() ),
new( "Toggle separation", () => DevActions.ToggleSeparation() ),
new( "Tank (1,000,000 hp)", () => DevActions.Tank() ) } ),
// ⛔ GODMODE NEEDED THE MECHANIC BUILT, not just wiring. Nothing in the project had
// any notion of invulnerability — see Health.Invulnerable, whose gate sits at the very
// top of Apply precisely so nothing below it runs.
//
// ⚠️ "Give weapon" IS RANDOM, because a button takes no argument. `nz_give <name>`
// already covers wanting a specific gun; a button can only usefully add "something
// else", and it draws from MysteryBox.Pool() so the map's BoxPacks filter is honoured.
new( "Dev", "Player", "#e0b45a", new() {
new( "Heal", () => DevActions.Heal() ),
new( "Godmode", () => DevActions.ToggleGod() ),
new( "Full armor", () => DevActions.FullArmor() ),
new( "Give points (100k)", () => DevActions.GivePoints() ),
new( "Give salvage (100k)", () => DevActions.GiveSalvage() ),
new( "Give weapon (random)", () => DevActions.GiveRandomWeapon() ),
new( "Remove all perks", () => PerkEffects.Clear() ),
new( "Remove all augments", () => AugmentCommands.ClearCmd() ) } ),
// ⚠️ ITS OWN CATEGORY, not filed under Player, because these act on the WEAPON
// IN YOUR HANDS rather than on you — and every one of them is keyed on that
// weapon's prefab path. Grouping them with Heal and Godmode would hide that
// distinction, which is the thing most likely to confuse a test session.
new( "Dev", "Weapon tech", "#c48fe0", new() {
// ⚠️ THE HELD WEAPON ONLY. `nz_tech_clear` wipes every tree on the player,
// which throws away the build on your other gun mid-test; this is the one
// for iterating on a single weapon.
new( "Clear tech (held weapon)", () => WeaponTech.ResetHeld() ),
new( "What is owned", () => WeaponTech.Owned() ),
new( "Clear ALL weapons", () => WeaponTech.ClearCmd() ),
new( "List the catalogue", () => WeaponTech.List() ),
// Rarity lives here too — same key, same weapon, same test loop.
new( "Rarity: report", () => Rarity.Report() ),
new( "Rarity: clear", () => Rarity.ClearCmd() ),
// ⚠️ HELD WEAPON ONLY, unlike "Rarity: clear" directly above which resets EVERY
// weapon. With two guns that is the difference between testing one tier against
// another and throwing both away.
new( "Rarity: held → Common", () => DevActions.HeldToCommon() ) } ),
new( "Placeables", "Spawners", "#7ad17a", new() {
new( "Zombie spawn", () => ToolPanelState.ShowTool( NZTools.ZombieSpawn ), false, PLACE ), new( "Special spawn", () => ToolPanelState.ShowTool( NZTools.SpecialSpawn ), false, PLACE ),
new( "Boss spawn", () => ToolPanelState.ShowTool( NZTools.BossSpawn ), false, PLACE ), new( "Player spawn", () => ToolPanelState.ShowTool( NZTools.PlayerSpawn ), false, PLACE ) } ),
new( "Placeables", "Player assisting", "#4fc9a8", new() {
// ⚠️ Filed under Player assisting beside the perk machines, which is
// where a mapper looks for it — even though Pack-a-Punch is NOT a perk
// in the code. The original files it the same way and for the same
// reason: it is a machine you walk up to and spend points at.
new( "Pack-a-Punch", () => ToolPanelState.ShowTool( NZTools.PackAPunch ), false, PLACE ),
@* Random perk for a fixed price. Sits here for the same reason
Pack-a-Punch does. Console: nz_fizz, nz_fizz_list, nz_fizz_clear. *@
new( "Der Wunderfizz", () => ToolPanelState.ShowTool( NZTools.Wunderfizz ), false, PLACE ),
@* One machine, one perk — picked in the tool's own settings panel, because
seventeen menu entries for one placeable would bury the category. Base
perks only; augments stay exclusive to the Wunderfizz above.
Console: nz_perk_machine, nz_perk_machine_list, nz_perk_machine_clear. *@
new( "Perk machine", () => ToolPanelState.ShowTool( NZTools.PerkMachine ), false,
"LMB place · perk in settings" ),
new( "Weapon buy", () => ToolPanelState.ShowTool( NZTools.WallBuy ), false, "LMB place · C weapon/price" ),
// One click, faces you. In the original these are random_box_spawns
// and the box MOVES between them after a teddy bear — phase three.
new( "Mystery box spawn", () => ToolPanelState.ShowTool( NZTools.MysteryBox ), false, PLACE ),
@* Refills the held weapon's reserve. Price is per Pack-a-Punch tier and climbs with
each refill of THAT weapon in the same round — tuned in Settings > Ammo box, not
per spot. Console: nz_ammobox, nz_ammobox_list, nz_ammobox_clear. *@
new( "Ammo box", () => ToolPanelState.ShowTool( NZTools.AmmoBox ), false, PLACE ),
@* Walk up, pay, the run is over. Price, prop, prompt, game-over text and the
keep-playing / reward-perks / perma-perks flags are all per spot, in the tool
panel. Console: nz_ending, nz_ending_list, nz_ending_price, nz_ending_test. *@
new( "Buyable ending", () => ToolPanelState.ShowTool( NZTools.BuyableEnding ), false, PLACE ),
new( "Electricity switch", () => ToolPanelState.ShowTool( NZTools.PowerSwitch ), false, PLACE ),
@* Armor tiers for salvage. Weapon tech and ammo mods are the other two
jobs in ARSENAL_REMAKE.md and need systems that do not exist yet.
Console: nz_arsenal, nz_arsenal_list, nz_arsenal_clear. *@
new( "Arsenal position", () => ToolPanelState.ShowTool( NZTools.Arsenal ), false, PLACE ),
@* Leave a weapon, or swap it for the one already there — upgrades, rarity and
reserve travel with it. Free, and contents last the whole game.
Console: nz_trade, nz_trade_list, nz_trade_empty, nz_trade_clear. *@
new( "Trading table", () => ToolPanelState.ShowTool( NZTools.TradeTable ), false, PLACE ),
@* ⚠️ THE BUILDING TABLE AND ITS PIECES LIVE UNDER **Easter egg > Collection**, which
had slots reserved for both of them before either existed. They were briefly listed
here too; one tool in two tabs is one tool somebody re-places because they did not
realise it was the same one. *@
new( "Exo station", null, true, PLACE ) } ),
new( "Placeables", "Map objects", "#a98ce8", new() {
// Buyable barrier — a door in all but name. Amber marker; the link
// and price it places with are set by nz_debris_set.
new( "Debris (buyable door)", () => ToolPanelState.ShowTool( NZTools.Debris ), false, CORNER ),
@* Kills near it fill it. Filed beside debris because both gate a door FLAG — but this
flag is an "all", not an "any": every box carrying it must be full before it opens.
Console: nz_soul, nz_soul_list, nz_soul_clear, nz_soul_feed. *@
new( "Soul box", () => ToolPanelState.ShowTool( NZTools.SoulBox ), false, PLACE ),
// Two clicked points become a boarded, vaultable wall. NOT the
// corner+height flow the walls below use — see BarricadeSpot.
new( "Barricade", () => ToolPanelState.ShowTool( NZTools.Barricade ), false, BARRICADE ),
@* Two clicks: the ledge, then where they land. The generated navmesh
covers everything you can WALK — this is for everything you cannot.
See NavLinkManager. Console: nz_navlink_*. *@
new( "Nav link (drop / one-way)", () => ToolPanelState.ShowTool( NZTools.NavLink ), false,
"LMB ledge · LMB landing · RMB remove" ),
new( "Traps, buttons, logic", null, true, "LMB · R dupe · C edit" ),
@* Interact to toggle the run into overdrive: no gap between rounds, spawn delay
pinned to the map's minimum, every zombie at the top speed tier. Starts off each
run. A placeholder box until the model lands. Console: nz_misery. *@
@* Colour, brightness, radius and an optional power gate, all in the tool panel.
⛔ THE ONLY PLACEABLE THAT COSTS FRAMERATE — s&box has no lightmaps, so every one is
dynamic every frame, and shadows are the expensive part. Emissive materials and the
indirect bake are both FREE; reach for those first and place these where a specific
spot needs a pool of light they cannot give.
Console: nz_light_place, nz_light_list, nz_light_set, nz_light_clear. *@
new( "Light", () => ToolPanelState.ShowTool( NZTools.MapLight ), false,
"LMB place · ⚠ costs fps" ),
@* A looping sound at a point — a vent, a generator, a dripping pipe. Picks a .sound
EVENT, not an audio file; Tools/import_sound.py turns an mp3/wav into one.
Console: nz_sound_place, nz_sound_list, nz_sound_set, nz_sound_clear. *@
new( "Sound", () => ToolPanelState.ShowTool( NZTools.SoundSpot ), false, PLACE ),
@* A patch of haze you walk INTO — volcanic soot, dust in a collapsed wing. Soft-edged
sphere, not a box, so the edge fades instead of popping.
⚠ You cannot see it from outside: VolumetricFogVolume renders nothing on the scene
path (it wants a baked fog texture from a compiled map), so these blend the ONE
scene-wide GradientFog by where you stand. Twenty areas cost the same as one.
Console: nz_fog_place, nz_fog_list, nz_fog_set, nz_fog_clear, nz_fog_report. *@
new( "Fog", () => ToolPanelState.ShowTool( NZTools.FogArea ), false, PLACE ),
new( "Misery acceleration device", () => ToolPanelState.ShowTool( NZTools.Misery ), false, PLACE ) } ),
new( "Placeables", "Walls", "#e8925a", new() {
// Solid forever, never buyable, invisible to the navmesh. Violet
// marker; visible/invisible is set in its settings panel or with
// nz_wall_set. Same corner + height-click flow as Debris.
new( "Invisible wall", () => ToolPanelState.ShowTool( NZTools.InvisibleWall ), false, CORNER ),
@* The invisible wall's twin: same corners, same footprint, but NO collision —
it hurts any player standing in it instead of stopping them. Drawn in creative
only; in a round it is invisible exactly like an invisible wall. Console:
nz_dmgwall_list, nz_dmgwall_set, nz_dmgwall_where, nz_dmgwall_enable. *@
new( "Damage wall", () => ToolPanelState.ShowTool( NZTools.DamageWall ), false, CORNER ),
@* A name for a space: drawn like the walls — corners, then a height click — with no body at all. Walk into it and
its name goes up at the top left (RoomNames). ⚠ Not the Easter egg's "Zone" further down, which is a step's
occupancy area and still a placeholder. Console: nz_room_zones, nz_room_zone_name, nz_room_zone_clear,
nz_room_zone_where. *@
new( "Room zone", () => ToolPanelState.ShowTool( NZTools.RoomZone ), false, CORNER ) } ),
new( "Placeables", "Transportation", "#5ad4d4", new() {
@* Click the pad, then click where it sends you — one record, not two.
Everyone standing on the pad travels together.
Console: nz_teleporter, nz_teleporter_list, nz_teleporter_clear. *@
new( "Teleporter", () => ToolPanelState.ShowTool( NZTools.Teleporter ), false,
"LMB pad · LMB destination · R reset" ),
@* Banana Colada's springboard made part of the map: nothing drawn in a round, its strength and radius
set in the tool panel. Touch the floor inside it and it throws you straight up.
Console: nz_springboard, nz_springboard_list, nz_springboard_clear. *@
new( "Springboard", () => ToolPanelState.ShowTool( NZTools.Springboard ), false,
"LMB place · LMB inside one re-sets it · RMB remove" ) } ),
new( "Settings", "Map", "#8fa0d8", new() {
new( "Gameplay", () => ToolPanelState.ShowGroup( ToolSettings.Gameplay ) ),
@* Tier count, price per tier and damage per tier — see ToolSettings.PapRows. *@
new( "Pack-a-Punch", () => ToolPanelState.ShowGroup( ToolSettings.Pap ) ),
@* Refill price per PaP tier and the per-round escalation. See ToolSettings.AmmoRows. *@
new( "Ammo box", () => ToolPanelState.ShowGroup( ToolSettings.Ammo ) ),
@* One name per flag — the HUD's top left says it once a player walks through where that flag's barrier stood.
See ToolSettings.RoomRows and RoomNames. Console: nz_rooms, nz_room_name, nz_room. *@
new( "Room names", () => ToolPanelState.ShowGroup( ToolSettings.Rooms ) ),
new( "Lobby", null, true ),
new( "Custom sounds", null, true ) } ),
new( "Settings", "Entities", "#4fc9a8", new() {
new( "Zombies", () => ToolPanelState.ShowGroup( ToolSettings.Zombies ) ),
@* When special rounds happen and how hard they are. The ENEMY is
chosen per-spawner, not here — see NZTools.SpecialSpawn. *@
new( "Special rounds", () => ToolPanelState.ShowGroup( ToolSettings.Specials ) ),
new( "Boss rounds", () => ToolPanelState.ShowGroup( ToolSettings.Bosses ) ),
new( "Players", () => ToolPanelState.ShowGroup( ToolSettings.Players ) ),
@* Edits whatever is in hand and saves on the spot — see
ToolSettings.WeaponRows. Console equivalents are nz_wep_*. *@
new( "Weapon stats", () => ToolPanelState.ShowGroup( ToolSettings.WeaponStats ) ) } ),
// ── EASTER EGG — the EE toolset. Design: Docs/EASTER_EGG_TOOLSET.md.
// PLACEHOLDERS ONLY for now: one entry per interactable in the spec, all `null, true`
// (they log "not wired yet"). Wiring — flags, conditions, steps — comes next. ──
new( "Easter egg", "Interactables", "#e8c84a", new() {
@* A button or lever carrying an EggStep: Required/Reward/Excluded flags plus
press-count, hold, cooldown, unlock-delay and round-reset conditions. Order,
Time window and Kills need the group coordinator and are not built yet.
Console: nz_press, nz_press_list, nz_press_flags. *@
new( "Pressable", () => ToolPanelState.ShowTool( NZTools.Pressable ), false, PLACE ),
@* A symbol or weak point triggered by a BULLET rather than the use key. Same
conditions as the pressable — they share the Egg* fields, so a step number and
its flags typed once cover both. Extra: "Needs Pack-a-Punch".
Console: nz_shoot, nz_shoot_list, nz_shoot_hit, nz_shoot_where. *@
new( "Shootable", () => ToolPanelState.ShowTool( NZTools.Shootable ), false, PLACE ),
new( "Lockpad", null, true, PLACE ),
@* Text or an image on a wall. NO flags, no conditions, no step — purely
informational, always visible, used to hint at things like a Lockpad's code.
Console: nz_clue, nz_clue_text, nz_clue_list, nz_clue_clear. *@
new( "Clue", () => ToolPanelState.ShowTool( NZTools.Clue ), false, PLACE ),
@* Basalt seal 1: a hexagon of light on a wall, showing a number (I-IV) in one of the four colours -
rolled again every round, no number and no colour twice. Nothing to set: what it shows is the roll's.
Console: nz_hex_slot, nz_hex_slots, nz_hex_slots_roll, nz_hex_slots_clear. *@
new( "Hex slot", () => ToolPanelState.ShowTool( NZTools.HexSlot ), false, PLACE ) } ),
new( "Easter egg", "Collection", "#7ac0e8", new() {
@* The bench the wonder weapon is assembled on. Same model as the trading table, so
the MARKER COLOUR is what tells them apart on a map carrying both.
Console: nz_buildtable, nz_buildtable_list, nz_buildtable_clear. *@
new( "Building table", () => ToolPanelState.ShowTool( NZTools.BuildTable ), false, PLACE ),
@* The three pieces that build it. WHICH piece is a mode on the tool rather than three
entries — nz_buildpart_set 1..3 — so all three can be laid out in one walk of the
map. nz_buildpart_list warns if a piece was placed NOWHERE, which is an unwinnable
map and the one failure nothing else reports.
Console: nz_buildpart, nz_buildpart_set, nz_buildpart_list, nz_buildpart_clear. *@
new( "Building piece (1/2/3)", () => ToolPanelState.ShowTool( NZTools.BuildPart ), false, PLACE ),
new( "Pickable", null, true, PLACE ),
new( "Drop zone", null, true, CORNER ) } ),
new( "Easter egg", "Zones & encounters", "#cf8fd4", new() {
new( "Zone", null, true, CORNER ),
new( "Soul box (EE)", null, true, PLACE ),
new( "Boss encounter", null, true, PLACE ),
new( "Escort", null, true, "LMB start · LMB end" ),
new( "Defense", null, true, PLACE ) } ),
};
void Run( Tool t )
{
if ( t.Act is null ) { Log.Info( $"[nz] {t.Label} — not wired yet" ); return; }
t.Act.Invoke();
}
protected override void OnUpdate()
{
// 'Menu' is already bound to Q in ProjectSettings/Input.config.
if ( Input.Pressed( "Menu" ) )
{
Open = !Open;
Mouse.Visibility = Open ? MouseVisibility.Visible : MouseVisibility.Auto;
}
// Leaving creative with the menu open would strand it on screen with
// no way to close it, since Q no longer responds.
// ⛔ AND NEVER IN A PUBLISHED COPY (`Edition`, 2026-10-05). Its host cannot enter Creative, but a client following an
// editor host into it can — and it gets no build menu.
if ( Open && (!NZGame.IsCreative || !Edition.CanBuild) )
{
Open = false;
Mouse.Visibility = MouseVisibility.Auto;
}
if ( Open ) Mouse.Visibility = MouseVisibility.Visible;
// Keep the settings panel pointed at whatever is actually armed —
// holstering with RMB, or arming from the console, must retarget it.
ToolPanelState.FollowActiveTool();
}
// ⚠️ Razor panels only re-render when this changes. OpenCat must be in it
// or the accordion will not visibly open.
protected override int BuildHash() => System.HashCode.Combine( Open, Tab, OpenCat );
}