EasterEgg/HexPlatforms.Twin.cs

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.

NetworkingFile Access
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" ) );
	}
}