EasterEgg/HexSidePanels.cs

Utility that builds and displays preview light panels along the six sides of hex map tiles. It can create a temporary scene object with meshes for every available tile side, either white or coloured, and contains a Build method that constructs the mesh geometry and material.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace NZombies;

/// <summary>
/// Light panels over a hex tile's six sides — what a lit seal-1 platform looks like (see <see cref="HexPlatforms"/>,
/// which lights one tile at a time through <see cref="Build"/>): white, or a pick's colour.
///
/// ⛔ THE TILES ARE MAP GEOMETRY, NOT OBJECTS. A tile's top is one polygon inside one "Mesh N" chunk and its six
/// sides are spread over other chunks, shared with walls and floors. The map only puts `lights/white001` on a
/// side where two lava-room tiles touch; every other side is concrete. There is nothing to recolour, so these
/// are our own quads laid just proud of the map's faces, in the map's own light material — or a tinted copy of it
/// (<see cref="HexPlatforms.MaterialFor"/>).
///
/// `nz_hexsides` is a DEBUG PREVIEW: every available tile lit at once, to check the panels fit before a round
/// picks any — white, or with `nz_hexsides 2` the four colours in turn, to judge them side by side. Tiles are not
/// lit by default — only a slam on one of the round's picks lights one.
/// </summary>
public static class HexSidePanels
{
	const string RootName = "nz_hexsides preview";

	/// <summary>
	/// Every tile is the same hexagon: points on ±Y 144u out, flats 124u out (248u across). Corners
	/// counter-clockwise from the north point. Public for `HexClue`, which draws the same hexagon.
	/// </summary>
	public static readonly Vector2[] Corners =
	{
		new( 0, 144 ), new( -124, 72 ), new( -124, -72 ), new( 0, -144 ), new( 124, -72 ), new( 124, 72 ),
	};

	/// <summary>
	/// How far out from the map's own face a panel sits.
	///
	/// ⚠️ NOT ZERO. white001 is unlit, so a panel coplanar with the concrete face would z-fight and flicker
	/// between glowing white and grey. Half a unit clears it and cannot be seen.
	/// </summary>
	const float Proud = 0.5f;

	/// <summary>
	/// `nz_hexsides [1|2|0]` — preview every available tile lit: 1 white, 2 in the four colours in turn (tile by tile,
	/// blue, yellow, green, red), 0 off; no argument toggles white.
	/// </summary>
	[ConCmd( "nz_hexsides" )]
	public static void Toggle( int on = -1 )
	{
		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) { Log.Warning( "[nz-hexsides] no active scene" ); return; }

		var existing = scene.GetAllObjects( false ).Where( g => g.Name == RootName ).ToList();
		bool build = on < 0 ? existing.Count == 0 : on != 0;

		foreach ( var go in existing ) go.Destroy();
		if ( !build )
		{
			Log.Info( $"[nz-hexsides] preview removed ({existing.Count} set(s))" );
			return;
		}

		// ⚠️ A WARNING, NOT A REFUSAL. On another map the panels simply hang in the air, which says plainly what
		// happened; a hard check against a map key could refuse on the very map it is for.
		if ( !HexPlatforms.OnBasalt )
			Log.Warning( $"[nz-hexsides] map is '{NZMap.Current}' — these panels sit at ttt_basalt's tile positions" );

		var root = scene.CreateObject();
		root.Name = RootName;
		root.Flags |= GameObjectFlags.NotSaved;
		root.NetworkMode = NetworkMode.Never;   // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)

		// 1: every tile white, one mesh. 2: tile k in colour k mod 4, one mesh per colour.
		var colours = on == 2 ? HexPlatforms.Colours : 1;

		for ( var c = 0; c < colours; c++ )
		{
			var colour = c;                     // a `for` variable is one variable for every pass; the lambda keeps its own
			var tiles = on == 2 ? HexPlatforms.Tiles.Where( ( _, k ) => k % colours == colour ) : HexPlatforms.Tiles;
			var model = Build( tiles, on == 2 ? HexPlatforms.MaterialFor( c ) : null );
			if ( model is null ) continue;

			var go = colours == 1 ? root : scene.CreateObject();
			if ( go != root )
			{
				go.Name = $"{RootName} ({HexPlatforms.ColourName( c )})";
				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 );
			}

