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.
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();
}
}