Component of the HexPlatforms manager implementing the teleporter rooms six hexagonal buttons. It defines button positions, visuals, host-side link generation and rules, pressing logic, UI aiming/interaction helpers, runtime mesh building, console commands and mirroring state to clients.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT — THE TELEPORTER'S BUTTONS (2026-09-27): its destination set, the last puzzle before the teleporter is used. The
/// energy through to it, six small hex stones wake on the floor round it, where the user laid six ASP wall buys
/// (Docs/BASALT_MARKED_SPOTS.md). Each is lit round its sides red, green or light blue, and all start red. E on one turns
/// it to its next colour — red, green, light blue, then red again — and with it the others it is linked to: each its own
/// one to three, one way, so the north one may turn the south one without the south one turning it back. The links are
/// dealt every game. All six the teleporter's light blue, and its destination is set: they turn no more, the step-done
/// clicking plays, and the blue altar rises out of the floor in the teleporter's middle (`HexPlatforms.BlueAltar.cs`).
///
/// Asked for as *"6 buttons on the teleporter room, and i need to press them a bunch of times — each button once again
/// gets a color, i need all of them to have the blue color like the teleporter, the light blue — but pressing a button
/// changes the other buttons too, not all of them, each button changes a set ammount of buttons — each button has 3
/// positions, red green and blue — it cycles between them, they all start red and i need to turn them all blue"*; then
/// *"pressing E, but not hud message"*, *"random"*, and *"a lit hex panel, but a lot smaller, like the size of a pistol,
/// the front is grey like hte stone texture on the hexagons, only the sides are colored"*.
///
/// ⛔ THE ORDER OF THE PRESSES NEVER MATTERS — ONLY HOW MANY EACH BUTTON GETS: 0, 1 or 2, since three turn it back to where
/// it was. An answer is a count for each button, and there are 729 ways to press them (3^6).
///
/// ⛔ ONLY A FAIR DEAL IS KEPT (<see cref="DealButtons"/>): exactly one answer from all red, of 7 to 10 presses in all, some
/// button pressed twice and some never. Measured on paper before it was built (2026-09-27, thousands of random deals):
/// - every press turning exactly three — itself and two others — can NEVER have exactly one answer: every deal had
/// several, or none;
/// - one pattern for every button, each turning its two neighbours round the ring, is worse: pressing all six once
/// changes nothing, and it has nine answers, the shortest four presses;
/// - itself and one to three others, one way, has exactly one answer about half the time, and is fair about one deal in
/// seven — a handful of draws.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - a PRESS is noticed on the presser's machine — the use key and its own camera (`NZPlayer.TickUse`) — which asks the
/// host (`NZNet.HexButtonPress`); the host decides: the buttons awake, the presser up and within reach;
/// - the LINKS are the HOST's alone, never sent — and with them the answer;
/// - the COLOURS and whether they are awake or the destination set are HOST state, MIRRORED (`NZNet.HexButtonState`) on
/// every change and to a joiner, with the button just pressed, for its push;
/// - the STONES, their lit sides and the push are LOCAL.
///
/// ⚠️ NO PROMPT, BY THE USER'S WORD. `UsePrompt.Text` asks the key's own question and shows nothing — nor anything below
/// it, since E presses the button.
/// </summary>
public sealed partial class HexPlatforms
{
// ══ where they are ══════════════════════════════════════════════════════════════════════
/// <summary>How many buttons, and how many colours each turns through.</summary>
const int Buttons = 6, ButtonColours = 3;
/// <summary>
/// The six spots, where the user's ASP wall buys lay on the teleporter room's floor on 2026-09-27, round the teleporter
/// about 151u out, off the corners of its outer step: each floor point, traced in the game — the floor at 1696, facing
/// up. Clockwise from the north.
/// </summary>
static Vector3 ButtonSpot( int b ) => b switch
{
0 => new( -4993.06f, -138.37f, 1696f ),
1 => new( -4861.24f, -210.45f, 1696f ),
2 => new( -4861.01f, -364.67f, 1696f ),
3 => new( -4991.78f, -440.07f, 1696f ),
4 => new( -5124.02f, -365.94f, 1696f ),
_ => new( -5122.51f, -213.56f, 1696f ),
};
static string ButtonName( int b ) => b switch
{
0 => "north", 1 => "north-east", 2 => "south-east", 3 => "south", 4 => "south-west", _ => "north-west",
};
/// <summary>A button by its number clockwise from the north (1-6) or its name — "north", "ne", "south-west"…; -1 if none.</summary>
static int ButtonOf( string which )
{
if ( int.TryParse( which, out var n ) ) return n >= 1 && n <= Buttons ? n - 1 : -1;
return (which ?? "").Trim().ToLowerInvariant().Replace( "-", "" ).Replace( " ", "" ).Replace( "_", "" ) switch
{
"north" or "n" => 0,
"northeast" or "ne" => 1,
"southeast" or "se" => 2,
"south" or "s" => 3,
"southwest" or "sw" => 4,
"northwest" or "nw" => 5,
_ => -1,
};
}
/// <summary>A button's colours, 0-2 — red, green, light blue — as wheel colours. Light blue is the teleporter's: the goal.</summary>
static int ButtonWheel( int colour ) => colour switch { 0 => WheelRed, 1 => WheelGreen, _ => WheelLightBlue };
const int ButtonBlue = 2;
static float? _buttonAcross, _buttonHeight;
/// <summary>
/// How wide a button is, corner to corner, in units: 10 — a pistol's length, as the user asked. `nz_hex_button_size` sets
/// it on this machine and until a restart; set the default here once settled.
/// </summary>
public static float ButtonAcross { get => Math.Clamp( _buttonAcross ?? 10f, 2f, 60f ); set => _buttonAcross = value; }
/// <summary>How far a button stands up off the floor, in units: 4 — its lit sides' height. `nz_hex_button_size` sets it too.</summary>
public static float ButtonHeight { get => Math.Clamp( _buttonHeight ?? 4f, 0.5f, 30f ); set => _buttonHeight = value; }
/// <summary>The middle of a button's top: where it is looked at, reached and heard.</summary>
static Vector3 ButtonTop( int b ) => ButtonSpot( b ) + Vector3.Up * ButtonHeight;
// ══ the state ════════════════════════════════════════════════════════════════════════════
/// <summary>The flags: awake; the destination set.</summary>
const int BtnActive = 1, BtnDone = 2;
/// <summary>
/// HOST — the links, six bits a button: bit j of button b's set when pressing b turns j, its own always. Never sent.
/// </summary>
long _btnLinks;
/// <summary>HOST — every button's colour, two bits each, 0 red; and the flags.</summary>
int _btnColours, _btnFlags;
/// <summary>MIRROR — the colours and the flags as this machine was told (`NZNet.HexButtonState`). Never the links.</summary>
int _btnColoursShown, _btnFlagsShown;
/// <summary>HOST — when each button was last pressed. ⚠️ MADE ON FIRST USE, NOT BY AN INITIALISER, for the hotload.</summary>
TimeSince[] _btnPressed;
/// <summary>
/// How soon after a press the same button takes another, in seconds: 0.05 — only an E seen twice for one press. ⚠️ IT WAS
/// 0.25, AND IT ATE THE USER'S QUICK DOUBLE TAPS: six second taps refused in their first run (2026-09-27, 02:59-03:01),
/// each "pressed a moment ago" — a button two colours on is two taps, and they come quick.
/// </summary>
const float BtnPressCooldown = 0.05f;
/// <summary>HOST — the buttons as they stand, for `NZNet.PushState` to replay to a joiner. Never the links.</summary>
public static (int Colours, int Flags) ButtonsNow
=> Instance.IsValid() ? (Instance._btnColours, Instance._btnFlags) : (0, 0);
/// <summary>Is the teleporter's destination set, as this machine was told?</summary>
public bool ButtonsDoneShown => (_btnFlagsShown & (BtnActive | BtnDone)) == (BtnActive | BtnDone);
static int BtnColour( int colours, int b ) => (colours >> (2 * b)) & 3;
static int BtnWithColour( int colours, int b, int c ) => (colours & ~(3 << (2 * b))) | ((c & 3) << (2 * b));
static int BtnLinksOf( long links, int b ) => (int)((links >> (6 * b)) & 63);
/// <summary>Six buttons' links from six masks, bit j of the b-th set when b turns j.</summary>
static long BtnPack( params int[] masks )
{
long links = 0;
for ( var b = 0; b < Buttons && b < masks.Length; b++ ) links |= (long)(masks[b] & 63) << (6 * b);
return links;
}
/// <summary>Every button light blue.</summary>
static int BtnAllBlue()
{
var packed = 0;
for ( var b = 0; b < Buttons; b++ ) packed = BtnWithColour( packed, b, ButtonBlue );
return packed;
}
/// <summary>The colours after one press of button b: it and every button it is linked to turned on a colour.</summary>
static int BtnAfterPress( long links, int colours, int b )
{
var turns = BtnLinksOf( links, b );
for ( var j = 0; j < Buttons; j++ )
if ( (turns >> j & 1) != 0 ) colours = BtnWithColour( colours, j, (BtnColour( colours, j ) + 1) % ButtonColours );
return colours;
}
/// <summary>How many times an answer presses button b, 0-2 — two bits a button.</summary>
static int BtnTimes( int presses, int b ) => (presses >> (2 * b)) & 3;
static int BtnPressCount( int presses ) => Enumerable.Range( 0, Buttons ).Sum( b => BtnTimes( presses, b ) );
/// <summary>
/// The ways to press them — how many times each, 0-2 — that turn these colours all light blue: how many there are,
/// counted as far as two, which is all a deal asks, and the first.
/// </summary>
static (int Count, int Presses) BtnAnswers( long links, int from )
{
var goal = BtnAllBlue();
int count = 0, first = -1;
for ( var code = 0; code < 729; code++ )
{
int colours = from, presses = 0, rest = code;
for ( var b = 0; b < Buttons; b++ )
{
var times = rest % 3;
rest /= 3;
presses |= times << (2 * b);
for ( var t = 0; t < times; t++ ) colours = BtnAfterPress( links, colours, b );
}
if ( colours != goal ) continue;
if ( count == 0 ) first = presses;
if ( ++count > 1 ) break;
}
return (count, first);
}
/// <summary>Is this deal fair: exactly one answer from all red, of 7 to 10 presses in all, some button twice and some never?</summary>
static bool BtnFair( long links )
{
var (count, presses) = BtnAnswers( links, 0 );
if ( count != 1 ) return false;
var total = BtnPressCount( presses );
var times = Enumerable.Range( 0, Buttons ).Select( b => BtnTimes( presses, b ) ).ToList();
return total >= 7 && total <= 10 && times.Contains( 2 ) && times.Contains( 0 );
}
/// <summary>
/// A fair deal made on paper, should a thousand draws find none — about one draw in seven is fair (measured 2026-09-27),
/// so it is never needed. Its answer: north never, north-east twice, south-east twice, south once, south-west once,
/// north-west twice — 8 presses.
/// </summary>
static long BtnFallback => BtnPack( 0b100101, 0b110010, 0b010110, 0b101011, 0b011011, 0b100010 );
/// <summary>A fair deal: each button turns itself and one to three others, drawn apart, one way (<see cref="BtnFair"/>).</summary>
static long DealButtons()
{
var others = new int[Buttons - 1];
for ( var draw = 0; draw < 1000; draw++ )
{
long links = 0;
for ( var b = 0; b < Buttons; b++ )
{
for ( int j = 0, k = 0; j < Buttons; j++ ) if ( j != b ) others[k++] = j;
for ( var i = others.Length - 1; i > 0; i-- )
{
var k = Game.Random.Next( i + 1 );
(others[i], others[k]) = (others[k], others[i]);
}
var mask = 1 << b;
var reach = 1 + Game.Random.Next( 3 );
for ( var i = 0; i < reach; i++ ) mask |= 1 << others[i];
links |= (long)mask << (6 * b);
}
if ( BtnFair( links ) ) return links;
}
Log.Warning( "[nz-hex] no fair deal for the teleporter's buttons in a thousand draws — the one made on paper" );
return BtnFallback;
}
void SendButtons( int pressed = -1 ) => NZNet.HexButtonState( _btnColours, _btnFlags, pressed );
/// <summary>
/// The buttons as they stand. EVERY machine — `NZNet.HexButtonState`: their lit sides dressed to match, and the one just
/// pressed, if any, pushed down and let up.
/// </summary>
public void ApplyButtons( int colours, int flags, int pressed )
{
var wasSet = ButtonsDoneShown;
_btnColoursShown = colours;
_btnFlagsShown = flags;
if ( pressed >= 0 && pressed < Buttons )
{
EndButtonPush();
_btnPush = pressed;
_btnPushSince = 0;
}
DressButtons();
// the blue altar, as the destination goes set or unset here: rising if a press has just set it; standing if it was set
// before this machine heard — joining, `nz_hex_buttons done`; gone once it is not (`HexPlatforms.BlueAltar.cs`)
if ( ButtonsDoneShown != wasSet ) BuildBlueAltar( rise: ButtonsDoneShown && pressed >= 0 );
}
/// <summary>The buttons wake: all red, their links dealt. HOST — the energy through to the teleporter.</summary>
void StartButtons()
{
if ( (_btnFlags & BtnActive) != 0 ) return;
_btnLinks = DealButtons();
_btnColours = 0;
_btnFlags = BtnActive;
SendButtons();
Log.Info( "[nz-hex] ⬡ THE TELEPORTER'S BUTTONS WAKE — six stones round it, all red. E on one turns it and those it is linked"
+ " to on a colour; all six light blue sets its destination" );
}
/// <summary>Back to not begun. HOST — with the junctions, which they follow; and the blue altar's send with them.</summary>
void ResetButtons()
{
ResetArenaSend();
if ( _btnFlags == 0 && _btnColours == 0 && _btnLinks == 0 ) return;
_btnLinks = 0;
_btnColours = _btnFlags = 0;
SendButtons();
}
// ══ pressing one ════════════════════════════════════════════════════════════════════════
/// <summary>How near a player's feet must be to a button's top to press it, in units: a step or two off it.</summary>
const float ButtonReach = 100f;
/// <summary>How far off a button a look may pass and still be at it, in units, beyond its own half width.</summary>
const float ButtonAimSlack = 3f;
/// <summary>
/// Which button this player is looking at, near enough to press, while the buttons take presses; -1 for none. LOCAL — the
/// prompt's test and the use key's, one question for both (`UsePrompt.Text`, `NZPlayer.TickUse`), from this machine's
/// camera.
/// </summary>
public static int HexButtonAimed( NZPlayer player )
{
var m = Instance;
if ( !m.IsValid() || !OnBasalt || (m._btnFlagsShown & (BtnActive | BtnDone)) != BtnActive || CannotCarry( player ) ) return -1;
var cam = m.Scene.Camera;
return cam.IsValid() ? ButtonAimedFrom( cam.WorldPosition, cam.WorldRotation.Forward, player.WorldPosition ) : -1;
}
/// <summary>
/// Which button a look from this eye, this way (a unit direction), is at, the looker's feet here; -1 for none. The
/// stones have no collider for a trace to stop on, so the look's ray must pass through a sphere round a button's top, as
/// for the cursed flame; the nearest along it wins.
/// </summary>
static int ButtonAimedFrom( Vector3 eye, Vector3 dir, Vector3 feet )
{
var best = -1;
var bestAlong = float.MaxValue;
for ( var b = 0; b < Buttons; b++ )
{
var top = ButtonTop( b );
if ( top.Distance( feet ) > ButtonReach ) continue;
if ( !RayMeetsSphere( eye, dir, top, ButtonAcross * 0.5f + ButtonAimSlack ) ) continue;
var along = Vector3.Dot( top - eye, dir );
if ( along >= bestAlong ) continue;
best = b;
bestAlong = along;
}
return best;
}
/// <summary>E on a button. LOCAL — the presser's machine asks the host, which decides (<see cref="HostPressButton"/>).</summary>
public static void PressHexButton( NZPlayer player, int button )
{
if ( player.IsValid() && button >= 0 && button < Buttons ) NZNet.HexButtonPress( button );
}
/// <summary>
/// Someone pressed E on a button. HOST — `NZNet.HexButtonPress`, with `who` the caller's connection id as the call itself
/// carries it, or "" for the host's own press.
/// </summary>
public static void HostPressButton( string who, int button )
{
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;
var refused = m.PressButton( button, body );
if ( refused != "" )
Log.Info( $"[nz-hex] the {( button >= 0 && button < Buttons ? ButtonName( button ) : $"#{button}" )} button is not"
+ $" pressed: {refused}" );
}
/// <summary>
/// The rules for a press. HOST — apart from the RPC, so the selftest can walk it. The buttons must be awake and the
/// destination not set; the presser up and within reach (`anywhere` skips both, for the test and the command); and the
/// button not pressed a moment ago (`anytime` skips that). It and every button it is linked to turn on a colour, heard
/// at it; all six light blue, the destination is set. Returns why not, or "".
/// </summary>
string PressButton( int button, NZPlayer by = null, bool anywhere = false, bool anytime = false )
{
if ( (_btnFlags & BtnActive) == 0 ) return "the buttons are not awake";
if ( (_btnFlags & BtnDone) != 0 ) return "the destination is set already";
if ( button < 0 || button >= Buttons ) return "no such button";
if ( !anywhere )
{
if ( CannotCarry( by ) ) return "the presser is down or gone";
if ( ButtonTop( button ).Distance( by.WorldPosition ) > ButtonReach + ReachSlack ) return "out of reach";
}
_btnPressed ??= new TimeSince[Buttons];
if ( !anytime && _btnPressed[button] < BtnPressCooldown ) return "pressed a moment ago";
_btnPressed[button] = 0;
_btnColours = BtnAfterPress( _btnLinks, _btnColours, button );
var set = _btnColours == BtnAllBlue();
if ( set ) _btnFlags |= BtnDone;
SendButtons( button );
Cue( ButtonCue, ButtonTop( button ) );
Log.Info( $"[nz-hex] ⬡ {( by.IsValid() ? NameFor( by ) : "someone" )} pressed the {ButtonName( button )} button — "
+ BtnColoursText( _btnColours ) );
if ( !set ) return "";
if ( !Testing ) DoneCueLater();
Cue( BlueAltarCue, BlueAltarFoot );
Fanfare( 13 );
Log.Info( $"[nz-hex] ✦ THE TELEPORTER'S DESTINATION IS SET — {( by.IsValid() ? NameFor( by ) : "someone" )} turned the last"
+ " of the six light blue, and the blue altar rises in its middle" );
return "";
}
// ══ the sound ════════════════════════════════════════════════════════════════════════════
/// <summary>A button pressed: a stone's thunk and the lights' blip, heard at it, 3D (`Tools/basalt_button_sounds.py`).</summary>
const string ButtonCue = "nz.hex.button";
// ══ the stones ══════════════════════════════════════════════════════════════════════════
/// <summary>
/// LOCAL — each button's stone, and its lit sides' renderer, kept from when it was made: a lookup does not see the
/// component of an object that is switched off (INSTRUCTIONS.md, `GetAllComponents`).
/// </summary>
GameObject[] _btnGo;
ModelRenderer[] _btnSides;
/// <summary>LOCAL — the button last pressed, and since when: its push.</summary>
int _btnPush = -1;
TimeSince _btnPushSince;
/// <summary>How long a press pushes a button down and lets it up, in seconds, and how far down, in units.</summary>
const float BtnPushSeconds = 0.2f, BtnPushDepth = 1.5f;
/// <summary>
/// How the buttons are drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 1: six small hex stones, their tops the stones' concrete and
/// their sides lit, 2026-09-27. A manager that has not built this layout builds them again — `OnUpdate` asks every frame —
/// as the junctions' does.
/// </summary>
const int ButtonsLayout = 1;
/// <summary>LOCAL — the layout this manager last built the buttons in; 0 before it has.</summary>
int _btnLaid;
/// <summary>
/// The six stones, on basalt, all game: their sides dark until the buttons wake, then each its colour. Built once, and
/// again at a new layout or size. LOCAL.
/// </summary>
void BuildButtons()
{
_btnLaid = ButtonsLayout;
ClearButtons();
if ( !OnBasalt || !Scene.IsValid() ) return;
var stone = Material.Load( StoneMaterial );
var light = Material.Load( LightMaterial );
if ( stone is null || light is null )
{
Log.Warning( $"[nz-hex] {StoneMaterial} or {LightMaterial} not found — the teleporter's buttons are not built" );
return;
}
var radius = ButtonAcross * 0.5f;
var top = ButtonTopModel( radius, ButtonHeight, stone );
var sides = ButtonSidesModel( radius, ButtonHeight, light );
_btnGo = new GameObject[Buttons];
_btnSides = new ModelRenderer[Buttons];
for ( var b = 0; b < Buttons; b++ )
{
var root = Scene.CreateObject();
root.Name = $"Basalt teleporter button ({ButtonName( b )})";
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 = ButtonSpot( b );
root.Components.Create<ModelRenderer>().Model = top;
var go = Scene.CreateObject();
go.Name = "sides";
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 );
go.LocalPosition = Vector3.Zero;
go.LocalRotation = Rotation.Identity;
var r = go.Components.Create<ModelRenderer>();
r.Model = sides;
r.RenderType = ModelRenderer.ShadowRenderType.Off;
_btnGo[b] = root;
_btnSides[b] = r;
}
_btnPush = -1;
DressButtons();
}
void ClearButtons()
{
if ( _btnGo is not null )
foreach ( var go in _btnGo )
if ( go.IsValid() ) go.Destroy();
_btnGo = null;
_btnSides = null;
}
/// <summary>The buttons' sides as this machine was told: dark before they wake, then each its colour. LOCAL.</summary>
void DressButtons()
{
if ( _btnSides is null ) return;
var awake = (_btnFlagsShown & BtnActive) != 0;
var dim = JunctionDimMaterial();
for ( var b = 0; b < Buttons; b++ )
{
if ( _btnSides[b] is not ModelRenderer r || !r.IsValid() ) continue;
r.MaterialOverride = awake ? WheelMaterial( ButtonWheel( BtnColour( _btnColoursShown, b ) ) ) : dim;
}
}
/// <summary>A pressed button pushed down and let up again. LOCAL — `OnUpdate`.</summary>
void TickButtons()
{
if ( _btnPush < 0 ) return;
if ( _btnPushSince >= BtnPushSeconds ) { EndButtonPush(); return; }
var go = _btnGo is not null && _btnPush < _btnGo.Length ? _btnGo[_btnPush] : null;
if ( go.IsValid() )
go.WorldPosition = ButtonSpot( _btnPush ) + Vector3.Down * (BtnPushDepth * MathF.Sin( MathF.PI * (_btnPushSince / BtnPushSeconds) ));
}
/// <summary>A push over: the button back up where it stands. LOCAL.</summary>
void EndButtonPush()
{
if ( _btnPush >= 0 && _btnGo is not null && _btnPush < _btnGo.Length && _btnGo[_btnPush].IsValid() )
_btnGo[_btnPush].WorldPosition = ButtonSpot( _btnPush );
_btnPush = -1;
}
/// <summary>A button's hexagon this far out to its corners — its points north and south, as the stones have theirs. Counter-clockwise from the north.</summary>
static Vector2[] ButtonCorners( float radius )
{
var flat = radius * MathF.Sqrt( 3f ) * 0.5f;
return new Vector2[]
{
new( 0f, radius ), new( -flat, radius * 0.5f ), new( -flat, -radius * 0.5f ),
new( 0f, -radius ), new( flat, -radius * 0.5f ), new( flat, radius * 0.5f ),
};
}
/// <summary>
/// A button's top: its hexagon at its height, facing up, in the stones' concrete laid as the map lays it — one repeat per
/// <see cref="StoneUnits"/>. Each wedge is wound counter-clockwise seen from above, so its normal points up.
/// </summary>
static Model ButtonTopModel( float radius, float height, Material stone )
{
var corners = ButtonCorners( radius );
var verts = new List<Vertex> { new( new Vector3( 0f, 0f, height ), Vector3.Up, Vector3.Forward, new Vector4( 0f, 0f, 0f, 0f ) ) };
foreach ( var c in corners )
verts.Add( new Vertex( new Vector3( c.x, c.y, height ), Vector3.Up, Vector3.Forward,
new Vector4( c.x / StoneUnits, -c.y / StoneUnits, 0f, 0f ) ) );
var idx = new List<int>();
for ( var i = 0; i < corners.Length; i++ )
{
idx.Add( 0 );
idx.Add( 1 + i );
idx.Add( 1 + (i + 1) % corners.Length );
}
return ButtonMesh( stone, verts, idx, new BBox( new Vector3( -radius, -radius, height - 0.5f ), new Vector3( radius, radius, height + 0.5f ) ) );
}
/// <summary>
/// A button's six sides, from the floor to its top, facing out, in white001 — the colour is its renderer's override, and
/// each side runs 0-1 across, a light's texture being flat. Wound as the tiles' panels are (`HexSidePanels.Build`).
/// </summary>
static Model ButtonSidesModel( float radius, float height, Material light )
{
var corners = ButtonCorners( radius );
var verts = new List<Vertex>();
var idx = new List<int>();
for ( var i = 0; i < corners.Length; i++ )
{
var a = corners[i];
var b = corners[(i + 1) % corners.Length];
var d = b - a;
var along = d * (1f / d.Length);
var n = new Vector3( along.y, -along.x, 0f ); // outward: the edge turned right, the panels' rule
var t = new Vector3( along.x, along.y, 0f );
var s = verts.Count;
verts.Add( new Vertex( new Vector3( a.x, a.y, 0f ), n, t, new Vector4( 0f, 1f, 0f, 0f ) ) );
verts.Add( new Vertex( new Vector3( b.x, b.y, 0f ), n, t, new Vector4( 1f, 1f, 0f, 0f ) ) );
verts.Add( new Vertex( new Vector3( b.x, b.y, height ), n, t, new Vector4( 1f, 0f, 0f, 0f ) ) );
verts.Add( new Vertex( new Vector3( a.x, a.y, height ), n, t, new Vector4( 0f, 0f, 0f, 0f ) ) );
idx.Add( s ); idx.Add( s + 1 ); idx.Add( s + 2 );
idx.Add( s ); idx.Add( s + 2 ); idx.Add( s + 3 );
}
return ButtonMesh( light, verts, idx, new BBox( new Vector3( -radius, -radius, -0.5f ), new Vector3( radius, radius, height + 0.5f ) ) );
}
static Model ButtonMesh( Material material, List<Vertex> verts, List<int> idx, BBox bounds )
{
var mesh = new Mesh( material );
mesh.CreateVertexBuffer( verts.Count, verts );
mesh.CreateIndexBuffer( idx.Count, idx );
// ⛔ SET BY HAND, AFTER THE BUFFERS: a runtime mesh gets empty bounds otherwise (`HexSidePanels.Build`)
mesh.Bounds = bounds;
return Model.Builder.AddMesh( mesh ).WithViewBounds( bounds ).Create();
}
// ══ in words ════════════════════════════════════════════════════════════════════════════
static string BtnColoursText( int colours )
=> string.Join( " · ", Enumerable.Range( 0, Buttons ).Select( b => $"{ButtonName( b )} {WheelName( ButtonWheel( BtnColour( colours, b ) ) )}" ) );
static string BtnPressesText( int presses )
=> string.Join( " · ", Enumerable.Range( 0, Buttons ).Select( b => $"{ButtonName( b )} ×{BtnTimes( presses, b )}" ) );
/// <summary>Which buttons each turns besides itself. HOST.</summary>
string BtnLinksText()
=> string.Join( " · ", Enumerable.Range( 0, Buttons ).Select( b =>
$"{ButtonName( b )} → " + string.Join( ", ", Enumerable.Range( 0, Buttons )
.Where( j => j != b && (BtnLinksOf( _btnLinks, b ) >> j & 1) != 0 ).Select( ButtonName ) ) ) );
/// <summary>Where they stand, in words. HOST.</summary>
string ButtonsStateText()
{
if ( (_btnFlags & BtnActive) == 0 ) return "the teleporter's buttons: waiting for the energy to reach the teleporter";
if ( (_btnFlags & BtnDone) != 0 ) return "the teleporter's buttons: DONE — all six light blue, its destination set";
return $"the teleporter's buttons: {BtnColoursText( _btnColours )}";
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_hex_button_size [across] [height]` — how wide the teleporter's buttons are corner to corner, and how tall, in
/// units; bare, it says. On this machine and until a restart: set the defaults in `ButtonAcross` and `ButtonHeight` once
/// they are settled.
/// </summary>
[ConCmd( "nz_hex_button_size" )]
public static void ButtonSizeCmd( float across = float.NaN, float height = float.NaN )
{
if ( !float.IsNaN( across ) ) ButtonAcross = across;
if ( !float.IsNaN( height ) ) ButtonHeight = height;
if ( (!float.IsNaN( across ) || !float.IsNaN( height )) && Instance.IsValid() ) Instance.BuildButtons();
Log.Info( $"[nz-hex] the teleporter's buttons are {ButtonAcross:0.#}u across, corner to corner, and {ButtonHeight:0.#}u tall" );
}
/// <summary>
/// `nz_hex_buttons [start|press b|links|answer|solve|done|reset]` — HOST: where the teleporter's buttons stand. `start`
/// wakes them, as the energy reaching the teleporter does; `press b` presses button b — by its name or its number
/// clockwise from the north (1-6) — from anywhere; `links` prints which each turns and `answer` how many presses each
/// still needs — testing only, both give it away; `solve` presses them so, which sets the destination; `done` sets it at
/// once; `reset` makes them not begun.
/// </summary>
[ConCmd( "nz_hex_buttons" )]
public static void ButtonsCmd( string what = "", string which = "" )
{
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; }
var refused = "";
switch ( what.Trim().ToLowerInvariant() )
{
case "":
break;
case "start":
if ( (m._jnFlags & JnDone) == 0 )
Log.Warning( "[nz-hex] the energy is not through to the teleporter, which wakes them — woken by hand" );
m.StartButtons();
break;
case "press":
{
var b = ButtonOf( which );
if ( b < 0 )
{
Log.Warning( "[nz-hex] nz_hex_buttons press north, north-east, south-east, south, south-west or north-west — or 1-6" );
return;
}
refused = m.PressButton( b, NZPlayer.Local, anywhere: true, anytime: true );
break;
}
case "links":
Log.Info( (m._btnFlags & BtnActive) != 0
? $"[nz-hex] the buttons' links, each turning itself and: {m.BtnLinksText()} — testing only, it gives it away"
: "[nz-hex] the buttons are not awake, so they have no links yet" );
return;
case "answer":
{
if ( (m._btnFlags & BtnActive) == 0 ) { Log.Info( "[nz-hex] the buttons are not awake, so they have no answer yet" ); return; }
var (count, presses) = BtnAnswers( m._btnLinks, m._btnColours );
Log.Info( count == 1
? $"[nz-hex] the presses still needed: {BtnPressesText( presses )} — {BtnPressCount( presses )} in all, any order."
+ " Testing only, it gives it away"
: $"[nz-hex] {( count == 0 ? "no" : "more than one" )} answer from here — not a fair deal" );
return;
}
case "solve":
{
if ( (m._btnFlags & BtnActive) == 0 ) { Log.Warning( "[nz-hex] the buttons are not awake — nz_hex_buttons start first" ); return; }
var (count, presses) = BtnAnswers( m._btnLinks, m._btnColours );
if ( count == 0 ) { Log.Warning( "[nz-hex] no answer from here — not a fair deal" ); return; }
for ( var b = 0; b < Buttons && refused == ""; b++ )
for ( var t = 0; t < BtnTimes( presses, b ) && refused == ""; t++ )
refused = m.PressButton( b, NZPlayer.Local, anywhere: true, anytime: true );
break;
}
case "done":
m.StartButtons();
m._btnColours = BtnAllBlue();
m._btnFlags = BtnActive | BtnDone;
m.SendButtons();
break;
case "reset":
m.ResetButtons();
break;
default:
Log.Warning( "[nz-hex] nz_hex_buttons start, press <name|1-6>, links, answer, solve, done or reset — or nothing, to see"
+ " where they stand" );
return;
}
if ( refused != "" ) Log.Warning( $"[nz-hex] no: {refused}" );
Log.Info( $"[nz-hex] {m.ButtonsStateText()}" );
}
}