			var r = go.Components.Create<ModelRenderer>();
			r.Model = model;
			r.RenderType = ModelRenderer.ShadowRenderType.Off;
		}

		Log.Info( on == 2
			? $"[nz-hexsides] PREVIEW: all {HexPlatforms.Tiles.Length} available tiles lit in the four colours in turn"
				+ " (blue, yellow, green, red) — nz_hex_colour retunes them, nz_hexsides 0 removes"
			: $"[nz-hexsides] PREVIEW: all {HexPlatforms.Tiles.Length} available tiles lit — nz_hexsides again to remove" );
	}

	/// <summary>
	/// One mesh with a light quad over every side of every tile given, in <paramref name="material"/> — the map's own
	/// white001 when none is given. Null if the material is missing.
	///
	/// ⚠️ <paramref name="uvUnits"/> IS FOR A SURFACE, NOT A LIGHT. A light's texture is flat white, so each panel runs 0-1
	/// across (the default). A surface — tile 1's plain concrete — needs its texture laid the way the map lays its own:
	/// world-aligned, u along the side and v down the height, one repeat every <paramref name="uvUnits"/> units.
	///
	/// <paramref name="corners"/> is the hexagon when it is not a tile's (<see cref="Corners"/>): the Shrieker platform's
	/// inner step (`HexPlatforms.Shriekers.cs`). Counter-clockwise, as those run.
	/// </summary>
	public static Model Build( IEnumerable<HexPlatforms.Tile> tiles, Material material = null, float uvUnits = 0f,
		Vector2[] corners = null )
	{
		corners ??= Corners;
		material ??= Material.Load( HexPlatforms.LightMaterial );
		if ( material is null ) { Log.Warning( $"[nz-hexsides] {HexPlatforms.LightMaterial} not found" ); return null; }

		var verts = new List<Vertex>();
		var idx = new List<int>();
		var bmin = new Vector3( float.MaxValue );
		var bmax = new Vector3( float.MinValue );

		// Each panel is pushed out along its own normal, which on a hexagon opens a sliver at every corner.
		// Lengthening both ends by Proud·tan(30°) closes it exactly: that is where two offset sides meet at a
		// 120° corner.
		float mitre = Proud * MathF.Tan( MathF.PI / 6f );

		foreach ( var tile in tiles )
		{
			var c = new Vector2( tile.X, tile.Y );
			float lo = tile.Bottom, hi = tile.Top;

			for ( int i = 0; i < corners.Length; i++ )
			{
				var a = c + corners[i];
				var b = c + corners[(i + 1) % corners.Length];
				var d = b - a;
				var along = d * (1f / d.Length);

				// Outward is the edge turned right, the counter-clockwise rule DebrisMesh.AddWalls uses.
				var out2 = new Vector2( along.y, -along.x );
				var pa = a + out2 * Proud - along * mitre;
				var pb = b + out2 * Proud + along * mitre;

				var n = new Vector3( out2.x, out2.y, 0f );
				var t = new Vector3( along.x, along.y, 0f );
				int s = verts.Count;

				float u0 = 0f, u1 = 1f, vLo = 1f, vHi = 0f;
				if ( uvUnits > 0f )
				{
					u0 = (pa.x * along.x + pa.y * along.y) / uvUnits;
					u1 = (pb.x * along.x + pb.y * along.y) / uvUnits;
					vLo = -lo / uvUnits;
					vHi = -hi / uvUnits;
				}

				verts.Add( new Vertex( new Vector3( pa.x, pa.y, lo ), n, t, new Vector4( u0, vLo, 0f, 0f ) ) );
				verts.Add( new Vertex( new Vector3( pb.x, pb.y, lo ), n, t, new Vector4( u1, vLo, 0f, 0f ) ) );
				verts.Add( new Vertex( new Vector3( pb.x, pb.y, hi ), n, t, new Vector4( u1, vHi, 0f, 0f ) ) );
				verts.Add( new Vertex( new Vector3( pa.x, pa.y, hi ), n, t, new Vector4( u0, vHi, 0f, 0f ) ) );

				// Same winding as DebrisMesh.AddWalls, which renders facing outward.
				idx.Add( s + 0 ); idx.Add( s + 1 ); idx.Add( s + 2 );
				idx.Add( s + 0 ); idx.Add( s + 2 ); idx.Add( s + 3 );

				bmin = Vector3.Min( bmin, Vector3.Min( new Vector3( pa.x, pa.y, lo ), new Vector3( pb.x, pb.y, lo ) ) );
				bmax = Vector3.Max( bmax, Vector3.Max( new Vector3( pa.x, pa.y, hi ), new Vector3( pb.x, pb.y, hi ) ) );
			}
		}

		if ( verts.Count == 0 ) return null;

		var bounds = new BBox( bmin, bmax );

		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 and is frustum-culled
		// against a point at the origin — see DebrisMesh.Build for the full story.
		mesh.Bounds = bounds;

		return Model.Builder.AddMesh( mesh ).WithViewBounds( bounds ).Create();
	}
}