Part of the HexPlatforms component that implements the game logic for the map's "twin" shield column. It tracks host and mirrored state for whether the twin shield is down, builds/hides the column visuals, tests whether a player carrying the light-blue flame is close and looking at the column (to show a Use prompt), and handles the host-side logic and console command to break or reset the shield.
using System;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT — THE TWIN SHIELD (2026-09-26). The map's second cyan shield column stands on the west upper floor, 200u from
/// teleporter #0's far pad, on the line of the lava room's stepping stones and above its west end — the teleporter's other
/// end from the altar's column. Once the altar has held and the flame burns light blue — the shields' own colour — its
/// carrier takes it through the teleporter, and the twin shield takes the flame and falls: *"i can just carry it to the
/// other shield and interact with it to take it down, meaning we can place the next puzzle inside the other shield"*.
///
/// ⛔ LOOKED AT, THEN E: the carrier of the light blue flame, within reach of the column and looking at it, sees "Press E -
/// Take down the shield" (`UsePrompt.ForTwin`), and E brings it down (`NZPlayer.TickUse`). Nothing else does — not the
/// purple flame, not empty hands.
///
/// ⚠️ THE FLAME IS SPENT, A CHOICE: it goes into the shield, and is gone for the rest of the game. A new game raises the
/// shield again.
///
/// ⛔ ITS FALL BEGINS THE NEXT STEP, the Shrieker platform round the column (`HexPlatforms.Shriekers.cs`): only from here
/// does a Shrieker dying on it count.
///
/// ⛔ WHO OWNS WHAT (INSTRUCTIONS.md, "BUILD FOR MULTIPLAYER"):
/// - the PRESS is the presser's machine's: it asks the host (`NZNet.TwinShieldBreak`), which knows who from the call;
/// - WHETHER IT IS DOWN is HOST state, MIRRORED (`NZNet.TwinShieldState`) on every change and to a joiner;
/// - its FACES are LOCAL: every machine splits and hides its own copy (`HexPlatforms.Shields.cs`).
/// </summary>
public sealed partial class HexPlatforms
{
// ══ where it is ══════════════════════════════════════════════════════════════════════════
/// <summary>
/// The twin column's middle: its six faces of `COM_SHIELD002A` in the BSP, 120u across round (-4992, -288), their middles
/// at 1811 — read off the map on 2026-09-26.
/// </summary>
static Vector3 TwinCentre => new( -4992f, -288f, 1811f );
/// <summary>How far a carrier's feet may be from the column's axis to bring it down: its faces are 60u out, and a step past.</summary>
const float TwinReach = 150f;
/// <summary>How near its axis a look must pass, and how far up or down from its middle: the column itself, more or less.</summary>
const float TwinLookRadius = 62f, TwinLookHalfHeight = 120f;
// ══ the state ════════════════════════════════════════════════════════════════════════════
/// <summary>HOST — is the twin shield down: the light blue flame gone into it?</summary>
bool _twinOpen;
/// <summary>MIRROR — the same, as this machine was told (`NZNet.TwinShieldState`).</summary>
bool _twinOpenShown;
/// <summary>HOST — whether it is down, for `NZNet.PushState` to replay to a joiner.</summary>
public static bool TwinOpenState => Instance.IsValid() && Instance._twinOpen;
void SendTwin() => NZNet.TwinShieldState( _twinOpen );
/// <summary>Down, or standing. EVERY machine — `NZNet.TwinShieldState`: the shield dressed to match, and the flame gone with it.</summary>
public void ApplyTwin( bool open )
{
_twinOpenShown = open;
BuildTwin();
BuildRewardTile();
}
/// <summary>The twin shield as this machine was told: down or standing. LOCAL, and only basalt's.</summary>
void BuildTwin()
{
if ( !OnBasalt ) { ClearTwin(); return; }
SetColumnHidden( TwinColumn, _twinOpenShown );
}
/// <summary>The twin shield back as the map has it, off basalt. LOCAL.</summary>
void ClearTwin() => SetColumnHidden( TwinColumn, false );
/// <summary>A new game: the twin shield back up. HOST — `RoundBegan( 1 )`.</summary>
void ResetTwin()
{
// the Shrieker platform follows the twin shield, and goes with it
ResetShriekers();
if ( !_twinOpen ) return;
_twinOpen = false;
SendTwin();
}
// ══ bringing it down ═════════════════════════════════════════════════════════════════════
/// <summary>
/// Is this player carrying the light blue flame, within reach of the twin column and looking at it? LOCAL — the prompt's
/// test and the use key's, one question for both (`UsePrompt.ForTwin`, `NZPlayer.TickUse`).
/// </summary>
public static bool TwinAimed( NZPlayer player )
{
var m = Instance;
if ( !m.IsValid() || !OnBasalt || m._twinOpenShown || CannotCarry( player ) ) return false;
if ( !m.FlameCarried || !m.FlameBlueShown || m._flameCarrierShown != CarrierIdOf( player ) ) return false;
if ( FlatDistance( player.WorldPosition, TwinCentre ) > TwinReach ) return false;
var cam = m.Scene.Camera;
return cam.IsValid()
&& RayMeetsColumn( cam.WorldPosition, cam.WorldRotation.Forward, TwinCentre, TwinLookRadius, TwinLookHalfHeight );
}
/// <summary>How far apart two points are across the ground, not counting height.</summary>
static float FlatDistance( Vector3 a, Vector3 b ) => MathF.Sqrt( (a.x - b.x) * (a.x - b.x) + (a.y - b.y) * (a.y - b.y) );
/// <summary>
/// Does a ray from here, this way (a unit direction), enter an upright cylinder round this middle — this radius round
/// its axis, this far up and down — ahead of it? Where it first crosses the circle, seen from above; its height there.
/// </summary>
static bool RayMeetsColumn( Vector3 from, Vector3 dir, Vector3 centre, float radius, float halfHeight )
{
float ox = from.x - centre.x, oy = from.y - centre.y;
float a = dir.x * dir.x + dir.y * dir.y;
if ( a < 1e-6f ) return false; // straight up or down: not at a column beside you
float b = 2f * (ox * dir.x + oy * dir.y);
float c = ox * ox + oy * oy - radius * radius;
var disc = b * b - 4f * a * c;
if ( disc < 0f ) return false;
var root = MathF.Sqrt( disc );
var t = (-b - root) / (2f * a);
if ( t < 0f ) t = (-b + root) / (2f * a); // inside the circle already: where it leaves
if ( t < 0f ) return false;
return MathF.Abs( from.z + dir.z * t - centre.z ) <= halfHeight;
}
/// <summary>E on the twin shield. LOCAL — the presser's machine asks the host, which decides (<see cref="HostBreakTwin"/>).</summary>
public static void TakeDownTwin( NZPlayer player )
{
if ( player.IsValid() ) NZNet.TwinShieldBreak();
}
/// <summary>
/// Someone pressed E on the twin shield. HOST — `NZNet.TwinShieldBreak`, with `who` the caller's connection id as the
/// call itself carries it, or "" for the host's own press.
/// </summary>
public static void HostBreakTwin( 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.BreakTwin( body );
if ( refused != "" ) Log.Info( $"[nz-hex] the twin shield stands: {refused}" );
}
/// <summary>
/// The rules for bringing it down. HOST — apart from the RPC, so the selftest can walk it. It must still stand, the
/// flame be light blue — the altar held — and the one at it its carrier, up and within reach (`anywhere` skips the
/// reach, for the test and `nz_hex_twin`). The flame is spent, the shield falls, and the step-done clicking plays.
/// Returns why not, or "".
/// </summary>
string BreakTwin( NZPlayer body, bool anywhere = false )
{
if ( _twinOpen ) return "it is down already";
if ( _defense != Defense.Won ) return "the flame is not light blue — the altar has not held";
if ( string.IsNullOrEmpty( _flameCarrier ) || CarrierIdOf( body ) != _flameCarrier ) return "they do not carry the flame";
if ( CannotCarry( body ) ) return "they are down, out or gone";
if ( !anywhere && FlatDistance( body.WorldPosition, TwinCentre ) > TwinReach + ReachSlack ) return "they are too far from it";
_flameCarrier = "";
_twinOpen = true;
SendTwin();
SendFlame();
Cue( DoneCue );
Fanfare( 8 );
Log.Info( $"[nz-hex] ✦ THE TWIN SHIELD FALLS — {NameFor( body )} held the light blue flame to it. The flame is spent, and the"
+ $" column stands open. Now {ShriekersTarget} Shriekers killed on its platform" );
return "";
}
// ══ commands ════════════════════════════════════════════════════════════════════════════
/// <summary>
/// `nz_hex_twin [open|close]` — HOST: the twin shield as it stands. `open` brings it down by hand — the flame spent, if
/// there was one — and `close` puts it back up. By the rules, the light blue flame's carrier brings it down with E.
/// </summary>
[ConCmd( "nz_hex_twin" )]
public static void TwinCmd( string what = "" )
{
if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] host only" ); return; }
var m = Ensure();
if ( !m.IsValid() ) { Log.Warning( "[nz-hex] no game running — start one first" ); return; }
switch ( what.Trim().ToLowerInvariant() )
{
case "":
break;
case "open":
m._flameCarrier = "";
m._twinOpen = true;
m.SendTwin();
m.SendFlame();
break;
case "close":
m._twinOpen = false;
m.SendTwin();
m.ResetShriekers();
break;
default:
Log.Warning( "[nz-hex] nz_hex_twin open or close — or nothing, to see where it stands" );
return;
}
Log.Info( $"[nz-hex] the twin shield is {( m._twinOpen ? "DOWN — the light blue flame spent in it" : "up" )}"
+ ( OnBasalt ? $" · it {( m.ColumnStands( m.TwinColumn ) ? "stands" : "is down, or not found" )} here, round {TwinCentre}" : " · it is basalt's" ) );
}
}