A game component (partial class HexPlatforms) that implements a six-junction puzzle for the Basalt map. It defines junction positions, models, visuals, networked host state (route, settings, answers, flags), handles shots and Elemental Pop tests, steps the energy along the route, plays cues, and builds/draws local model parts and blinking mastermind rings.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT — THE JUNCTIONS (2026-09-27). The lava survived, the facility's energy, woken by the Mastermind, has to be routed
/// to the teleporter through six junction boxes around the map. Each wears a coloured circle and has three settings, and a
/// Prisma round turns it to its next. The Mastermind's three rings blink the route — six colours one after another, dealt
/// every game: `O_O_O_O_O_O__________`, again and again. A reload with Elemental Pop at the first colour's junction sends
/// the energy along it: every junction set right plays a tone, each higher than the last, and the first set wrong plays the
/// power-down, the energy dying there whatever the ones after it are set to. Nothing is lost for it. All six right, and the
/// energy reaches the teleporter: its sides turn light blue, the step-done clicking plays, and its six buttons wake — the
/// next step, its destination (`HexPlatforms.Buttons.cs`).
///
/// Asked for as *"6 junctions around the map, and we do need the wonder weapon to switch them and its basically just trial
/// and error — its linear … if 1 is correct and 3 is correct, but 2 is wrong, it still dies at 2 — its all about sound
/// cues, each one makes a sound that's progressively higher tuned, so we know exactly where it failed"*, *"to try and turn
/// it on, we need to reload with elemental pop near the first one, the electric shock does it"*, and *"instead of numbers
/// each one has a colored circle in it — after the lava step, the 3 rings of the mastermind step start blinking in the
/// order of the 6 colors, random every game"*.
///
/// ⛔ THE JUNCTIONS ARE OUR OWN MODELS ON THE MAP'S WALLS — Nightfire's electrical boxes, as the user chose — where they
/// marked six spots with ASP wall buys (Docs/BASALT_MARKED_SPOTS.md). Each keeps its colour every game; the ORDER is what is
/// dealt, and with it which junction the reload must be at.
///
/// ⚠️ A ROUND IS FOUND WHERE IT LANDS, as the Mastermind's are: the model's only physics is a small hull, so most rounds
/// pass through to the wall behind — and a landing inside the boxes' outline, from the wall to their fronts, is a round on
/// the junction.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - a ROUND or a RELOAD is noticed on the shooter's machine, weapons being never networked, which asks the host
/// (`NZNet.JunctionShot`, `NZNet.JunctionTest`);
/// - the ANSWERS are the HOST's alone, never sent;
/// - the ROUTE, the SETTINGS and whether it is begun, testing or done are HOST state, MIRRORED (`NZNet.JunctionState`) on
/// every change and to a joiner;
/// - each junction the energy reaches is an EVENT (`NZNet.JunctionPulse`), flashed on every machine; its tone is the host's
/// to send, heard alike by everyone;
/// - the BOXES, their lamps and the rings' blinking are LOCAL.
/// </summary>
public sealed partial class HexPlatforms
{
// ══ where they are ══════════════════════════════════════════════════════════════════════
/// <summary>How many junctions, and how many settings each has.</summary>
const int Junctions = 6, JunctionSettings = 3;
/// <summary>
/// The six spots, where the user's ASP wall buys hung on 2026-09-27: each wall's surface point, and the way it faces —
/// traced in the game. The order is only the list's; the route is dealt.
/// </summary>
static (Vector3 At, Vector2 Facing) JunctionSpot( int j ) => j switch
{
0 => (new( -1129.43f, 43.67f, 1238.48f ), new( 0.502f, 0.865f )),
1 => (new( 510.61f, 343.53f, 1236.70f ), new( 0.503f, 0.864f )),
2 => (new( 2050.33f, 13.99f, 1240.32f ), new( -0.507f, 0.862f )),
3 => (new( 118.20f, -1255.31f, 1232.42f ), new( -0.504f, 0.864f )),
4 => (new( -4078.63f, -64.04f, 1238.67f ), new( -0.501f, -0.865f )),
_ => (new( -734.77f, -1745.23f, 1399.30f ), new( -0.502f, -0.865f )),
};
/// <summary>
/// Each junction's colour, the same every game: the rainbow's six — red, orange, yellow, green, blue, purple — as wheel
/// colours. Light blue and pink are left out: blinked, they are the easiest to take for blue and for red or purple.
/// </summary>
static int JunctionColour( int j ) => j switch { 0 => 0, 1 => 1, 2 => 2, 3 => 3, 4 => 5, _ => 6 };
const string JunctionModel = "models/nightfire/electrical_boxes.vmdl";
/// <summary>
/// The model's own frame, as the game loads it: its depth along x — how deep, and which side of its origin, read off the
/// model itself (<see cref="JunctionDepth"/>) — y across (-37.5 to 9.4), and z up (20.8 at the top; the conduits hang to
/// -54). Its front is its +x: every face of it that looks along its depth looks +x, and its back, x -8, is open — made
/// to lie flat on a wall. Its main box, the one with the door, y -9.1 to 9.4 and z -19.5 to 20.8, is what stands on the
/// mark; the plain box is to its left as you face it.
///
/// ⛔ NOT THE OBJ's AXES READ AS THEY LOOK, NOT ITS ORIGIN, AND NOT A GUESS AT ITS FRONT. The first build took the OBJ's
/// depth for the model's y, and the boxes stood side-on to their walls: *"the junction needs to be rotated 90 degrees
/// counter clockwise around the vertical axis"*. The second turned them with their origin on the wall — and the model
/// lies BEHIND its origin, so every box stood inside its wall: *"all the boxes are inside the wall, they need to be
/// further away from the wall"*. The third seated them, and turned them half round besides, on a colour read off a
/// screenshot — and put every door to the wall: *"the box nearest to one of the rings, has it's back turned towards the
/// player"*. The front was found at last from the mesh itself: which way its faces look (2026-09-27).
/// </summary>
static Vector3 JunctionBoxMiddle => new( 0f, 0f, 0.65f );
static float? _junctionOut;
/// <summary>
/// How much further out from their walls than seated the junctions stand, in units: 0, their backs on the wall.
/// `nz_hex_junction_out` moves all six at once, on this machine and until a restart — set the default here once settled.
/// </summary>
public static float JunctionOut { get => _junctionOut ?? 0f; set => _junctionOut = value; }
/// <summary>LOCAL — the model's depth along its x as the game loaded it, back to front; read when the boxes are built.</summary>
static (float Min, float Max)? _jnDepth;
/// <summary>The model's depth along its x: as loaded, or the Nightfire source's 8u behind its origin until it has been.</summary>
static (float Min, float Max) JunctionDepth() => _jnDepth ?? (-8f, 0f);
/// <summary>
/// A junction's model in the world: its turn — its x, its front, along the wall's normal — and its origin, placed so the
/// model's back sits on the wall, <see cref="JunctionOut"/> further out.
/// </summary>
static Transform JunctionFrame( int j )
{
var (at, facing) = JunctionSpot( j );
var outward = new Vector3( facing.x, facing.y, 0f );
var turn = Rotation.FromYaw( MathF.Atan2( facing.y, facing.x ) * 180f / MathF.PI );
var (back, _) = JunctionDepth();
return new Transform( at + outward * (JunctionOut - back) - turn * JunctionBoxMiddle, turn );
}
/// <summary>
/// The boxes in their frame, for a round's landing: from 3u into the wall behind them to past their fronts, across, and
/// up — a round passing through them lands on the wall, the model's only physics being a small hull.
/// </summary>
static BBox JunctionBoxes()
{
var (min, max) = JunctionDepth();
return new BBox( new Vector3( min - JunctionOut - 3f, -38f, -20f ), new Vector3( max + 2f, 10f, 21f ) );
}
/// <summary>Where a junction's main box stands, halfway out from the wall: what a reload's burst must reach.</summary>
static Vector3 JunctionMiddle( int j )
{
var (at, facing) = JunctionSpot( j );
var (min, max) = JunctionDepth();
return at + new Vector3( facing.x, facing.y, 0f ) * (JunctionOut + (max - min) * 0.5f);
}
/// <summary>Which junction a round landing here struck, or -1.</summary>
public static int JunctionAt( Vector3 at )
{
var boxes = JunctionBoxes();
for ( var j = 0; j < Junctions; j++ )
{
var frame = JunctionFrame( j );
if ( boxes.Contains( frame.Rotation.Inverse * (at - frame.Position) ) ) return j;
}
return -1;
}
/// <summary>A junction's name, for the log: its colour.</summary>
static string JunctionName( int j ) => WheelName( JunctionColour( j ) );
// ══ the state ════════════════════════════════════════════════════════════════════════════
/// <summary>The flags: begun; the energy on its way; through to the teleporter.</summary>
const int JnActive = 1, JnTesting = 2, JnDone = 4;
/// <summary>
/// HOST — the route (the junctions in the order the energy runs them, three bits each), every junction's setting (two
/// bits each), its right one the same way, and the flags.
/// </summary>
int _jnRoute, _jnSettings, _jnAnswers, _jnFlags;
/// <summary>MIRROR — the route, the settings and the flags as this machine was told (`NZNet.JunctionState`). Never the answers.</summary>
int _jnRouteShown, _jnSettingsShown, _jnFlagsShown;
/// <summary>HOST — the energy on its way: how many junctions it has passed, and since when it last moved.</summary>
int _jnStep;
TimeSince _jnStepSince;
/// <summary>How long the energy takes from one junction to the next, in seconds: a tone's length, near enough.</summary>
const float JnStepSeconds = 0.6f;
/// <summary>How soon after a junction turned another round on it may turn it again: the Prisma fires ten a second.</summary>
const float JnShotCooldown = 0.3f;
/// <summary>HOST — when each junction last turned. ⚠️ MADE ON FIRST USE, NOT BY AN INITIALISER, for the hotload.</summary>
TimeSince[] _jnShot;
/// <summary>HOST — the junctions as they stand, for `NZNet.PushState` to replay to a joiner. Never the answers.</summary>
public static (int Route, int Settings, int Flags) JunctionsNow
=> Instance.IsValid() ? (Instance._jnRoute, Instance._jnSettings, Instance._jnFlags) : (0, 0, 0);
/// <summary>Has the energy reached the teleporter, as this machine was told? What the next step asks, and the pad's colour.</summary>
public bool JunctionsDoneShown => (_jnFlagsShown & (JnActive | JnDone)) == (JnActive | JnDone);
static int JnRouteAt( int route, int k ) => (route >> (3 * k)) & 7;
static int JnSetting( int settings, int j ) => (settings >> (2 * j)) & 3;
static int JnWithSetting( int settings, int j, int v ) => (settings & ~(3 << (2 * j))) | ((v & 3) << (2 * j));
/// <summary>The six in an order of their own, drawn afresh.</summary>
static int JnRandomRoute()
{
var order = Enumerable.Range( 0, Junctions ).ToArray();
for ( var i = order.Length - 1; i > 0; i-- )
{
var k = Game.Random.Next( i + 1 );
(order[i], order[k]) = (order[k], order[i]);
}
var packed = 0;
for ( var k = 0; k < Junctions; k++ ) packed |= order[k] << (3 * k);
return packed;
}
/// <summary>A setting for every junction, each drawn on its own.</summary>
static int JnRandomSettings()
{
var packed = 0;
for ( var j = 0; j < Junctions; j++ ) packed = JnWithSetting( packed, j, Game.Random.Next( JunctionSettings ) );
return packed;
}
void SendJunctions() => NZNet.JunctionState( _jnRoute, _jnSettings, _jnFlags );
/// <summary>
/// The junctions as they stand. EVERY machine — `NZNet.JunctionState`: the boxes' lamps, the rings' blinking and the
/// teleporter's sides dressed to match.
/// </summary>
public void ApplyJunctions( int route, int settings, int flags )
{
// the rings start their blinking from the top when the junctions go live here
if ( (flags & JnActive) != 0 && (_jnFlagsShown & JnActive) == 0 ) _jnBlinkSince = 0;
_jnRouteShown = route;
_jnSettingsShown = settings;
_jnFlagsShown = flags;
DressJunctions();
DressMastermind();
BuildShriekerSides();
}
/// <summary>The energy reached a junction, right or not. EVERY machine — `NZNet.JunctionPulse`: the junction flashes it.</summary>
public void ApplyJunctionPulse( int junction, bool right )
{
_jnPulse = junction;
_jnPulseRight = right;
_jnPulseSince = 0;
DressJunctions();
}
/// <summary>The junctions go live: a route dealt, every junction a setting and a right one. HOST — the lava survived.</summary>
void StartJunctions()
{
if ( (_jnFlags & JnActive) != 0 ) return;
_jnRoute = JnRandomRoute();
_jnSettings = JnRandomSettings();
_jnAnswers = JnRandomSettings();
_jnFlags = JnActive;
_jnStep = 0;
SendJunctions();
Log.Info( $"[nz-hex] ⚡ THE JUNCTIONS WAKE — the rings blink the route: {JnRouteText( _jnRoute )}. A Prisma round turns a"
+ " junction; a reload with Elemental Pop at the first sends the energy" );
}
/// <summary>Back to not begun, the teleporter's buttons first, which follow them. HOST — with the rising lava, which it follows.</summary>
void ResetJunctions()
{
ResetButtons();
if ( _jnFlags == 0 && _jnRoute == 0 && _jnSettings == 0 && _jnAnswers == 0 ) return;
_jnRoute = _jnSettings = _jnAnswers = _jnFlags = 0;
_jnStep = 0;
SendJunctions();
}
// ══ the rounds and the reload ════════════════════════════════════════════════════════════
/// <summary>A Prisma round struck a junction. HOST — `NZNet.JunctionShot`.</summary>
public static void HostJunctionShot( int junction )
{
if ( NZGame.IsClient ) return;
var m = Instance;
if ( m.IsValid() ) m.JunctionShot( junction );
}
/// <summary>
/// The rules for a round on a junction. HOST — apart from the RPC, so the selftest can walk it. It turns to its next
/// setting, unless it turned a moment ago (`anytime` skips that, for the test and the command). Nothing while the
/// junctions are not live, the energy is on its way, or it is through. Returns why not, or "".
/// </summary>
string JunctionShot( int junction, bool anytime = false )
{
if ( (_jnFlags & JnActive) == 0 ) return "the junctions are not live";
if ( (_jnFlags & JnDone) != 0 ) return "the energy is through already";
if ( (_jnFlags & JnTesting) != 0 ) return "the energy is on its way";
if ( junction < 0 || junction >= Junctions ) return "no such junction";
_jnShot ??= new TimeSince[Junctions];
if ( !anytime && _jnShot[junction] < JnShotCooldown ) return "it turned a moment ago";
_jnShot[junction] = 0;
_jnSettings = JnWithSetting( _jnSettings, junction, (JnSetting( _jnSettings, junction ) + 1) % JunctionSettings );
SendJunctions();
return "";
}
/// <summary>
/// Elemental Pop's reload burst went off here, this far. ON THE RELOADER'S MACHINE — `PerkEffects.ElementalPop`. While
/// the junctions wait for the energy as this machine was told, a burst that reaches the route's first junction asks the
/// host to send it.
/// </summary>
public static void OnPopBurst( Vector3 origin, float reach )
{
var m = Instance;
if ( !m.IsValid() || (m._jnFlagsShown & (JnActive | JnTesting | JnDone)) != JnActive || !OnBasalt ) return;
if ( m.PopReachesFirstJunction( origin, reach ) ) NZNet.JunctionTest();
}
/// <summary>Does a burst from here, this far, reach the first junction of the route as this machine was told?</summary>
bool PopReachesFirstJunction( Vector3 origin, float reach )
=> origin.Distance( JunctionMiddle( JnRouteAt( _jnRouteShown, 0 ) ) ) <= reach;
/// <summary>A reload's burst reached the first junction. HOST — `NZNet.JunctionTest`, with `who` as the call carries it.</summary>
public static void HostJunctionTest( string who )
{
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.StartJunctionTest( NameFor( body ) );
if ( refused != "" ) Log.Info( $"[nz-hex] the energy is not sent: {refused}" );
}
/// <summary>The energy sent along the route. HOST — apart from the RPC, so the selftest can walk it. Returns why not, or "".</summary>
string StartJunctionTest( string by = "" )
{
if ( (_jnFlags & JnActive) == 0 ) return "the junctions are not live";
if ( (_jnFlags & JnDone) != 0 ) return "the energy is through already";
if ( (_jnFlags & JnTesting) != 0 ) return "the energy is on its way already";
_jnFlags |= JnTesting;
_jnStep = 0;
_jnStepSince = 0;
SendJunctions();
Log.Info( $"[nz-hex] ⚡ the energy is sent{( string.IsNullOrEmpty( by ) ? "" : $" by {by}" )} — {JnSettingsText()}" );
return "";
}
/// <summary>
/// The energy on its way, a junction every <see cref="JnStepSeconds"/>: set right, its tone — the k-th, a step higher —
/// and on to the next; set wrong, the power-down, and it dies there. Past the sixth, it is through. HOST — `OnUpdate`.
/// </summary>
void WatchJunctions()
{
if ( (_jnFlags & JnTesting) == 0 || _jnStepSince < JnStepSeconds ) return;
_jnStepSince = 0;
var junction = JnRouteAt( _jnRoute, _jnStep );
var right = JnSetting( _jnSettings, junction ) == JnSetting( _jnAnswers, junction );
NZNet.JunctionPulse( junction, right );
if ( !right )
{
_jnFlags &= ~JnTesting;
SendJunctions();
Cue( JunctionFailCue );
Log.Info( $"[nz-hex] ⚡ the energy dies at the {JunctionName( junction )} junction, number {_jnStep + 1} on the route" );
return;
}
Cue( JunctionCue( _jnStep ) );
_jnStep++;
if ( _jnStep < Junctions ) return;
_jnFlags = (_jnFlags & ~JnTesting) | JnDone;
SendJunctions();
if ( !Testing ) DoneCueLater();
Fanfare( 12 );
Log.Info( "[nz-hex] ✦ THE ENERGY REACHES THE TELEPORTER — every junction set right; its sides turn light blue, and its"
+ " buttons wake" );
StartButtons();
}
// ══ the sounds ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// The k-th junction's tone on the route, 0 first: `Assets/sounds/nz/nz.hex.junction1.sound` … `6`, a major pentatonic
/// climbing from A4, 2D so everyone counts them alike (`Tools/basalt_junction_sounds.py`).
/// </summary>
static string JunctionCue( int k ) => $"nz.hex.junction{Math.Clamp( k, 0, Junctions - 1 ) + 1}";
/// <summary>The energy dying at a junction: Color Smash's power-down, 2D.</summary>
const string JunctionFailCue = "nz.hex.junction.fail";
// ══ the boxes ════════════════════════════════════════════════════════════════════════════
/// <summary>LOCAL — each junction's model, and on it its circle and its three lamps.</summary>
GameObject[] _jnGo;
ModelRenderer[] _jnCircle, _jnLamp;
/// <summary>LOCAL — the last junction the energy reached, whether it was set right, and since when: its flash.</summary>
int _jnPulse = -1;
bool _jnPulseRight;
TimeSince _jnPulseSince;
/// <summary>How long a junction flashes as the energy reaches it.</summary>
const float JnPulseSeconds = 0.45f;
/// <summary>LOCAL — since when the rings have blinked the route here.</summary>
TimeSince _jnBlinkSince;
/// <summary>
/// How the junctions are drawn: ⚠️ BUMP IT WHEN THAT CHANGES. 1: the boxes, their circles and lamps, 2026-09-27; 2: turned
/// a quarter to face out of their walls, the lights on their true fronts; 3: seated on the wall from the model's own
/// depth, with `nz_hex_junction_out` and `nz_hex_junction_flip`; 4: turned half round by default — every door to the
/// wall; 5: turned back, the model's front found from its faces, the lights on the door's face, the flip gone. A manager
/// that has not built this layout builds them again — `OnUpdate` asks every frame — as the altar's does.
/// </summary>
const int JunctionsLayout = 5;
/// <summary>LOCAL — the layout this manager last built the junctions in; 0 before it has.</summary>
int _jnLaid;
/// <summary>
/// How far out from the model's back its main box's door stands: 6.19u (the OBJ: that box x -8 to -1.81). The plain box
/// beside it stands out further, to -0.09 — the model's bounds — so the door is not the bounds' front.
/// </summary>
const float JunctionDoorDepth = 6.19f;
/// <summary>
/// Where a junction's circle and its three lamps lie on its main box's door, in the model's frame, 0.3u proud of it,
/// facing out as the discs do (+x) — the lamps left to right as you face it, settings 1 to 3: the model's +y is on your
/// right.
/// </summary>
static Vector3 JunctionLightAt( float across, float up ) => new( JunctionDepth().Min + JunctionDoorDepth + 0.3f, across, up );
static Vector3 JunctionCircleAt => JunctionLightAt( 0f, 8f );
static Vector3 JunctionLampAt( int i ) => JunctionLightAt( -5f + i * 5f, -9.5f );
const float JunctionCircleRadius = 5f, JunctionLampRadius = 1.9f;
/// <summary>Say once that the model did not load, not at every rebuild.</summary>
static bool _junctionMissing;
static Material _jnDim;
/// <summary>An unlit lamp: a copy of white001, nearly black — so it still reads as a lamp, only off.</summary>
static Material JunctionDimMaterial()
{
var white = Material.Load( LightMaterial );
if ( white is null ) return null;
// ⛔ `.vmat` ON THE END (2026-10-05), as `StripLights.Salt` explains: the engine reads a copy's name as a resource path, and
// without the extension refuses it. At the 22:51 host handoff Vascc's game said so 30 times in one frame ("FixupResourceName:
// Illegal path, missing extension passed in"), once for each thing wearing this copy: 6 junction circles, 18 lamps, 6 buttons.
_jnDim ??= white.CreateCopy( "nz_hex_junction_dim.vmat" );
_jnDim.Set( "g_vColorTint", new Vector3( 0.06f, 0.06f, 0.06f ) );
return _jnDim;
}
/// <summary>The six boxes, on basalt, all game: built once, and again at a new layout. Their lights follow the state. LOCAL.</summary>
void BuildJunctions()
{
_jnLaid = JunctionsLayout;
ClearJunctions();
if ( !OnBasalt || !Scene.IsValid() ) return;
var model = Model.Load( JunctionModel );
if ( model is null || model.IsError )
{
if ( !_junctionMissing ) Log.Warning( $"[nz-hex] {JunctionModel} did not load — the junctions are not built" );
_junctionMissing = true;
return;
}
// the model's depth as the game loaded it: where its back is, so it can be seated on the wall
var bounds = model.Bounds;
_jnDepth = (bounds.Mins.x, bounds.Maxs.x);
_jnGo = new GameObject[Junctions];
_jnCircle = new ModelRenderer[Junctions];
_jnLamp = new ModelRenderer[Junctions * JunctionSettings];
var circle = DiscModel( JunctionCircleRadius, 32 );
var lamp = DiscModel( JunctionLampRadius, 16 );
for ( var j = 0; j < Junctions; j++ )
{
var frame = JunctionFrame( j );
var root = Scene.CreateObject();
root.Name = $"Basalt junction ({JunctionName( j )})";
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 = frame.Position;
root.WorldRotation = frame.Rotation;
root.Components.Create<ModelRenderer>().Model = model;
_jnCircle[j] = JunctionPart( root, "circle", circle, JunctionCircleAt );
for ( var i = 0; i < JunctionSettings; i++ )
_jnLamp[j * JunctionSettings + i] = JunctionPart( root, $"lamp {i + 1}", lamp, JunctionLampAt( i ) );
_jnGo[j] = root;
}
DressJunctions();
}
/// <summary>One of a junction's lights: a child of its box, in the box's frame, drawing this disc.</summary>
ModelRenderer JunctionPart( GameObject root, string name, Model model, Vector3 at )
{
if ( model is null ) return null;
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 );
go.LocalPosition = at;
go.LocalRotation = Rotation.Identity;
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
r.RenderType = ModelRenderer.ShadowRenderType.Off;
return r;
}
void ClearJunctions()
{
if ( _jnGo is not null )
foreach ( var go in _jnGo )
if ( go.IsValid() ) go.Destroy();
_jnGo = null;
_jnCircle = null;
_jnLamp = null;
}
/// <summary>
/// The junctions' lights as this machine was told. The circle glows its colour once the junctions are live; the lamps
/// show the setting — the lit one white, the others dark — all three flashing green or red as the energy reaches it, and
/// all three white once it is through. Before, all dark. LOCAL.
/// </summary>
void DressJunctions()
{
if ( _jnGo is null ) return;
var live = (_jnFlagsShown & JnActive) != 0;
var through = (_jnFlagsShown & JnDone) != 0;
var dim = JunctionDimMaterial();
var white = MaterialFor( White );
for ( var j = 0; j < Junctions; j++ )
{
if ( _jnCircle?[j] is ModelRenderer c && c.IsValid() ) c.MaterialOverride = live ? WheelMaterial( JunctionColour( j ) ) : dim;
var pulsing = _jnPulse == j && _jnPulseSince < JnPulseSeconds;
var setting = JnSetting( _jnSettingsShown, j );
for ( var i = 0; i < JunctionSettings; i++ )
{
if ( _jnLamp?[j * JunctionSettings + i] is not ModelRenderer l || !l.IsValid() ) continue;
l.MaterialOverride = pulsing ? WheelMaterial( _jnPulseRight ? WheelGreen : WheelRed )
: through ? white
: live && i == setting ? white
: dim;
}
}
}
/// <summary>A junction's flash run out. LOCAL — `OnUpdate`.</summary>
void TickJunctions()
{
if ( _jnPulse < 0 || _jnPulseSince < JnPulseSeconds ) return;
_jnPulse = -1;
DressJunctions();
}
/// <summary>
/// A flat disc facing out of a box's front (its +x), centred on its own origin, in white001 — the colour is its renderer's
/// override. Each wedge is wound so its normal points out: the panels' rule, the normal a counter-clockwise triangle gives.
/// </summary>
static Model DiscModel( float radius, int segments )
{
var material = Material.Load( LightMaterial );
if ( material is null ) return null;
var verts = new List<Vertex> { new( Vector3.Zero, Vector3.Forward, Vector3.Left, new Vector4( 0.5f, 0.5f, 0f, 0f ) ) };
for ( var i = 0; i < segments; i++ )
{
var a = MathF.PI * 2f * i / segments;
verts.Add( new Vertex( new Vector3( 0f, MathF.Cos( a ) * radius, MathF.Sin( a ) * radius ), Vector3.Forward, Vector3.Left,
new Vector4( 0.5f + MathF.Cos( a ) * 0.5f, 0.5f - MathF.Sin( a ) * 0.5f, 0f, 0f ) ) );
}
var idx = new List<int>();
for ( var i = 0; i < segments; i++ )
{
idx.Add( 0 );
idx.Add( 1 + i );
idx.Add( 1 + (i + 1) % segments );
}
// ⛔ SET BY HAND, AFTER THE BUFFERS: a runtime mesh gets empty bounds otherwise (`HexSidePanels.Build`)
var bounds = new BBox( new Vector3( -0.5f, -radius, -radius ), new Vector3( 0.5f, radius, radius ) );
var mesh = new Mesh( material );
mesh.CreateVertexBuffer( verts.Count, verts );
mesh.CreateIndexBuffer( idx.Count, idx );
mesh.Bounds = bounds;
return Model.Builder.AddMesh( mesh ).WithViewBounds( bounds ).Create();
}
// ══ the rings' blinking ══════════════════════════════════════════════════════════════════
/// <summary>
/// The rings' pattern, in units of this long: `O_O_O_O_O_O__________` — six blinks, a unit on and a unit off between
/// them (11 units), then a gap of ten — and over again.
/// </summary>
const float JnBlinkUnit = 0.5f;
const int JnBlinkUnits = 21, JnBlinkRun = 11;
/// <summary>Are the rings blinking the route here: the junctions live, and the energy not yet through?</summary>
bool JunctionsBlinking => OnBasalt && (_jnFlagsShown & (JnActive | JnDone)) == JnActive;
/// <summary>
/// What the Mastermind's rings show now, for the junctions: -2 when they are not blinking; -1 between blinks — the map's
/// white, so a green blink reads against it; else the colour of the route's junction the blink stands for. LOCAL.
/// </summary>
int JunctionBlinkShown()
{
if ( !JunctionsBlinking ) return -2;
var unit = (int)(_jnBlinkSince / JnBlinkUnit) % JnBlinkUnits;
if ( unit >= JnBlinkRun || unit % 2 == 1 ) return -1;
return JunctionColour( JnRouteAt( _jnRouteShown, unit / 2 ) );
}
// ══ in words ════════════════════════════════════════════════════════════════════════════
static string JnRouteText( int route )
=> string.Join( " → ", Enumerable.Range( 0, Junctions ).Select( k => JunctionName( JnRouteAt( route, k ) ) ) );
/// <summary>Every junction's setting, in the route's order. HOST.</summary>
string JnSettingsText()
=> string.Join( " · ", Enumerable.Range( 0, Junctions ).Select( k =>
{
var j = JnRouteAt( _jnRoute, k );
return $"{JunctionName( j )} {JnSetting( _jnSettings, j ) + 1}";
} ) );
/// <summary>Where it stands, in words. HOST.</summary>
string JunctionsStateText()
{
if ( (_jnFlags & JnActive) == 0 ) return "the junctions: waiting for the lava to be survived";
if ( (_jnFlags & JnDone) != 0 ) return "the junctions: DONE — the energy is through to the teleporter";
return $"the junctions: the route {JnRouteText( _jnRoute )} · settings {JnSettingsText()}"
+ ( (_jnFlags & JnTesting) != 0 ? $" · the energy on its way, {_jnStep} passed" : "" );
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_hex_junction_out [units]` — how much further out from their walls than seated the junctions stand, all six moved
/// at once; bare, it prints it with the model's depth as loaded. On this machine and until a restart: set the default in
/// `JunctionOut` once it is settled.
/// </summary>
[ConCmd( "nz_hex_junction_out" )]
public static void JunctionOutCmd( float units = float.NaN )
{
if ( !float.IsNaN( units ) )
{
JunctionOut = units;
if ( Instance.IsValid() ) Instance.BuildJunctions();
}
var (min, max) = JunctionDepth();
Log.Info( $"[nz-hex] the junctions stand {JunctionOut:0.#}u further out than seated — the model {max - min:0.#}u deep,"
+ $" x {min:0.#} to {max:0.#}" + ( _jnDepth is null ? " (not loaded yet: the source's numbers)" : "" ) );
}
/// <summary>
/// `nz_hex_junctions [start|shoot j|test|answer|solve|done|reset|tone k]` — HOST: where the junctions stand. `start` wakes
/// them, as the lava's end does; `shoot j` is a Prisma round on junction j, by its colour or its number in the list
/// (1-6); `test` sends the energy, as a reload with Elemental Pop at the first would; `answer` prints every junction's
/// right setting — testing only, it gives it away; `solve` sets them all to it, for `test` to finish; `done` puts the
/// energy through at once, waking the teleporter's buttons; `reset` makes them not begun; `tone k` plays the k-th tone on
/// this machine alone.
/// </summary>
[ConCmd( "nz_hex_junctions" )]
public static void JunctionsCmd( string what = "", string which = "" )
{
var cmd = what.Trim().ToLowerInvariant();
if ( cmd == "tone" )
{
var k = int.TryParse( which, out var n ) ? n : 1;
NZSound.Play( k <= 0 ? JunctionFailCue : JunctionCue( k - 1 ) );
Log.Info( $"[nz-hex] {(k <= 0 ? JunctionFailCue : JunctionCue( k - 1 ))}" );
return;
}
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 ( cmd )
{
case "":
break;
case "start":
if ( m._lava != LavaDone ) Log.Warning( "[nz-hex] the lava has not been survived, which wakes them — woken by hand" );
m.StartJunctions();
break;
case "shoot":
{
var j = int.TryParse( which, out var n ) ? n - 1
: Enumerable.Range( 0, Junctions ).FirstOrDefault( k => JunctionColour( k ) == WheelOf( which ), -1 );
if ( j < 0 || j >= Junctions ) { Log.Warning( "[nz-hex] nz_hex_junctions shoot red, orange, yellow, green, blue or purple — or 1-6" ); return; }
refused = m.JunctionShot( j, anytime: true );
break;
}
case "test":
refused = m.StartJunctionTest( "hand" );
break;
case "answer":
Log.Info( (m._jnFlags & JnActive) != 0
? "[nz-hex] the junctions' right settings: " + string.Join( " · ", Enumerable.Range( 0, Junctions ).Select( k =>
{
var j = JnRouteAt( m._jnRoute, k );
return $"{JunctionName( j )} {JnSetting( m._jnAnswers, j ) + 1}";
} ) ) + " — testing only, it gives it away"
: "[nz-hex] the junctions are not live, so they have no answers yet" );
return;
case "solve":
if ( (m._jnFlags & JnActive) == 0 ) { Log.Warning( "[nz-hex] the junctions are not live — nz_hex_junctions start first" ); return; }
m._jnSettings = m._jnAnswers;
m.SendJunctions();
break;
case "done":
m.StartJunctions();
m._jnSettings = m._jnAnswers;
m._jnFlags = JnActive | JnDone;
m.SendJunctions();
m.StartButtons();
break;
case "reset":
m.ResetJunctions();
break;
default:
Log.Warning( "[nz-hex] nz_hex_junctions start, shoot <colour|1-6>, test, answer, solve, done, reset or tone <1-6> — or"
+ " nothing, to see where they stand" );
return;
}
if ( refused != "" ) Log.Warning( $"[nz-hex] no: {refused}" );
Log.Info( $"[nz-hex] {m.JunctionsStateText()}" );
}
}