A partial class HexPlatforms that manages the map locations and visual display of four puzzle glyph symbols for a shield lock. It defines the 28 fixed spot positions and facings, deals a random four-spot code, decides which symbols should show based on a carried cursed flame and reveal radius (host authority), packs shown-symbol state into a compact SymbolSet, and creates/destroys local GameObjects to draw symbols and previews.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT — THE SHIELD LOCK'S CODE, WRITTEN ON THE WALLS. The code's four glyphs (`HexPlatforms.Lock.cs`) stand as four
/// symbols on four of the 28 spots the user marked round the map with Desert Eagle wall buys
/// (Docs/BASALT_MARKED_SPOTS.md), each in the colour of the lockpad slot it goes in, and only the cursed flame shows them.
/// Asked for as *"those 28 spots are where the 4 code symbols can appear — each symbol will have one of the 4 colors with
/// no repeats — and each of the 4 symbols will appear in one of the 28 slots with no repeats — the code changes every
/// game … these symbols are invisible to players, but if someone is carrying the cursed flame and is within a medium
/// radius of one of the symbols, it becomes visible"*, and *"the color of the symbol matches the slot it's supposed to go
/// in in the lockpad"*.
///
/// ⛔ THE RULES:
/// - four symbols, one per colour — blue, yellow, green, red, the slots' order — each the glyph its slot wants, drawn from
/// the Color Rings clock's own shapes (`RingsClue.GlyphShapes`) in its colour's light, the tiles';
/// - on four DIFFERENT spots of the 28, dealt with the code: a new game, or a config loaded, deals both again
/// (<see cref="DealCode"/>) — never a new round;
/// - a symbol shows while the cursed flame's carrier is within <see cref="RevealRadius"/> of it, to everyone, and goes
/// again when they walk away. ⚠️ AND ONLY FROM ITS OWN SIDE OF ITS SURFACE — a choice, not asked for: the flame lights
/// what it stands before, so a carrier on the far side of a wall does not light the symbol on its near face;
/// - once the lock is open, none shows: the code is spent.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the SPOTS, like the glyphs, are HOST state, dealt there and never sent whole;
/// - which symbols SHOW is decided by the HOST — from where it sees the carrier — five times a second, and MIRRORED
/// (`NZNet.CodeSymbols`) on every change and to a joiner. ⚠️ ONLY A SYMBOL ON SHOW CARRIES ITS SPOT AND GLYPH
/// (<see cref="SymbolSet"/>), so the code leaves the host one symbol at a time, as the flame shows it — never before;
/// - the SYMBOLS themselves are LOCAL: every machine draws its own from the set it was sent.
/// </summary>
public sealed partial class HexPlatforms
{
// ══ the spots ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// The 28 spots, n = 1-28 west to east as Docs/BASALT_MARKED_SPOTS.md numbers them (index n - 1 here): where each of the
/// user's Desert Eagle wall buys stood, 1.5u off its surface, and its angles — pitch, yaw, roll — which face straight
/// out of it, read with `nz_wallbuy_list` on 2026-09-26 before they were taken away. 24 are on walls, three on ceilings
/// (5, 17, 19) and one on a floor (3); #7 is on Debris 10, which never moves. A property, so a change reaches a running
/// game (INSTRUCTIONS.md §1).
/// </summary>
static (Vector3 At, Angles Facing)[] CodeSpots => new (Vector3, Angles)[]
{
(new( -5052.91f, -398.42f, 1843.82f ), new( 0f, -120.11f, 0f )), // 1 wall
(new( -4582.47f, -428.26f, 1759.15f ), new( 0f, 119.74f, 0f )), // 2 wall
(new( -4503.36f, -23.04f, 1377.50f ), new( -90f, -90f, 0f )), // 3 floor
(new( -4222.50f, -569.34f, 1758.63f ), new( 0f, 0f, 0f )), // 4 wall
(new( -3120.43f, -1275.15f, 1366.50f ), new( 90f, 90f, 0f )), // 5 ceiling
(new( -3090.50f, -2091.84f, 1204.58f ), new( 0f, 0f, 0f )), // 6 wall
(new( -2737.02f, -369.05f, 1212.61f ), new( 0f, 89.40f, 0f )), // 7 wall, on Debris 10
(new( -2420.03f, -2198.91f, 1419.69f ), new( 0f, -59.86f, 0f )), // 8 wall
(new( -1723.11f, -86.81f, 1217.15f ), new( 0f, -119.74f, 0f )), // 9 wall
(new( -1497.62f, -2204.81f, 1468.40f ), new( 0f, 59.86f, 0f )), // 10 wall
(new( -1477.50f, 497.29f, 1220.22f ), new( 0f, 180f, 0f )), // 11 wall
(new( -1098.50f, 383.55f, 1237.05f ), new( 0f, 0f, 0f )), // 12 wall
(new( -1054.50f, -1641.32f, 1466.53f ), new( 0f, 0f, 0f )), // 13 wall
(new( -970.50f, -290.38f, 1234.92f ), new( 0f, 0f, 0f )), // 14 wall
(new( -614.53f, -1077.31f, 1483.64f ), new( 0f, 59.86f, 0f )), // 15 wall
(new( -476.87f, -1973.08f, 1484.82f ), new( 0f, -59.86f, 0f )), // 16 wall
(new( -282.17f, 710.44f, 1353.50f ), new( 90f, 90f, 0f )), // 17 ceiling
(new( -158.50f, -2310.65f, 1361.53f ), new( 0f, 0f, 0f )), // 18 wall
(new( -18.75f, 521.26f, 1364.50f ), new( 90f, 90f, 0f )), // 19 ceiling
(new( 9.50f, -626.64f, 1303.96f ), new( 0f, 0f, 0f )), // 20 wall
(new( 105.50f, -967.83f, 1257.08f ), new( 0f, 0f, 0f )), // 21 wall
(new( 365.73f, -292.43f, 1254.76f ), new( 0f, 59.86f, 0f )), // 22 wall
(new( 619.66f, 636.93f, 1261.34f ), new( 0f, -60.26f, 0f )), // 23 wall
(new( 850.83f, 690.20f, 1353.25f ), new( 0f, 119.74f, 0f )), // 24 wall
(new( 1373.50f, 1088.20f, 1315.70f ), new( 0f, 0f, 0f )), // 25 wall
(new( 1776.55f, 901.10f, 1220.69f ), new( 0f, -119.74f, 0f )), // 26 wall
(new( 1804.70f, 320.47f, 1219.80f ), new( 0f, 60.25f, 0f )), // 27 wall
(new( 1829.89f, -99.57f, 1245.19f ), new( 0f, -179.49f, 0f )), // 28 wall
};
/// <summary>What a spot is on, from which way it faces: a wall, a ceiling (facing down) or a floor (facing up).</summary>
static string SpotKind( Angles facing ) => facing.pitch > 45f ? "ceiling" : facing.pitch < -45f ? "floor" : "wall";
// ══ the deal ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// HOST — where each of the code's four symbols stands: for each colour, blue's first, an index into
/// <see cref="CodeSpots"/>, four different ones. Null until dealt: read it through <see cref="SymbolSpots"/>, since a
/// manager a hotload carried over can hold a code dealt before the spots were.
/// </summary>
int[] _codeSpots;
/// <summary>The symbols' spots, dealt on first need. HOST.</summary>
int[] SymbolSpots => _codeSpots is { Length: Colours } ? _codeSpots : (_codeSpots = DealSpots());
/// <summary>Four different spots of the 28, at random, one per colour.</summary>
static int[] DealSpots()
{
var pool = Enumerable.Range( 0, CodeSpots.Length ).ToList();
var spots = new int[Colours];
for ( var k = 0; k < Colours; k++ )
{
var i = Game.Random.Next( pool.Count );
spots[k] = pool[i];
pool.RemoveAt( i );
}
return spots;
}
/// <summary>
/// A new code: four glyphs, and four different spots for them to stand on. HOST — a new game (<see cref="ResetLock"/>),
/// a config loaded (`OnConfigShown`), `nz_hex_lock new`.
///
/// ⚠️ A COUNT, NOT A LIST, in the log, as the picks are: the console is readable by anyone at the machine, and
/// `nz_hex_lock` shows the code on purpose when testing.
/// </summary>
void DealCode()
{
_lockGlyphs = DealLockCode();
_codeSpots = DealSpots();
Log.Info( $"[nz-hex] 🔒 a new code for the shield lock — {Colours} glyphs, on {Colours} of the {CodeSpots.Length} spots" );
}
/// <summary>The code's symbols in words, slot by slot: "blue 3 on spot 12 (wall) · …", those on show marked. HOST.</summary>
string SymbolsText()
{
var spots = CodeSpots;
var at = SymbolSpots;
var code = LockCode;
return string.Join( " · ", Enumerable.Range( 0, Colours ).Select( k =>
$"{ColourName( k )} {code[k]} on spot {at[k] + 1} ({SpotKind( spots[at[k]].Facing )})"
+ ( _symbolsSent.Shows( k ) ? " — SHOWING" : "" ) ) );
}
// ══ which show ══════════════════════════════════════════════════════════════════════════
/// <summary>
/// The symbols on show, in one long: for colour k, blue's first, ten bits from bit 10k — 1 if it shows, its spot (five
/// bits, 0-27) and its glyph less one (three bits). A symbol not on show is all zeros, so nothing of it travels.
/// </summary>
public readonly record struct SymbolSet( long Packed )
{
const int Stride = 10;
/// <summary>Does this colour's symbol show?</summary>
public bool Shows( int colour ) => colour >= 0 && colour < Colours && ((Packed >> (colour * Stride)) & 1L) != 0;
/// <summary>The spot a colour's symbol shows on, an index into the 28 — 0 when it does not show.</summary>
public int SpotOf( int colour ) => Shows( colour ) ? (int)((Packed >> (colour * Stride + 1)) & 31L) : 0;
/// <summary>The glyph a colour's symbol is, 1-8 — 0 when it does not show.</summary>
public int GlyphOf( int colour ) => Shows( colour ) ? (int)((Packed >> (colour * Stride + 6)) & 7L) + 1 : 0;
/// <summary>This set with a colour's symbol showing, on this spot, as this glyph.</summary>
public SymbolSet With( int colour, int spot, int glyph )
{
if ( colour < 0 || colour >= Colours ) return this;
var bits = 1L | ((long)(spot & 31) << 1) | ((long)((glyph - 1) & 7) << 6);
return new SymbolSet( (Packed & ~(1023L << (colour * Stride))) | (bits << (colour * Stride)) );
}
/// <summary>How many show.</summary>
public int Count => Enumerable.Range( 0, Colours ).Count( Shows );
}
static float? _revealRadius, _symbolScale;
/// <summary>
/// How near the cursed flame must come to a symbol to show it, in units: 250 — "a medium radius", a few strides. Every
/// spot has somewhere to stand well inside it: measured off the map's floors on 2026-09-26, a flame at the shoulder of a
/// carrier standing by a spot is within some 120u of it, the ceilings' the furthest. HOST — the host decides what shows.
/// </summary>
public static float RevealRadius { get => Math.Clamp( _revealRadius ?? 250f, 32f, 2000f ); set => _revealRadius = value; }
/// <summary>How big a symbol is drawn: units to one glyph unit, 16 — the widest glyph about 28u across. LOCAL.</summary>
public static float SymbolScale { get => Math.Clamp( _symbolScale ?? 16f, 2f, 200f ); set => _symbolScale = value; }
/// <summary>HOST — the symbols the host last said show: what `NZNet.CodeSymbols` last carried.</summary>
SymbolSet _symbolsSent;
/// <summary>HOST — `nz_hex_symbols all`: every symbol shown, wherever the flame is. Testing only — it gives the code away.</summary>
bool _symbolsAll;
/// <summary>HOST — what shows, for `NZNet.PushState` to replay to a joiner.</summary>
public static SymbolSet SymbolsState => Instance.IsValid() ? Instance._symbolsSent : default;
/// <summary>HOST — when the host last looked.</summary>
TimeSince _symbolWatch;
/// <summary>
/// How often the host looks, in seconds: five times a second, so a symbol shows within a fifth of a second of the flame
/// coming near — nobody walks far in that.
/// </summary>
const float SymbolWatchEvery = 0.2f;
/// <summary>
/// HOST — from `OnUpdate`, five times a second: which symbols the cursed flame shows now, and everyone told when that
/// changes.
/// </summary>
void WatchSymbols()
{
if ( _symbolWatch < SymbolWatchEvery ) return;
_symbolWatch = 0f;
var now = SymbolsNow();
if ( now == _symbolsSent ) return;
_symbolsSent = now;
NZNet.CodeSymbols( now.Packed );
}
/// <summary>What shows now: none off basalt or with the lock open, all four by `nz_hex_symbols all`, and otherwise those near the carried flame. HOST.</summary>
SymbolSet SymbolsNow()
{
if ( !OnBasalt || _lockOpen ) return default;
if ( _symbolsAll ) return AllSymbols();
return CarriedFlameAt() is Vector3 flame ? SymbolsFrom( flame ) : default;
}
/// <summary>
/// Where the cursed flame is as the host sees it: at its carrier's shoulder, where everyone but the carrier sees it held
/// (<see cref="OtherHold"/>) — or null, nobody carrying it. HOST.
/// </summary>
Vector3? CarriedFlameAt()
{
if ( string.IsNullOrEmpty( _flameCarrier ) || _flameLost ) return null;
var body = CarrierBodyOf( _flameCarrier );
if ( !body.IsValid() ) return null;
return body.WorldPosition + Rotation.FromYaw( body.WorldRotation.Yaw() ) * OtherHold;
}
/// <summary>
/// The symbols a flame here shows: each within <see cref="RevealRadius"/> of it, on the side its surface faces. HOST —
/// apart from the carrier, so the selftest can walk it.
/// </summary>
SymbolSet SymbolsFrom( Vector3 flame )
{
var spots = CodeSpots;
var at = SymbolSpots;
var code = LockCode;
var s = default( SymbolSet );
for ( var k = 0; k < Colours; k++ )
{
var (p, facing) = spots[at[k]];
var to = flame - p;
if ( to.Length <= RevealRadius && Vector3.Dot( to, facing.ToRotation().Forward ) > 0f )
s = s.With( k, at[k], code[k] - '0' );
}
return s;
}
/// <summary>All four symbols showing. HOST — `nz_hex_symbols all`.</summary>
SymbolSet AllSymbols()
{
var at = SymbolSpots;
var code = LockCode;
var s = default( SymbolSet );
for ( var k = 0; k < Colours; k++ ) s = s.With( k, at[k], code[k] - '0' );
return s;
}
// ══ the symbols on the walls ════════════════════════════════════════════════════════════
/// <summary>MIRROR — the symbols on show, as this machine was told (`NZNet.CodeSymbols`). They are drawn from it.</summary>
SymbolSet _symbolsShown;
/// <summary>LOCAL — each colour's symbol as drawn, and the set they were drawn from.</summary>
GameObject[] _symbolGos;
SymbolSet _symbolsBuilt;
/// <summary>
/// How the symbols are drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 1: the glyph in its colour's light, 2026-09-26. A manager
/// that has not drawn this layout draws them again — `OnUpdate` asks every frame — as the lock's `LockLayout` does.
/// </summary>
const int SymbolsLayout = 1;
/// <summary>LOCAL — the layout this manager last drew the symbols in; 0 before it has.</summary>
int _symbolsLaid;
/// <summary>LOCAL — `nz_hex_symbols spots`: a white glyph on every one of the 28 spots, to check where they are.</summary>
bool _spotsPreview;
List<GameObject> _spotPreviewGos;
/// <summary>What a symbol's object is named, and a preview spot's: how a sweep knows them.</summary>
const string SymbolName = "Basalt code symbol", SpotPreviewName = "Basalt code spot";
/// <summary>Which symbols show. EVERY machine — `NZNet.CodeSymbols`: each drawn on its spot, or taken away.</summary>
public void ApplySymbols( SymbolSet s )
{
_symbolsShown = s;
BuildSymbols();
}
/// <summary>
/// The symbols as this machine was told: each colour's on show drawn on its spot, facing out of its surface, the rest
/// gone — and, for `nz_hex_symbols spots`, a white glyph on all 28. Each is drawn again only when what it shows
/// changes. LOCAL, and only on basalt.
/// </summary>
void BuildSymbols()
{
_symbolGos ??= new GameObject[Colours];
// a new layout: every symbol and preview drawn again, and any a manager before this one left, swept
if ( _symbolsLaid != SymbolsLayout )
{
_symbolsLaid = SymbolsLayout;
ClearSymbols();
if ( Scene.IsValid() )
foreach ( var old in Scene.GetAllObjects( false )
.Where( x => x.Tags.Has( PanelTag ) && (x.Name.StartsWith( SymbolName ) || x.Name.StartsWith( SpotPreviewName )) )
.ToList() )
old.Destroy();
}
var s = OnBasalt ? _symbolsShown : default;
for ( var k = 0; k < Colours; k++ )
{
var same = s.Shows( k ) == _symbolsBuilt.Shows( k ) && s.SpotOf( k ) == _symbolsBuilt.SpotOf( k )
&& s.GlyphOf( k ) == _symbolsBuilt.GlyphOf( k );
if ( same && _symbolGos[k].IsValid() == s.Shows( k ) ) continue;
if ( _symbolGos[k].IsValid() ) _symbolGos[k].Destroy();
_symbolGos[k] = s.Shows( k )
? SymbolAt( s.SpotOf( k ), s.GlyphOf( k ), k, $"{SymbolName} ({ColourName( k )}, spot {s.SpotOf( k ) + 1})" )
: null;
}
_symbolsBuilt = s;
BuildSpotPreview();
}
/// <summary>A glyph drawn on a spot, facing out of its surface, in a colour's light — or white. Null if it cannot be made. LOCAL.</summary>
GameObject SymbolAt( int spot, int glyph, int colour, string name )
{
var spots = CodeSpots;
if ( !Scene.IsValid() || spot < 0 || spot >= spots.Length ) return null;
var model = IconModel( RingsClue.GlyphShapes( glyph ), RingsClue.GlyphStroke, colour, SymbolScale );
if ( model is null ) return null;
var (at, facing) = spots[spot];
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.Tags.Add( PanelTag );
go.WorldPosition = at;
go.WorldRotation = facing.ToRotation(); // its Forward the surface's normal: the right way round to whoever faces it
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
r.RenderType = ModelRenderer.ShadowRenderType.Off;
return go;
}
/// <summary>`nz_hex_symbols spots`: all 28 spots, each with a white glyph — spot n shows glyph (n - 1) mod 8 + 1. LOCAL.</summary>
void BuildSpotPreview()
{
var want = _spotsPreview && OnBasalt;
if ( want == (_spotPreviewGos is { Count: > 0 }) ) return;
if ( _spotPreviewGos is not null )
foreach ( var go in _spotPreviewGos )
if ( go.IsValid() ) go.Destroy();
_spotPreviewGos = null;
if ( !want ) return;
_spotPreviewGos = new();
for ( var i = 0; i < CodeSpots.Length; i++ )
{
var go = SymbolAt( i, i % RingPositions + 1, White, $"{SpotPreviewName} {i + 1} (preview)" );
if ( go.IsValid() ) _spotPreviewGos.Add( go );
}
}
void ClearSymbols()
{
if ( _symbolGos is not null )
for ( var k = 0; k < _symbolGos.Length; k++ )
{
if ( _symbolGos[k].IsValid() ) _symbolGos[k].Destroy();
_symbolGos[k] = null;
}
_symbolsBuilt = default;
if ( _spotPreviewGos is not null )
foreach ( var go in _spotPreviewGos )
if ( go.IsValid() ) go.Destroy();
_spotPreviewGos = null;
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_hex_symbols [all|auto|spots|nospots]` — the code's symbols. Bare, where each stands and whether it shows — the
/// host's alone, since that is the code. `all` shows all four to everyone, wherever the flame is, and `auto` goes back to
/// the flame's rule — both HOST, testing only, it gives the code away. `spots` draws a white glyph on every one of the
/// 28 spots, on this machine, to check where each is and which way it faces; `nospots` takes them away.
/// </summary>
[ConCmd( "nz_hex_symbols" )]
public static void SymbolsCmd( string what = "" )
{
var m = Ensure();
if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no game running — start one first" ); return; }
var host = !NZGame.IsClient;
switch ( what.Trim().ToLowerInvariant() )
{
case "":
break;
case "all":
case "auto":
if ( !host ) { Log.Warning( "[nz-hex] host only — the host decides what shows" ); return; }
m._symbolsAll = what.Trim().ToLowerInvariant() == "all";
m._symbolWatch = SymbolWatchEvery;
m.WatchSymbols();
break;
case "spots":
case "nospots":
m._spotsPreview = what.Trim().ToLowerInvariant() == "spots";
m.BuildSymbols();
Log.Info( m._spotsPreview
? $"[nz-hex] a white glyph on every one of the {CodeSpots.Length} spots, on this machine: spot n shows glyph (n - 1) mod 8 + 1 · nz_hex_symbols nospots takes them away"
: "[nz-hex] the spots' white glyphs are gone" );
return;
default:
Log.Warning( "[nz-hex] nz_hex_symbols all, auto, spots or nospots — or nothing, to see where they stand" );
return;
}
if ( !host )
{
Log.Info( $"[nz-hex] {m._symbolsShown.Count} of {Colours} of the code's symbols show here now (only the host knows where the rest stand)" );
return;
}
Log.Info( $"[nz-hex] the code's symbols: {m.SymbolsText()}" );
Log.Info( $"[nz-hex] they show within {RevealRadius:0}u of the cursed flame"
+ ( m._symbolsAll ? " — ALL FORCED ON (nz_hex_symbols auto undoes)" : "" )
+ ( m._lockOpen ? " — none now: the lock is open" : "" )
+ ( m.CarriedFlameAt() is null ? " · nobody carries it now" : "" ) );
}
/// <summary>
/// `nz_hex_reveal [radius] [size]` — how near the cursed flame must come to show a symbol, in units, and how big the
/// symbols are drawn (units to a glyph unit) — redrawn at once; bare, it prints them. Until a restart, like
/// `nz_hex_flame`: the radius counts on the host, which decides what shows, and the size on each machine.
/// </summary>
[ConCmd( "nz_hex_reveal" )]
public static void RevealCmd( float radius = 0f, float size = 0f )
{
if ( radius > 0f ) RevealRadius = radius;
if ( size > 0f ) SymbolScale = size;
var m = Instance;
if ( m.IsValid() && size > 0f ) { m._symbolsLaid = 0; m.BuildSymbols(); }
Log.Info( $"[nz-hex] a symbol shows within {RevealRadius:0}u of the cursed flame (it counts on the host), drawn"
+ $" {SymbolScale:0.#}u to a glyph unit — the widest glyph about {SymbolScale * 1.75f:0}u across" );
}
}