UI/KeypadMenu.cs

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.

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