Part of the HexPlatforms manager. Implements a four-glyph shield lockpad: deals a random 4-glyph code, accepts tries from players, opens the shield on correct code, jams on wrong code until next round, and draws the local keypad/lock visuals.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT'S SHIELD LOCK. The cyan Combine shield — a hexagonal column of `EFFECTS/COM_SHIELD002A` beside teleporter #0's
/// near pad, 120u across and floor to ceiling — carries a lockpad on its east face, where the user had put a Desert Eagle
/// wall buy to mark the spot. The right code makes the lockpad and the shield disappear. Asked for as *"i want to replace
/// the wallbuy with a lockpad that needs a code of 4 numbers"*, then *"the right code makes both the lockpad and the
/// combine cyan wall disappear"*.
///
/// ⛔ THE CODE IS FOUR GLYPHS, NOT NUMBERS — four of the eight cryptic glyphs round the Color Rings clock
/// (`RingsClue.GlyphShapes`), one in each of the lockpad's four slots, and each slot is tinted in one of the four colours:
/// blue, yellow, green, red, in the colours' own order. Asked for as *"i dont want it to be numbers, 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"*.
/// Dealt at random, repeats allowed, as the platforms' glyphs are. It travels as four digits 1-8, glyph k as "k".
///
/// ⛔ THE CODE IS WRITTEN ON THE WALLS, WHERE ONLY THE CURSED FLAME SHOWS IT (`HexPlatforms.Code.cs`): each glyph a symbol
/// in its slot's colour on one of the 28 spots the user marked, a new code and new spots every game.
///
/// ⛔ ONE WRONG CODE JAMS IT UNTIL THE NEXT ROUND, FOR EVERYONE. Asked for as *"if i get the code wrong one time i cannot
/// input another code until the following round"*. ⚠️ THE WHOLE LOCK, NOT THE ONE WHO TYPED IT — a choice: a jam for the
/// typer alone would let a squad of four try four codes a round. The next round's start frees it
/// (<see cref="UnjamLock"/>).
///
/// ⛔ THE SHIELD IS PART OF A BIGGER MESH, SO IT IS SPLIT OUT OF IT — the first time the lock opens, as its twin's is when
/// the light blue flame brings that one down: both in `HexPlatforms.Shields.cs` (<see cref="SplitColumn"/>). The twin
/// column at the teleporter's far end ("Mesh 49") is not this lock's (`HexPlatforms.Twin.cs`).
///
/// ⛔ ENTERED ON A KEYPAD. Looking at the lock within reach shows "Press E - Enter the code" (`UsePrompt.ForShieldLock`), and
/// E opens a pad on screen (`KeypadMenu`): the eight glyphs as keys, the four tinted slots filling as they are pressed, the
/// code sent as the fourth goes in.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the CODE is HOST state and never leaves it whole — a client holding it could read it off its console. It leaves one
/// symbol at a time, as the cursed flame shows each (`HexPlatforms.Code.cs`). A new one every game, and `nz_hex_lock`
/// shows it, for testing;
/// - a TRY goes from the presser's machine to the host (`NZNet.ShieldLockTry`), which knows who from the call itself;
/// - OPEN and JAMMED are HOST state, MIRRORED to everyone (`NZNet.ShieldLockState`) and to a joiner. A wrong try is told
/// back (`NZNet.ShieldLockWrong`), so the presser's keypad can say so;
/// - the LOCKPAD and the SHIELD'S FACES are LOCAL: every machine draws its own lock and hides its own copy of the faces.
/// </summary>
public sealed partial class HexPlatforms
{
// ══ where it is ══════════════════════════════════════════════════════════════════════════
/// <summary>
/// The lockpad's middle: where the Desert Eagle wall buy stood, 1.5u off the shield's east flat (x 1852), facing +X —
/// read off it with `nz_wallbuy_list` on 2026-09-26.
/// </summary>
static Vector3 LockAt => new( 1853.5f, 614.45f, 1249.6f );
/// <summary>Which way the lockpad faces: out of the shield's east flat.</summary>
static Vector3 LockFacing => new( 1f, 0f, 0f );
/// <summary>The shield column's middle (BSP brush 1467): its faces lie 60u out of it, the frame round its top twice that.</summary>
static Vector3 ShieldCentre => new( 1792f, 608f, 1296.5f );
/// <summary>How far a player's feet may be from the lockpad to use it, and how near a look must pass to its middle.</summary>
static float? _lockReach;
public static float LockReach { get => Math.Clamp( _lockReach ?? 110f, 40f, 400f ); set => _lockReach = value; }
const float LockAimRadius = 16f;
/// <summary>How far one player is from the lockpad. For `KeypadMenu`, which shuts when they walk away.</summary>
public static float LockDistance( NZPlayer p ) => p.IsValid() ? LockAt.Distance( p.WorldPosition ) : float.MaxValue;
// ══ the state ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// HOST — the code: four glyphs, one per slot in the colours' order (blue, yellow, green, red), as the digits 1-8; null
/// until the first game deals it.
/// </summary>
string _lockGlyphs;
/// <summary>HOST — is the lock open: the right code in, the lockpad and the shield gone?</summary>
bool _lockOpen;
/// <summary>MIRROR — the same, as this machine was told (`NZNet.ShieldLockState`).</summary>
bool _lockOpenShown;
/// <summary>HOST — is the lock jammed: a wrong code in this round, and no other taken until the next one starts?</summary>
bool _lockJammed;
/// <summary>MIRROR — the same, as this machine was told (`NZNet.ShieldLockState`). The prompt and the keypad say so.</summary>
bool _lockJammedShown;
/// <summary>HOST — whether it is open, and jammed, for `NZNet.PushState` to replay to a joiner.</summary>
public static bool LockOpenState => Instance.IsValid() && Instance._lockOpen;
public static bool LockJammedState => Instance.IsValid() && Instance._lockJammed;
/// <summary>MIRROR — is it open on this machine? The keypad asks.</summary>
public static bool LockOpenShown => Instance.IsValid() && Instance._lockOpenShown;
/// <summary>MIRROR — is it jammed on this machine? The prompt and the keypad ask.</summary>
public static bool LockJammedShown => Instance.IsValid() && Instance._lockJammedShown;
/// <summary>The code as four glyph digits 1-8, dealt on first need. HOST.</summary>
string LockCode => _lockGlyphs ??= DealLockCode();
/// <summary>Four glyphs at random, 1-8 each, repeats allowed — as the platforms' glyphs are dealt.</summary>
static string DealLockCode() => string.Concat( Enumerable.Range( 0, 4 ).Select( _ => (char)('1' + Game.Random.Next( 8 )) ) );
/// <summary>A code in words, slot by slot: "blue 3 · yellow 7 · green 1 · red 8".</summary>
static string Spell( string code )
=> string.Join( " · ", Enumerable.Range( 0, Math.Min( 4, code?.Length ?? 0 ) ).Select( k => $"{ColourName( k )} {code[k]}" ) );
/// <summary>Is this four glyph digits, 1-8?</summary>
static bool IsGlyphCode( string code ) => code is { Length: 4 } && code.All( c => c >= '1' && c <= '8' );
void SendLock() => NZNet.ShieldLockState( _lockOpen, _lockJammed );
/// <summary>
/// Open or shut, and jammed or not. EVERY machine — `NZNet.ShieldLockState`: the lockpad and the shield dressed to match,
/// and the keypad told.
/// </summary>
public void ApplyLock( bool open, bool jammed )
{
var wasJammed = _lockJammedShown;
_lockOpenShown = open;
_lockJammedShown = jammed;
BuildLock();
if ( open ) KeypadMenu.Close();
else if ( jammed && !wasJammed ) KeypadMenu.Jammed();
else if ( wasJammed && !jammed ) KeypadMenu.Clear();
}
/// <summary>A new game: a new code on new spots, the lock shut and free, and the shield back. HOST — `RoundBegan( 1 )`.</summary>
void ResetLock()
{
DealCode();
_lockOpen = false;
_lockJammed = false;
SendLock();
}
/// <summary>A round began: a lock jammed by a wrong code takes codes again. HOST — `RoundBegan`, every round.</summary>
void UnjamLock()
{
if ( !_lockJammed ) return;
_lockJammed = false;
SendLock();
Log.Info( "[nz-hex] 🔒 a new round — the shield lock takes a code again" );
}
// ══ trying a code ════════════════════════════════════════════════════════════════════════
/// <summary>
/// Is this player looking at the lockpad, shut and near enough to use? LOCAL — the prompt's test and the use key's, one
/// question for both (`UsePrompt.ForShieldLock`, `NZPlayer.TickUse`): the camera's ray through a sphere round the pad,
/// as for the cursed flame, since the pad has no collider. ⚠️ STILL TRUE WHILE IT IS JAMMED: the prompt then says so,
/// and E does nothing — nor anything behind it.
/// </summary>
public static bool LockAimed( NZPlayer player )
{
var m = Instance;
if ( !m.IsValid() || !OnBasalt || m._lockOpenShown || CannotCarry( player ) ) return false;
if ( LockAt.Distance( player.WorldPosition ) > LockReach ) return false;
var cam = m.Scene.Camera;
return cam.IsValid() && RayMeetsSphere( cam.WorldPosition, cam.WorldRotation.Forward, LockAt, LockAimRadius );
}
/// <summary>
/// A code typed on the keypad. HOST — `NZNet.ShieldLockTry`, with `who` the caller's connection id as the call itself
/// carries it, or "" for the host's own. A wrong one is told back to whoever typed it.
/// </summary>
public static void HostTryCode( string who, string digits )
{
if ( NZGame.IsClient ) return;
var m = Instance;
if ( !m.IsValid() ) return;
var body = CarrierBodyOf( who );
if ( !body.IsValid() && (string.IsNullOrEmpty( who ) || who == Connection.Local?.Id.ToString()) ) body = NZPlayer.Local;
// ⛔ EVERY TRY IS ANSWERED (the co-op audit, 2026-09-27 — *"fix the keypad checking bug"*). Only a wrong code was: one
// the host refused — from too far, or while down — left the typist's pad on "CHECKING…" for good.
var tried = m.TryCode( body, digits );
if ( tried == LockTry.Wrong ) NZNet.ShieldLockWrong( who ?? "" );
else if ( tried == LockTry.Ignored ) NZNet.ShieldLockIgnored( who ?? "", m.WhyIgnored( body, digits ) );
}
/// <summary>Why a code was turned away, for the typist's pad — `TryCode`'s own tests, in its order.</summary>
string WhyIgnored( NZPlayer body, string digits )
=> _lockOpen ? "IT IS OPEN"
: _lockJammed ? "JAMMED UNTIL THE NEXT ROUND"
: CannotCarry( body ) ? "NOT WHILE DOWN"
: LockAt.Distance( body.WorldPosition ) > LockAcceptReach ? "TOO FAR — STEP CLOSER"
: !IsGlyphCode( digits ) ? "TRY AGAIN"
: "TRY AGAIN";
/// <summary>How far from the lock the host still takes a code: its reach, and the slack every reach allows.</summary>
public static float LockAcceptReach => LockReach + ReachSlack;
/// <summary>What a try did.</summary>
enum LockTry { Opened, Wrong, Ignored }
/// <summary>
/// The rules for a code. HOST — apart from the RPC, so the selftest can walk it. The lock must be shut and not jammed,
/// the presser up and within reach (`anywhere` skips the reach, for the test and `nz_hex_lock try`), and the code four
/// glyphs. Right opens it, with the step-done clicking; wrong plays the power-down at the lock and jams it until the
/// next round, for everyone.
/// </summary>
LockTry TryCode( NZPlayer body, string digits, bool anywhere = false )
{
if ( _lockOpen || _lockJammed || CannotCarry( body ) ) return LockTry.Ignored;
if ( !anywhere && LockAt.Distance( body.WorldPosition ) > LockReach + ReachSlack ) return LockTry.Ignored;
if ( !IsGlyphCode( digits ) ) return LockTry.Ignored;
if ( digits != LockCode )
{
_lockJammed = true;
SendLock();
Cue( FailCue, LockAt );
Log.Info( $"[nz-hex] 🔒 {NameFor( body )} tried {Spell( digits )} on the shield lock — wrong. It takes no other code"
+ " until the next round" );
return LockTry.Wrong;
}
_lockOpen = true;
SendLock();
Cue( DoneCue );
Fanfare( 5 );
Log.Info( $"[nz-hex] 🔓 THE SHIELD LOCK OPENS — {NameFor( body )} entered the code, and the lockpad and the cyan shield are gone" );
return LockTry.Opened;
}
// ══ the lockpad and the shield ═══════════════════════════════════════════════════════════
/// <summary>LOCAL — the lockpad drawn on the shield.</summary>
GameObject _lockGo;
/// <summary>
/// How the lockpad is drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 2: glyph keys and four tinted slots (2026-09-26). A manager
/// that has not laid this layout draws the lock again — `OnUpdate` asks every frame — so a running game shows the new
/// pad at once, not at the next change.
/// </summary>
const int LockLayout = 2;
/// <summary>LOCAL — the layout this manager last drew the lock in; 0 before it has.</summary>
int _lockLaid;
// the lockpad's layout, in its own units: -0.8..0.8 across, -0.68..1 up
/// <summary>Where slot k sits across the pad, and each slot's half-size.</summary>
static float SlotX( int k ) => -0.57f + k * 0.38f;
const float SlotY = 0.72f, SlotHalfW = 0.15f, SlotHalfH = 0.17f;
/// <summary>Where glyph k's key sits: 1-4 across the upper row, 5-8 the lower, under the slots.</summary>
static Vector2 KeyAt( int glyph ) => new( SlotX( (glyph - 1) % 4 ), glyph <= 4 ? 0.08f : -0.36f );
const float KeyHalf = 0.17f, KeyGlyphUnit = 0.16f;
/// <summary>World units to one of the lockpad's units: 24, so it is 38u wide and 40u tall. Its stroke, in its units.</summary>
const float LockpadScale = 24f, LockpadStroke = 0.028f;
/// <summary>A rectangle outline, as one stroke-family line, round its middle.</summary>
static (string Kind, float[] P) Box( float x, float y, float halfW, float halfH )
=> ("line", new[] { x - halfW, y - halfH, x + halfW, y - halfH, x + halfW, y + halfH, x - halfW, y + halfH, x - halfW, y - halfH });
/// <summary>
/// The lockpad's white light, in the stroke family: its frame, a rule under the slots, and the eight glyph keys — each a
/// square with its glyph in it, drawn from `RingsClue.GlyphShapes` so it is the clock's own. A property, so a change
/// reaches a running game (INSTRUCTIONS.md §1).
/// </summary>
static (string Kind, float[] P)[] LockpadShapes
{
get
{
var s = new List<(string Kind, float[] P)>
{
Box( 0f, 0.16f, 0.80f, 0.84f ),
("line", new[] { -0.72f, 0.45f, 0.72f, 0.45f }),
};
for ( var g = 1; g <= RingPositions; g++ )
{
var at = KeyAt( g );
s.Add( Box( at.x, at.y, KeyHalf, KeyHalf ) );
foreach ( var (kind, p) in RingsClue.GlyphShapes( g ) )
{
var q = p.ToArray(); // ⚠️ NOT Array.Clone(): the sandbox's whitelist refuses it
if ( kind == "line" )
for ( var i = 0; i + 1 < q.Length; i += 2 ) { q[i] = at.x + q[i] * KeyGlyphUnit; q[i + 1] = at.y + q[i + 1] * KeyGlyphUnit; }
else
{
q[0] = at.x + q[0] * KeyGlyphUnit;
q[1] = at.y + q[1] * KeyGlyphUnit;
q[2] *= KeyGlyphUnit;
}
s.Add( (kind, q) );
}
}
return s.ToArray();
}
}
/// <summary>
/// The lockpad as this machine was told: the pad on the shield while shut — its white light and its four slots, each in
/// its colour's own light, the tiles' — and both gone once open. LOCAL.
/// </summary>
void BuildLock()
{
if ( !OnBasalt ) { ClearLock(); return; }
var open = _lockOpenShown;
SetShieldHidden( open );
if ( open || _lockLaid != LockLayout )
{
if ( _lockGo.IsValid() ) _lockGo.Destroy();
_lockGo = null;
}
_lockLaid = LockLayout;
if ( open || _lockGo.IsValid() ) return;
var root = Scene.CreateObject();
root.Name = "Basalt shield lock";
root.Flags |= GameObjectFlags.NotSaved;
root.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
root.Tags.Add( PanelTag );
root.WorldPosition = LockAt;
root.WorldRotation = Rotation.LookAt( LockFacing, Vector3.Up );
LockPart( root, "light", IconModel( LockpadShapes, LockpadStroke, White, LockpadScale ) );
for ( var k = 0; k < Colours; k++ )
LockPart( root, $"slot {k + 1} ({ColourName( k )})",
IconModel( new[] { Box( SlotX( k ), SlotY, SlotHalfW, SlotHalfH ) }, LockpadStroke, k, LockpadScale ) );
_lockGo = root;
}
/// <summary>One of the lockpad's parts: a child of it, in its plane, drawing this model.</summary>
void LockPart( GameObject root, string name, Model model )
{
if ( model is null ) return;
var go = Scene.CreateObject();
go.Name = name;
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
go.SetParent( root, false );
go.LocalPosition = Vector3.Zero;
go.LocalRotation = Rotation.Identity;
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
r.RenderType = ModelRenderer.ShadowRenderType.Off;
}
/// <summary>The lock's shield gone, or back — its column's, by the two columns' one way (`HexPlatforms.Shields.cs`). LOCAL.</summary>
void SetShieldHidden( bool hidden ) => SetColumnHidden( LockColumn, hidden );
/// <summary>Is the lock's shield there on this machine? For the selftest and `nz_hex_lock`.</summary>
bool ShieldStands() => ColumnStands( LockColumn );
void ClearLock()
{
if ( _lockGo.IsValid() ) _lockGo.Destroy();
_lockGo = null;
_lockLaid = LockLayout;
SetShieldHidden( false );
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_hex_lock [open|close|jam|unjam|new|code <gggg>|try <gggg>]` — HOST: the shield lock, and its code. `open`
/// opens it as the right code would, `close` shuts it with the shield back; `jam` jams it as a wrong code would, and
/// `unjam` frees it as the next round does; `new` deals a new code on new spots, `code` sets the glyphs, and `try` enters
/// one as you would on the keypad, from wherever you stand, by the rules — a jammed lock ignores it. A code is four
/// glyphs, 1-8, blue's first. Bare, it says where it stands, and the code and where its symbols are, which is no secret
/// here: this is the host's console, and testing.
/// </summary>
[ConCmd( "nz_hex_lock" )]
public static void LockCmd( string what = "", string arg = "" )
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
var m = Ensure();
if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no game running — start one first" ); return; }
switch ( what.Trim().ToLowerInvariant() )
{
case "":
break;
case "open":
m._lockOpen = true;
m.SendLock();
break;
case "close":
m._lockOpen = false;
m.SendLock();
break;
case "jam":
m._lockJammed = true;
m.SendLock();
break;
case "unjam":
m._lockJammed = false;
m.SendLock();
break;
case "new":
m.DealCode();
break;
case "code":
if ( !IsGlyphCode( arg.Trim() ) ) { Log.Warning( "[nz-hex] nz_hex_lock code <four glyphs, 1-8 each, blue's first> — e.g. 3718" ); return; }
m._lockGlyphs = arg.Trim();
break;
case "try":
{
var got = m.TryCode( NZPlayer.Local, arg.Trim(), anywhere: true );
Log.Info( $"[nz-hex] try {arg}: {got}" );
break;
}
default:
Log.Warning( "[nz-hex] nz_hex_lock open, close, jam, unjam, new, code <gggg> or try <gggg> — glyphs 1-8 — or nothing, to see where it stands" );
return;
}
Log.Info( $"[nz-hex] the shield lock is {( m._lockOpen ? "OPEN — the lockpad and the shield gone" : m._lockJammed ? "shut, and JAMMED until the next round" : "shut" )}"
+ $" · its code {Spell( m.LockCode )} · the shield {( m.ShieldStands() ? "stands" : "is down, or not found" )} here" );
Log.Info( $"[nz-hex] its symbols: {m.SymbolsText()}" );
}
/// <summary>
/// `nz_hex_lock_place` — HOST: take away any wall buy standing where the lockpad is — the Desert Eagle that marked the
/// spot — so the two do not sit one on the other. In memory, like every edit: Save (nz_save) keeps it.
/// </summary>
[ConCmd( "nz_hex_lock_place" )]
public static void LockPlaceCmd()
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }
var cfg = ActiveConfig.Current;
if ( cfg?.WallBuys is null ) { Log.Warning( "[nz-hex] no config here" ); return; }
var gone = cfg.WallBuys.Where( b => b.Position.Distance( LockAt ) < 24f ).ToList();
foreach ( var b in gone ) cfg.WallBuys.Remove( b );
if ( gone.Count > 0 ) WallBuyManager.Ensure()?.Rebuild();
Log.Info( gone.Count == 0 ? "[nz-hex] no wall buy stands where the shield lock is"
: $"[nz-hex] {string.Join( ", ", gone.Select( b => b.WeaponPrefab ) )} went from where the shield lock is — in memory: Save (nz_save) keeps it" );
}
}