UI helper for the shield lock keypad menu. Manages open/close state, entered four-digit glyph code (digits 1-8), status text, host GameObject creation, input helpers (press/back/clear) and timeouts while awaiting the host response.
using System;
using Sandbox;
namespace NZombies;
/// <summary>
/// Open/close state for basalt's shield lock keypad (`HexPlatforms.Lock.cs`): four of the Color Rings' eight glyphs, pressed
/// on a pad on screen, one into each of four slots tinted blue, yellow, green and red, and sent to the host as the fourth
/// goes in (`NZNet.ShieldLockTry`). Asked for as *"i want it to be the 8 cryptic icons from the circle — and i want each
/// slot in the lockpad to be slightly colored in the 4 colors we use"*.
///
/// ⛔ STATE HERE, DRAWING IN THE RAZOR (`KeypadPanel`) — the Wunderfizz menu's split, so a command can drive it and the way
/// out does not depend on the panel: `NZPlayer` closes it on ESC and when the player walks away (<see cref="TickRange"/>).
///
/// ⚠️ THE MOUSE ONLY. The number keys are also the weapon slots, and typing a code would change guns.
///
/// ⚠️ A CODE IS FOUR DIGITS 1-8, glyph k as "k" — how it crosses the network.
///
/// ⚠️ ONE WRONG CODE JAMS THE LOCK UNTIL THE NEXT ROUND, for everyone (`HexPlatforms.Lock.cs`): the pad then says so and
/// takes no glyph, and the lock's prompt says so instead of offering it.
/// </summary>
public static class KeypadMenu
{
/// <summary>Is the keypad on screen?</summary>
public static bool IsOpen { get; private set; }
/// <summary>The glyphs pressed so far, up to four, as the digits 1-8.</summary>
public static string Entered { get; private set; } = "";
/// <summary>A line under the display: checking, or wrong.</summary>
public static string Status { get; private set; } = "";
const string Checking = "CHECKING…";
/// <summary>The keys: the eight glyphs, in the clock's order.</summary>
public static int[] Glyphs => new[] { 1, 2, 3, 4, 5, 6, 7, 8 };
/// <summary>A glyph's picture — `Tools/basalt_glyphs.py` draws them from the clock's own shapes.</summary>
public static string GlyphImage( int glyph ) => $"materials/clues/glyphs/glyph_{Math.Clamp( glyph, 1, 8 )}.png";
/// <summary>The glyph in slot k, 1-8, or 0 while it is empty.</summary>
public static int GlyphIn( int slot ) => slot >= 0 && slot < Entered.Length ? Entered[slot] - '0' : 0;
/// <summary>
/// Slot k's tint, as the style it is drawn with: its colour — the tiles' own, retuned or not — faint over the pad and a
/// little stronger for its edge. Blue, yellow, green, red.
/// </summary>
public static string SlotStyle( int slot )
{
var c = HexPlatforms.HueOf( slot );
int r = (int)(c.r * 255f), g = (int)(c.g * 255f), b = (int)(c.b * 255f);
return $"background-color: rgba( {r}, {g}, {b}, 0.16 ); border: 2px solid rgba( {r}, {g}, {b}, 0.75 );";
}
static GameObject _host;
/// <summary>
/// The object the panel lives on, made on first open.
///
/// ⚠️ MADE IN CODE, NOT PLACED IN THE SCENE, as the Wunderfizz's is — and so the razor carries `@namespace NZombies`.
/// </summary>
static void EnsureHost()
{
if ( _host.IsValid() ) return;
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return;
_host = scene.CreateObject();
_host.Name = "Keypad UI";
_host.Flags |= GameObjectFlags.NotSaved;
_host.Components.Create<ScreenPanel>();
_host.Components.Create<KeypadPanel>();
}
/// <summary>Open the keypad, empty. LOCAL — the use key on the lockpad (`NZPlayer.TickUse`).</summary>
public static void Open()
{
EnsureHost();
IsOpen = true;
Entered = "";
Status = "";
// ⚠️ THE CURSOR IS WHAT MAKES A MENU USABLE — and with it up, V no longer toggles noclip under the player
Mouse.Visibility = MouseVisibility.Visible;
}
public static void Close()
{
if ( !IsOpen ) return;
IsOpen = false;
Entered = "";
Status = "";
Mouse.Visibility = MouseVisibility.Hidden;
}
/// <summary>Empty the slots.</summary>
public static void Clear()
{
Entered = "";
Status = "";
}
/// <summary>Take the last glyph back.</summary>
public static void Back()
{
if ( Entered.Length > 0 ) Entered = Entered[..^1];
Status = "";
}
/// <summary>A glyph pressed on the pad: into the next slot, and the code sent once the fourth is in. Nothing while the lock is jammed.</summary>
public static void Press( int glyph )
{
if ( !IsOpen || glyph < 1 || glyph > 8 || Entered.Length >= 4 || HexPlatforms.LockJammedShown ) return;
Entered += (char)('0' + glyph);
Status = "";
if ( Entered.Length < 4 ) return;
// the fourth: to the host, which says if it was wrong (`Wrong`), or opens the lock for everyone (the keypad shuts)
Status = Checking;
_sent = 0f;
NZNet.ShieldLockTry( Entered );
}
/// <summary>Since this pad sent its code — "CHECKING…" gives up after <see cref="AnswerWait"/>.</summary>
static TimeSince _sent;
/// <summary>How long a code waits for the host's answer before the pad lets the player try again.</summary>
const float AnswerWait = 3f;
/// <summary>
/// The host turned the code away without judging it, and says why. LOCAL — `NZNet.ShieldLockIgnored`, to whoever typed it.
/// The slots empty for another try.
/// </summary>
public static void Ignored( string why )
{
if ( !IsOpen ) return;
Entered = "";
Status = string.IsNullOrWhiteSpace( why ) ? "TRY AGAIN" : why;
}
/// <summary>The host said the code was wrong. LOCAL — `NZNet.ShieldLockWrong`, to whoever typed it.</summary>
public static void Wrong()
{
if ( !IsOpen ) return;
Entered = "";
Status = "WRONG CODE";
}
/// <summary>
/// The lock was jammed, by this player's wrong code or someone else's. LOCAL — `HexPlatforms.ApplyLock`. The slots
/// empty, and a code this pad sent and the host has not answered never will be: a jammed lock ignores it.
/// </summary>
public static void Jammed()
{
if ( !IsOpen ) return;
Entered = "";
if ( Status == Checking ) Status = "";
}
/// <summary>
/// Shut it when the lock is open, or the player walks away from it. From `NZPlayer`'s tick, beside the ESC handler.
/// Half as far again as the reach that opens it, so standing on the edge does not flicker it open and shut.
/// </summary>
///
/// ⛔ AND INSIDE THE HOST'S OWN REACH, NOT HALF AS FAR AGAIN — AND NEVER WHILE DOWN (the co-op audit, 2026-09-27). The pad
/// stayed open out to 1.5 × the reach, past where the host still takes a code, and while its player was down, when it
/// takes none: a code sent from there was turned away, and the pad said "CHECKING…" for good. And if no answer comes at
/// all, it gives up after <see cref="AnswerWait"/>.
public static void TickRange( NZPlayer player )
{
if ( !IsOpen ) return;
if ( HexPlatforms.LockOpenShown || !HexPlatforms.OnBasalt ) { Close(); return; }
if ( player.IsValid() && (player.IsDown || player.IsOutOfRound) ) { Close(); return; }
if ( player.IsValid() && HexPlatforms.LockDistance( player ) > HexPlatforms.LockAcceptReach - 8f ) { Close(); return; }
if ( Status == Checking && _sent > AnswerWait )
{
Entered = "";
Status = "NO ANSWER — TRY AGAIN";
}
}
}