Partial class for HexPlatforms implementing the Color Rings puzzle. It encodes ring positions, targets, and per-platform ammo mods in a packed bitfield, handles dealing random mods and targets, applies and mirrors host state, reacts to ammo-mod procs to move dots with cooldowns, draws platform glyph marks and mod icons locally, and provides console commands for testing and admin.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT — STEP 3, COLOR RINGS (named by the user, 2026-09-26). It begins when Bonfire is done. The rings clue
/// (<see cref="RingsClue"/>) shows four dots, one per colour, each on its ring at one of 8 positions.
///
/// ⛔ EACH PLATFORM WANTS ITS OWN AMMO MOD, DEALT AT RANDOM, AND ITS ICON ON THE HEX CLUE SAYS WHICH. When Color Rings
/// begins the host deals the four Color Smash platforms one ammo mod each out of the six (<see cref="RingMods"/>), no two
/// alike, and every machine's hex clue (`HexClue`) draws that mod's icon (<see cref="ModIcon"/>) on the platform's tile, in
/// the platform's own colour. Trigger a platform's mod over it and its colour's dot moves one position on, clockwise. The
/// colour says which ring; the icon says which mod. Asked for as *"make an icon for each ammo mod, and we put a random icon
/// on each platform on the map — that is the ammo mod that we need to use for each of the platforms, meaning it's no
/// longer color related, the color is only meant to be a connection to the rings"*, and *"the icons must be able to be any
/// of the 4 colors to match the tile it's in"*. Until then the mods were fixed by colour: Dead Wire blue, Fire Works
/// yellow, Radioactive Decay green, Blast Furnace red.
///
/// ⚠️ THE ICONS ARE ON THE CLUE, NOT ON THE PLATFORMS. They were first laid on the platforms' tops, a misreading of "on the
/// map" that the user put right: *"the ammo mod icon only appears on the map … i mean as the ammo mod only appears in the
/// clue map we made"*, and *"the rings icon appears on the actual platform"* — the glyph. A platform shows only its glyph.
///
/// ⛔ THE HINT: EACH PLATFORM SHOWS A GLYPH, AND ITS COLOUR'S DOT MUST POINT AT IT. When Color Rings begins the host
/// deals each platform one of the eight glyphs (`RingsClue.GlyphStrokes`) — at random, repeats allowed — laid in the
/// middle of its top, in its colour. The glyph's place on the rings clue's clock (glyph k at position k-1) is where
/// that colour's dot must end. Asked for as *"each of the 4 hexagons that has a color from the color smash step, after
/// completing the bonfire step, will have one of these symbols appear on its face, the symbol is always random and can be
/// repeated among different tiles — that is where each color's circle will have to be pointing towards"*. No dot begins
/// where it must end. All four there: Color Rings is done (<see cref="RingsDone"/>) — the step-done clicking plays, and no
/// mod moves a dot after. What it opens is still to decide.
///
/// ⚠️ AN AMMO MOD ONLY EVER GOES OFF ON A ZOMBIE. Each mod procs on the shooter's machine when a bullet hits one — Blast
/// Furnace when one dies — so "triggered on the red platform" means on a zombie standing on it. Where that zombie stands
/// is the place: the tile its feet are on, with a body's slack on tile 1 (24u past the edge, 48u off the top). Another
/// platform's mod, or a mod no platform wants, moves nothing.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the SHOOTER'S MACHINE is the only one that knows a mod went off (`AmmoMods.Fire`, `BlastFurnace.OnZombieKilled`), and
/// it reports "mod X went off at P" to the host (`NZNet.HexAmmoMod`) — the slam's pattern;
/// - the POSITIONS, each platform's MOD and GLYPH, and whether it is DONE are HOST state, dealt together and MIRRORED whole,
/// in one long, on every move (`NZNet.HexRings`) and to a joiner;
/// - the PICTURES (the rings, and the hex clue with its icons) and the GLYPHS are LOCAL: every machine draws its own.
///
/// ⚠️ ONE MOVE A SECOND PER COLOUR (<see cref="RingCooldown"/>). Several reports can land together for what the player
/// sees as one trigger — a shotgun blast that kills three on a platform with Blast Furnace, Elemental Pop's surge going
/// off beside the fitted mod, two players at once — and without a pause they would move a dot several places.
/// </summary>
public sealed partial class HexPlatforms
{
/// <summary>How many places a dot can stand on its ring: 8, an eighth of a turn apart.</summary>
public const int RingPositions = 8;
/// <summary>
/// The ammo mods a platform can want: all six the Arsenal sells, each with its icon (<see cref="ModIcon"/>). A
/// platform's mod crosses the network as its place in this list, in 3 bits, so ⚠️ ADD AT THE END, NEVER BETWEEN — and
/// eight at most. A property, not a static array, so a change reaches a running game (INSTRUCTIONS.md §1).
/// </summary>
public static string[] RingMods => new[] { "deadwire", "fireworks", "radiation", "blastfurnace", "thunderwall", "cryofreeze" };
/// <summary>A mod's own name — "Dead Wire" for `deadwire` — or its id, if the catalogue has no such mod.</summary>
public static string ModName( string mod ) => AmmoMods.Find( mod )?.Name ?? mod;
/// <summary>
/// The whole of Color Rings, in one long so it crosses the network as one message:
/// - colour c's dot (0-7: 0 straight up, then clockwise) in bits 3c to 3c+2;
/// - bit 12 set once the puzzle has begun;
/// - the mod colour c's platform wants, as its place in <see cref="RingMods"/>, in bits 13+3c to 15+3c;
/// - where colour c's dot must end — its platform's glyph's place on the clock — in bits 25+3c to 27+3c;
/// - bit 37 set once every dot is there: Color Rings done.
/// `default` is not begun.
///
/// ⚠️ A LONG SINCE THE GLYPHS: 38 bits, where an int held the first 25.
/// </summary>
public readonly record struct RingState( long Packed )
{
const long Begun = 1L << 12, Finished = 1L << 37;
const int ModBits = 13, TargetBits = 25;
/// <summary>Has the puzzle begun — the dots on the rings, the icons on the hex clue, the glyphs on the platforms?</summary>
public bool Active => (Packed & Begun) != 0;
/// <summary>Is Color Rings done — every dot where its platform's glyph says?</summary>
public bool Done => (Packed & Finished) != 0;
/// <summary>Where a colour's dot stands, 0-7.</summary>
public int PositionOf( int colour ) => colour < 0 || colour >= Colours ? 0 : (int)((Packed >> (colour * 3)) & 7);
/// <summary>Where a colour's dot must end, 0-7: the place on the clock of the glyph its platform shows.</summary>
public int TargetOf( int colour ) => colour < 0 || colour >= Colours ? 0 : (int)((Packed >> (TargetBits + colour * 3)) & 7);
/// <summary>The glyph a colour's platform shows, 1-8 — glyph k stands at position k-1 — or 0 before the puzzle begins.</summary>
public int GlyphOf( int colour ) => Active && colour >= 0 && colour < Colours ? TargetOf( colour ) + 1 : 0;
/// <summary>Is every dot where it must end?</summary>
public bool Solved
{
get
{
for ( var c = 0; c < Colours; c++ )
if ( PositionOf( c ) != TargetOf( c ) ) return false;
return true;
}
}
/// <summary>The ammo mod a colour's platform wants, by id — or "", before the puzzle begins.</summary>
public string ModOf( int colour )
{
if ( !Active || colour < 0 || colour >= Colours ) return "";
var mods = RingMods;
var k = (int)((Packed >> (ModBits + colour * 3)) & 7);
return k < mods.Length ? mods[k] : "";
}
/// <summary>
/// Only what the hex clue shows of it — begun, and each platform's mod — so `HexClue` draws again when a mod is
/// dealt, not at every move of a dot.
/// </summary>
public long ModsShown => Active ? Packed & ((0xFFFL << ModBits) | Begun) : 0L;
/// <summary>The same, with one colour's dot one position on, clockwise. Everything else stays.</summary>
public RingState Moved( int colour )
{
if ( colour < 0 || colour >= Colours ) return this;
var p = (long)((PositionOf( colour ) + 1) % RingPositions);
return new RingState( (Packed & ~(7L << (colour * 3))) | (p << (colour * 3)) | Begun );
}
/// <summary>The same, with the dots at these positions, by colour number (blue, yellow, green, red).</summary>
public RingState WithPositions( IReadOnlyList<int> positions )
{
var packed = Packed & ~0xFFFL;
for ( var c = 0; c < Colours && c < positions.Count; c++ )
packed |= (long)(positions[c] & 7) << (c * 3);
return new RingState( packed );
}
/// <summary>The same, with the dots due at these positions, by colour number — the glyphs the platforms show, less one.</summary>
public RingState WithTargets( IReadOnlyList<int> targets )
{
var packed = Packed & ~(0xFFFL << TargetBits);
for ( var c = 0; c < Colours && c < targets.Count; c++ )
packed |= (long)(targets[c] & 7) << (TargetBits + c * 3);
return new RingState( packed );
}
/// <summary>
/// The same, with the platforms wanting these mods, by colour number (blue, yellow, green, red) — ids out of
/// <see cref="RingMods"/>. One it does not know becomes the first.
/// </summary>
public RingState WithMods( IReadOnlyList<string> ids )
{
var mods = RingMods;
var packed = Packed & ~(0xFFFL << ModBits);
for ( var c = 0; c < Colours && c < ids.Count; c++ )
{
var k = 0;
while ( k < mods.Length && mods[k] != ids[c] ) k++;
packed |= (long)(k < mods.Length ? k : 0) << (ModBits + c * 3);
}
return new RingState( packed );
}
/// <summary>The same, done.</summary>
public RingState AsDone() => new( Packed | Finished );
/// <summary>
/// Begun, with the dots at these positions, by colour number (blue, yellow, green, red). Every platform wants the
/// first mod, and every dot is due at 0, until told otherwise.
/// </summary>
public static RingState Of( int blue, int yellow, int green, int red )
=> new RingState( Begun ).WithPositions( new[] { blue, yellow, green, red } );
/// <summary>Four different mods out of <see cref="RingMods"/>, at random, one per colour's platform — only mods the game has built.</summary>
public static string[] DealMods()
{
var pool = RingMods.Where( id => AmmoMods.Find( id )?.Built == true ).ToList();
var dealt = new string[Colours];
for ( var c = 0; c < Colours; c++ )
{
// ⚠️ NEVER SHORT WITH SIX, but a pool cut below four repeats a mod rather than leave a platform bare
if ( pool.Count == 0 ) pool = RingMods.ToList();
var k = Game.Random.Next( pool.Count );
dealt[c] = pool[k];
pool.RemoveAt( k );
}
return dealt;
}
/// <summary>Where each dot must end, one per colour, each any of the eight — repeats allowed, as asked.</summary>
public static int[] DealTargets()
=> Enumerable.Range( 0, Colours ).Select( _ => Game.Random.Next( RingPositions ) ).ToArray();
/// <summary>
/// Begun: each platform dealt a different mod and a glyph of its own, and every dot somewhere at random — ⚠️ BUT NOT
/// WHERE IT MUST END, so the puzzle never begins with a dot done, nor, by chance, all four.
/// </summary>
public static RingState Random()
{
var targets = DealTargets();
var starts = targets.Select( t => (t + 1 + Game.Random.Next( RingPositions - 1 )) % RingPositions ).ToArray();
return new RingState( Begun ).WithPositions( starts ).WithTargets( targets ).WithMods( DealMods() );
}
/// <summary>The four, in words: each colour's dot, where it must end (its platform's glyph), and its platform's mod.</summary>
public string Describe()
{
// ⚠️ A COPY, because a lambda in a struct cannot reach `this` (CS1673)
var s = this;
return string.Join( ", ", Enumerable.Range( 0, Colours )
.Select( c => $"{ColourName( c )} {s.PositionOf( c )}→{s.TargetOf( c )} (glyph {s.TargetOf( c ) + 1}, {ModName( s.ModOf( c ) )})" ) )
+ ( s.Done ? " · DONE" : "" );
}
}
/// <summary>HOST — where the dots stand, and which mod each platform wants.</summary>
RingState _rings;
/// <summary>MIRROR — the same, as this machine was told (`NZNet.HexRings`). `RingsClue` and `HexClue` draw from it; the glyphs are laid from it.</summary>
RingState _ringsShown;
/// <summary>Where the dots stand and what each platform wants, as this machine shows them.</summary>
public RingState RingsShown => _ringsShown;
/// <summary>HOST — the same, for `NZNet.PushState` to replay to a joiner.</summary>
public static RingState RingsState => Instance.IsValid() ? Instance._rings : default;
void SendRings() => NZNet.HexRings( _rings.Packed );
/// <summary>
/// Where the dots stand and what each platform wants. EVERY machine — `NZNet.HexRings`: the rings clue and the hex
/// clue's icons draw again from it, and the platforms' glyphs are laid to match.
/// </summary>
public void ApplyRings( RingState r )
{
_ringsShown = r;
BuildPlatformMarks();
}
/// <summary>The puzzle begins: each platform dealt its mod and its glyph, every dot somewhere else. HOST — Bonfire done.</summary>
void StartRings()
{
if ( _rings.Active ) return;
_rings = RingState.Random();
SendRings();
Log.Info( "[nz-hex] ◎ COLOR RINGS BEGUN — each platform's own ammo mod, its icon on the hex clue, moves its colour's"
+ " dot, to the glyph on the platform: " + _rings.Describe() );
}
/// <summary>
/// Every dot where its platform's glyph says: Color Rings is done. HOST. The dots stay where they are and no mod moves one
/// after; the step-done clicking plays, as it does for Color Smash. What it opens is still to decide.
/// </summary>
void RingsDone( string who = "" )
{
_rings = _rings.AsDone();
SendRings();
Cue( DoneCue );
Fanfare( 3 );
Log.Info( $"[nz-hex] ✦ COLOR RINGS DONE{By( who )} — every dot where its platform's glyph says: {_rings.Describe()}" );
}
/// <summary>The puzzle back to not begun, the icons off the clue and the glyphs off the platforms. HOST — a new game, with Bonfire.</summary>
void ResetRings()
{
if ( _rings.Packed == 0 ) return;
_rings = default;
SendRings();
}
static float? _ringCooldown;
/// <summary>How long after a dot moves before its colour can move it again, in seconds — see the class note.</summary>
public static float RingCooldown { get => _ringCooldown ?? 1f; set => _ringCooldown = value; }
TimeSince[] _ringMoved;
/// <summary>
/// HOST — when each colour's dot last moved. ⚠️ MADE ON FIRST USE, NOT BY AN INITIALISER, so a manager that a hotload
/// carried over from before the field existed still gets one.
/// </summary>
TimeSince[] RingMoved => _ringMoved ??= new TimeSince[Colours];
// ══ the marks: the ammo icons on the hex clue, the glyphs on the platforms ══════════════════
/// <summary>
/// Each ammo mod's icon, drawn on a platform's tile on the hex clue (`HexClue.Draw`) when Color Rings deals it that mod —
/// null for "", or a mod with none. The ammo symbols' stroke family —
/// one stroke, round ends, small rings and dots, most directions the hexagon's own — each hinting at its mod without
/// naming it:
/// - Dead Wire: two terminals, and a zig-zag broken in the middle — the current jumps the gap;
/// - Fire Works: a burst of rays and sparks, and a broken trail rising into it;
/// - Radioactive Decay: a core, three rays, and a ring broken into pieces;
/// - Blast Furnace: the alchemists' fire (an upward triangle) over a hearth, a flame inside;
/// - Thunderwall: a gust of wind against a wall;
/// - Cryofreeze: a frost crystal, six branched arms — the same whichever way it is turned.
/// The points span -1..1, y up.
///
/// ⚠️ `Tools/basalt_hex_symbols.py` READS THESE LINES to draw the icons as pictures, so each shape has this one home. Keep
/// each mod between its `"id" => new[]` line and its `},`, one shape a line, as ("kind", new[] { numbers }).
///
/// ⚠️ A METHOD, NOT A STATIC ARRAY, so a change to a shape reaches a running game with the hotload (INSTRUCTIONS.md §1).
/// </summary>
public static (string Kind, float[] P)[] ModIcon( string mod ) => mod switch
{
"deadwire" => new[]
{
("ring", new[] { 0.00f, 0.74f, 0.12f }),
("ring", new[] { 0.00f, -0.74f, 0.12f }),
("line", new[] { 0.00f, 0.62f, 0.26f, 0.40f, -0.22f, 0.20f, 0.10f, 0.14f }),
("line", new[] { -0.10f, -0.14f, 0.22f, -0.20f, -0.26f, -0.40f, 0.00f, -0.62f }),
},
"fireworks" => new[]
{
("line", new[] { 0.2425f, 0.14f, 0.485f, 0.28f }),
("line", new[] { 0.00f, 0.28f, 0.00f, 0.56f }),
("line", new[] { -0.2425f, 0.14f, -0.485f, 0.28f }),
("line", new[] { -0.2425f, -0.14f, -0.485f, -0.28f }),
("line", new[] { 0.2425f, -0.14f, 0.485f, -0.28f }),
("dot", new[] { 0.6582f, 0.38f, 0.055f }),
("dot", new[] { 0.00f, 0.76f, 0.055f }),
("dot", new[] { -0.6582f, 0.38f, 0.055f }),
("dot", new[] { -0.6582f, -0.38f, 0.055f }),
("dot", new[] { 0.6582f, -0.38f, 0.055f }),
("line", new[] { 0.00f, -0.92f, 0.00f, -0.76f }),
("line", new[] { 0.00f, -0.64f, 0.00f, -0.36f }),
},
"radiation" => new[]
{
("dot", new[] { 0.00f, 0.00f, 0.10f }),
("line", new[] { 0.00f, 0.26f, 0.00f, 0.60f }),
("line", new[] { -0.2252f, -0.13f, -0.5196f, -0.30f }),
("line", new[] { 0.2252f, -0.13f, 0.5196f, -0.30f }),
("arc", new[] { 0.00f, 0.00f, 0.80f, -5.00f, 65.00f }),
("arc", new[] { 0.00f, 0.00f, 0.80f, 115.00f, 185.00f }),
("arc", new[] { 0.00f, 0.00f, 0.80f, 235.00f, 305.00f }),
},
"blastfurnace" => new[]
{
("line", new[] { 0.00f, 0.72f, -0.66f, -0.42f, 0.66f, -0.42f, 0.00f, 0.72f }),
("line", new[] { -0.82f, -0.66f, 0.82f, -0.66f }),
("line", new[] { -0.20f, -0.22f, 0.00f, 0.14f, 0.20f, -0.22f }),
},
"thunderwall" => new[]
{
("line", new[] { -0.80f, 0.36f, 0.12f, 0.36f }),
("arc", new[] { 0.12f, 0.54f, 0.18f, -90.00f, 160.00f }),
("line", new[] { -0.90f, 0.00f, 0.42f, 0.00f }),
("line", new[] { -0.80f, -0.36f, 0.12f, -0.36f }),
("arc", new[] { 0.12f, -0.54f, 0.18f, -160.00f, 90.00f }),
("line", new[] { 0.72f, 0.70f, 0.72f, -0.70f }),
},
"cryofreeze" => new[]
{
("ring", new[] { 0.00f, 0.00f, 0.10f }),
("line", new[] { 0.00f, 0.20f, 0.00f, 0.80f }),
("line", new[] { 0.00f, 0.54f, 0.1732f, 0.64f }),
("line", new[] { 0.00f, 0.54f, -0.1732f, 0.64f }),
("line", new[] { -0.1732f, 0.10f, -0.6928f, 0.40f }),
("line", new[] { -0.4677f, 0.27f, -0.4677f, 0.47f }),
("line", new[] { -0.4677f, 0.27f, -0.6409f, 0.17f }),
("line", new[] { -0.1732f, -0.10f, -0.6928f, -0.40f }),
("line", new[] { -0.4677f, -0.27f, -0.6409f, -0.17f }),
("line", new[] { -0.4677f, -0.27f, -0.4677f, -0.47f }),
("line", new[] { 0.00f, -0.20f, 0.00f, -0.80f }),
("line", new[] { 0.00f, -0.54f, -0.1732f, -0.64f }),
("line", new[] { 0.00f, -0.54f, 0.1732f, -0.64f }),
("line", new[] { 0.1732f, -0.10f, 0.6928f, -0.40f }),
("line", new[] { 0.4677f, -0.27f, 0.4677f, -0.47f }),
("line", new[] { 0.4677f, -0.27f, 0.6409f, -0.17f }),
("line", new[] { 0.1732f, 0.10f, 0.6928f, 0.40f }),
("line", new[] { 0.4677f, 0.27f, 0.6409f, 0.17f }),
("line", new[] { 0.4677f, 0.27f, 0.4677f, 0.47f }),
},
_ => null,
};
/// <summary>Which way a platform's glyph's top points: north, as the clues are drawn.</summary>
static Vector3 MarkTop => new( 0f, 1f, 0f );
/// <summary>
/// A platform's glyph — where its dot must end — lies in the middle of its top, in the platform's colour, its top to the
/// north. 85 world units to a glyph unit: about 155u across and 110u tall, the size of the offering on tile 1
/// (<see cref="IconScale"/>), well inside the tile's 249u. It was 55u a unit, 75u south of the middle, while the ammo
/// icon still shared the top.
/// </summary>
const float MarkGlyphScale = 85f;
/// <summary>
/// What <see cref="BuildPlatformMarks"/> lays, and how: ⚠️ BUMP IT WHEN THAT CHANGES. 2: the glyph alone, in the middle,
/// the ammo icons gone to the hex clue (2026-09-26).
///
/// ⚠️ A HOTLOAD CAN KEEP THE MANAGER BUT NOT WHAT IT HELD. The array that held the icons went with them, so icons already
/// lying in a running game would have had nothing left to take them up. A manager that has not laid this layout yet
/// sweeps every mark in the scene first, once (<see cref="SweepMarks"/>) — and `OnUpdate` asks every frame, so that is
/// the frame after the hotload.
/// </summary>
const int MarksLayout = 2;
/// <summary>LOCAL — the layout this manager last laid its marks in; 0 before it has.</summary>
int _marksLaid;
/// <summary>Every platform mark carries it, so a sweep knows them.</summary>
const string MarkTag = "nz_hexmark";
/// <summary>LOCAL — each colour's platform's glyph: its object, which glyph (1-8), and the tile.</summary>
(GameObject Go, int Glyph, int Tile)[] _platformGlyphs;
/// <summary>⚠️ Made on first use, like <see cref="RingMoved"/>.</summary>
(GameObject Go, int Glyph, int Tile)[] PlatformGlyphs => _platformGlyphs ??= new (GameObject, int, int)[Colours];
/// <summary>
/// Each platform's glyph as things stand on this machine. LOCAL, and only on basalt: once Color Rings has begun, each
/// colour's platform — the tile lit in that colour — has its glyph in the middle of its top, in that colour. None before
/// the puzzle begins, or after a new game. A glyph is laid again only when it, or its tile, changes. The platform's
/// ammo icon is not here: it is on the hex clue (`HexClue.Draw`).
/// </summary>
void BuildPlatformMarks()
{
if ( _marksLaid != MarksLayout ) SweepMarks();
var glyphs = PlatformGlyphs;
for ( var c = 0; c < Colours; c++ )
{
var glyph = OnBasalt ? _ringsShown.GlyphOf( c ) : 0;
var tile = glyph == 0 ? -1 : LitTileOf( c );
if ( tile < 0 ) glyph = 0;
var had = glyphs[c];
if ( glyph == 0 ? !had.Go.IsValid() : had.Go.IsValid() && had.Glyph == glyph && had.Tile == tile ) continue;
if ( had.Go.IsValid() ) had.Go.Destroy();
glyphs[c] = default;
if ( glyph == 0 ) continue;
var t = Tiles[tile];
var go = Mark( $"Hex tile {t.Id} — glyph {glyph} ({ColourName( c )})",
IconModel( RingsClue.GlyphShapes( glyph ), RingsClue.GlyphStroke, c, MarkGlyphScale ),
new Vector3( t.X, t.Y, t.Top + IconLift ) );
glyphs[c] = (go, glyph, tile);
}
}
/// <summary>
/// Every platform mark in the scene gone, and none tracked: ours, and any a hotload left behind — known by their tag or,
/// from before it, by their names ("Hex tile 25 — the Dead Wire icon (blue)", "Hex tile 25 — glyph 3 (blue)"). Never
/// the offering, the bonfire or a panel. LOCAL.
/// </summary>
void SweepMarks()
{
_marksLaid = MarksLayout;
_platformGlyphs = null;
if ( !Scene.IsValid() ) return;
foreach ( var old in Scene.GetAllObjects( false )
.Where( x => x.Tags.Has( MarkTag )
|| (x.Tags.Has( PanelTag ) && (x.Name.Contains( " icon (" ) || x.Name.Contains( " — glyph " ))) )
.ToList() )
old.Destroy();
}
/// <summary>One mark laid flat on a platform's top, facing up, its top to the north — or null, with no model to show.</summary>
GameObject Mark( 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.Tags.Add( PanelTag );
go.Tags.Add( MarkTag );
go.WorldPosition = at;
go.WorldRotation = Rotation.LookAt( Vector3.Up, MarkTop );
var r = go.Components.Create<ModelRenderer>();
r.Model = model;
r.RenderType = ModelRenderer.ShadowRenderType.Off;
return go;
}
/// <summary>The tile lit in this colour on this machine, or -1 — once Color Smash is done, that colour's platform.</summary>
int LitTileOf( int colour )
{
for ( var i = 0; i < Tiles.Length && i < 64; i++ )
if ( _lit.Has( i ) && _lit.ColourOf( i ) == colour ) return i;
return -1;
}
/// <summary>The glyph lying on this colour's platform on this machine, 1-8, or 0. For the selftest.</summary>
int GlyphShown( int colour ) => PlatformGlyphs[colour].Go.IsValid() ? PlatformGlyphs[colour].Glyph : 0;
// ══ the ammo mods ═══════════════════════════════════════════════════════════════════════
/// <summary>
/// An ammo mod went off on a zombie standing here. THE SHOOTER'S MACHINE — `AmmoMods.Fire` for the rolled mods,
/// `BlastFurnace.OnZombieKilled` for its kill. Passes it to the host only when it could move a dot
/// (<see cref="CouldMove"/>), so the host is not told of every proc on the map.
/// </summary>
public static void AmmoModWentOff( string mod, Vector3 at )
{
if ( !OnBasalt || !Instance.IsValid() || !Instance.CouldMove( mod, at ) ) return;
// the shooter is this machine's own player — a mod only ever procs where it was fitted
NZNet.HexAmmoMod( mod, at, NZPlayers.NameOf( NZPlayer.Local ) );
}
/// <summary>
/// Could this move a dot, as THIS machine sees it: the rings begun, and `at` on a tile lit in a colour whose platform
/// wants this mod. EVERY machine — the lit tiles are MIRRORED, and once Color Smash is done they are the four platforms,
/// each in its colour. The host asks again of its own picks (<see cref="ModWentOff"/>).
/// </summary>
bool CouldMove( string mod, Vector3 at )
{
if ( !_ringsShown.Active || _ringsShown.Done || string.IsNullOrEmpty( mod ) ) return false;
var tile = TileAt( at, BodyEdgeSlack, BodyHeightSlack );
if ( tile < 0 || !_lit.Has( tile ) ) return false;
var colour = _lit.ColourOf( tile );
return colour >= 0 && colour < Colours && _ringsShown.ModOf( colour ) == mod;
}
/// <summary>A mod went off here. HOST — `NZNet.HexAmmoMod`. `who` only names the shooter in the log.</summary>
public static void HostAmmoMod( string mod, Vector3 at, string who = "" )
{
if ( NZGame.IsClient ) return;
var m = Instance;
if ( m.IsValid() ) m.ModWentOff( mod, at, who );
}
/// <summary>
/// A mod went off on a zombie standing here, by the rings' rules: on a platform that wants this mod, it moves the
/// platform's colour's dot one on. HOST — apart from the RPC, so the selftest can walk it.
///
/// ⚠️ THE TILE IT STANDS ON, NOT THE FIRST PICK NEAR IT. Two picks can touch, and their margins overlap: `TileAt` gives
/// the one of all 54 its feet are furthest into, the slam's rule.
/// </summary>
void ModWentOff( string mod, Vector3 at, string who = "" )
{
if ( !_rings.Active || _rings.Done || string.IsNullOrEmpty( mod ) ) return;
var tile = TileAt( at, BodyEdgeSlack, BodyHeightSlack );
var colour = tile < 0 ? -1 : PickColour( tile );
if ( colour < 0 || _rings.ModOf( colour ) != mod ) return;
if ( RingMoved[colour] < RingCooldown ) return;
RingMoved[colour] = 0f;
_rings = _rings.Moved( colour );
SendRings();
Log.Info( $"[nz-hex] {ModName( mod )} on the {ColourName( colour )} platform ({Tiles[tile].Id}){By( who )} — the"
+ $" {ColourName( colour )} dot moves to {_rings.PositionOf( colour )}"
+ ( _rings.PositionOf( colour ) == _rings.TargetOf( colour ) ? ", its platform's glyph" : "" ) );
if ( _rings.Solved ) RingsDone( who );
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_hex_rings [start|move <colour>|mods [<blue> <yellow> <green> <red>]|reset]` — HOST: where the dots
/// stand, and the mod each platform wants. To test:
/// - `start` begins the puzzle as Bonfire's end does;
/// - `move red` moves the red dot, as the red platform's own mod on it would;
/// - `mods` deals the platforms new mods at random, or these four, one per colour (ids: deadwire, fireworks,
/// radiation, blastfurnace, thunderwall, cryofreeze);
/// - `glyphs` deals the platforms new glyphs at random, or these four, 1-8, one per colour;
/// - `solve` puts every dot on its platform's glyph, and so finishes it, as the last right mod would;
/// - `reset` takes the dots, the icons and the glyphs off.
/// It prints the answer — no secret here, the clues and the platforms show it.
/// </summary>
[ConCmd( "nz_hex_rings" )]
public static void RingsCmd( string what = "", string arg1 = "", string arg2 = "", string arg3 = "", string arg4 = "" )
{
if ( NZGame.IsClient )
{
var shown = Instance.IsValid() ? Instance._ringsShown : default;
Log.Info( $"[nz-hex] Color Rings: {( shown.Active ? shown.Describe() : "not begun" )} (as the host last said)" );
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 "start":
m.StartRings();
if ( !m._done )
Log.Warning( "[nz-hex] Color Smash is not done, so no platform is lit in its colour and no mod can move a"
+ " dot — nz_hex_step done first" );
break;
case "move":
{
var k = ColourNumber( arg1 );
if ( k < 0 || k >= Colours ) { Log.Warning( "[nz-hex] nz_hex_rings move blue, yellow, green or red" ); return; }
if ( !m._rings.Active ) { Log.Warning( "[nz-hex] Color Rings has not begun — nz_hex_rings start" ); return; }
if ( m._rings.Done ) { Log.Warning( "[nz-hex] Color Rings is done — nothing moves now (nz_hex_rings reset)" ); return; }
m._rings = m._rings.Moved( k );
m.SendRings();
if ( m._rings.Solved ) m.RingsDone( "hand" );
break;
}
case "glyphs":
{
if ( !m._rings.Active ) { Log.Warning( "[nz-hex] Color Rings has not begun — nz_hex_rings start" ); return; }
if ( m._rings.Done ) { Log.Warning( "[nz-hex] Color Rings is done — nz_hex_rings reset first" ); return; }
var given = new[] { arg1, arg2, arg3, arg4 };
int[] targets;
if ( given.All( x => string.IsNullOrWhiteSpace( x ) ) )
targets = RingState.DealTargets();
else if ( given.All( x => int.TryParse( x, out var n ) && n >= 1 && n <= RingPositions ) )
targets = given.Select( x => int.Parse( x ) - 1 ).ToArray();
else
{
Log.Warning( "[nz-hex] nz_hex_rings glyphs <blue> <yellow> <green> <red> — four glyphs, 1-8, repeats allowed —"
+ " or nothing, to deal them at random" );
return;
}
m._rings = m._rings.WithTargets( targets );
m.SendRings();
if ( m._rings.Solved ) m.RingsDone( "hand" );
break;
}
case "solve":
{
if ( !m._rings.Active ) { Log.Warning( "[nz-hex] Color Rings has not begun — nz_hex_rings start" ); return; }
if ( m._rings.Done ) { Log.Warning( "[nz-hex] Color Rings is done already" ); break; }
m._rings = m._rings.WithPositions( Enumerable.Range( 0, Colours ).Select( c => m._rings.TargetOf( c ) ).ToArray() );
m.SendRings();
m.RingsDone( "hand" );
break;
}
case "mods":
{
if ( !m._rings.Active ) { Log.Warning( "[nz-hex] Color Rings has not begun — nz_hex_rings start" ); return; }
var given = new[] { arg1, arg2, arg3, arg4 }.Select( x => x.Trim().ToLowerInvariant() ).ToArray();
var pool = RingMods;
string[] ids;
if ( given.All( x => x == "" ) || (given[0] == "random" && given.Skip( 1 ).All( x => x == "" )) )
ids = RingState.DealMods();
else if ( given.All( x => pool.Contains( x ) ) && given.Distinct().Count() == Colours )
ids = given;
else
{
Log.Warning( "[nz-hex] nz_hex_rings mods <blue> <yellow> <green> <red> — four different of "
+ string.Join( ", ", pool ) + " — or nothing, to deal them at random" );
return;
}
m._rings = m._rings.WithMods( ids );
m.SendRings();
break;
}
case "reset":
m.ResetRings();
break;
default:
Log.Warning( "[nz-hex] nz_hex_rings start, move <colour>, mods [four mods], glyphs [four glyphs], solve or reset"
+ " — or nothing, to see where they stand" );
return;
}
Log.Info( $"[nz-hex] Color Rings: {( m._rings.Active ? m._rings.Describe() : "not begun" )}"
+ ( m._rings.Active && m._picked.Count >= Colours
? " · the platforms: " + string.Join( ", ", Enumerable.Range( 0, Colours )
.Select( k => $"{ColourName( k )} tile {Tiles[m._picked[k]].Id}" ) )
: "" ) );
}
}