EasterEgg/LightStrokes.cs

Generates flat light-stroke geometry and can paint the same shapes into a bitmap. Builds polygons, rounded-line segments, rings, arcs and dots into vertex/index buffers and produces a runtime Model with proper bounds; also has a static Paint helper to rasterise the shapes into a Bitmap.

Native InteropFile Access
using System;
using System.Collections.Generic;
using Sandbox;

namespace NZombies;

/// <summary>
/// Flat strokes of light in one plane: the marks basalt's easter egg draws on its walls and floors. The hex slots'
/// hexagons and numerals (`HexSlotManager`), the napalm icon on tile 1 and the platforms' glyphs (`HexPlatforms`) are
/// drawn with it. Lines with round ends and bends, rings, arcs and dots — the ammo symbols' stroke family
/// (`Tools/basalt_hex_symbols.py`) — built as one mesh in one material: the map's light, or a colour's tinted copy of it,
/// so they glow as the light strips do. <see cref="Paint"/> lays the same shapes into a picture instead: the rings clue's
/// glyphs, and the hex clue's ammo icons.
///
/// ⚠️ THE PLANE IS THE MESH'S LOCAL Y-Z, FACING +X. u runs along local +Y and v along +Z. So a GameObject turned to face out
/// of a surface — its Forward the surface's normal — shows the drawing the right way round to whoever faces it: its Left
/// is their right.
///
/// ⚠️ ROUND ENDS AND BENDS ARE DISCS LAID OVER THE SEGMENTS. The light material is opaque, so where two pieces overlap
/// nothing shows twice.
/// </summary>
public class LightStrokes
{
	readonly List<Vertex> _verts = new();
	readonly List<int> _idx = new();
	float _u0 = float.MaxValue, _u1 = float.MinValue, _v0 = float.MaxValue, _v1 = float.MinValue;

	/// <summary>How many sides a disc is drawn with; a ring or an arc is cut twice as fine.</summary>
	const int Round = 20;

	/// <summary>A convex polygon.</summary>
	public void Poly( params Vector2[] p )
	{
		// ⚠️ WOUND COUNTER-CLOCKWISE AS WHOEVER FACES IT SEES IT, whichever way the points arrive. That is the winding that
		// renders facing out (`HexSidePanels.Build`, `DebrisMesh.AddWalls`), so it is fixed here rather than trusted at
		// every call.
		var area = 0f;
		for ( var i = 0; i < p.Length; i++ )
		{
			var a = p[i];
			var b = p[(i + 1) % p.Length];
			area += a.x * b.y - b.x * a.y;
		}
		if ( area < 0f ) Array.Reverse( p );

		var s = _verts.Count;
		foreach ( var q in p )
		{
			_verts.Add( new Vertex( new Vector3( 0f, q.x, q.y ), Vector3.Forward, Vector3.Left, new Vector4( 0.5f, 0.5f, 0f, 0f ) ) );
			_u0 = MathF.Min( _u0, q.x ); _u1 = MathF.Max( _u1, q.x );
			_v0 = MathF.Min( _v0, q.y ); _v1 = MathF.Max( _v1, q.y );
		}

		for ( var i = 1; i + 1 < p.Length; i++ )
		{
			_idx.Add( s ); _idx.Add( s + i ); _idx.Add( s + i + 1 );
		}
	}

	/// <summary>A filled disc.</summary>
	public void Dot( Vector2 c, float r )
	{
		var p = new Vector2[Round];
		for ( var i = 0; i < Round; i++ ) p[i] = c + Dir( i * 360f / Round ) * r;
		Poly( p );
	}

	/// <summary>A line through these points, <paramref name="w"/> wide, round at its ends and at every bend.</summary>
	public void Line( IReadOnlyList<Vector2> pts, float w )
	{
		for ( var i = 0; i + 1 < pts.Count; i++ )
		{
			var a = pts[i];
			var d = pts[i + 1] - a;
			var len = d.Length;
			if ( len < 1e-4f ) continue;

			var n = new Vector2( -d.y, d.x ) * (w / 2f / len);
			Poly( a - n, pts[i + 1] - n, pts[i + 1] + n, a + n );
		}

		foreach ( var q in pts ) Dot( q, w / 2f );
	}

	/// <summary>A ring, <paramref name="w"/> wide, centred on the radius <paramref name="r"/>.</summary>
	public void Ring( Vector2 c, float r, float w ) => Band( c, r, w, 0f, 360f, Round * 2 );

	/// <summary>
	/// An arc of the circle at <paramref name="c"/>, from <paramref name="a0"/> to <paramref name="a1"/> degrees —
	/// counter-clockwise, 0 along +u — <paramref name="w"/> wide and round at its ends.
	/// </summary>
	public void Arc( Vector2 c, float r, float a0, float a1, float w )
	{
		Band( c, r, w, a0, a1, Math.Max( 2, (int)MathF.Ceiling( MathF.Abs( a1 - a0 ) / (180f / Round) ) ) );
		Dot( c + Dir( a0 ) * r, w / 2f );
		Dot( c + Dir( a1 ) * r, w / 2f );
	}

	/// <summary>The band between r − w/2 and r + w/2, from a0 to a1 degrees, in this many pieces.</summary>
	void Band( Vector2 c, float r, float w, float a0, float a1, int pieces )
	{
		float inner = r - w / 2f, outer = r + w / 2f;
		for ( var i = 0; i < pieces; i++ )
		{
			var da = a0 + (a1 - a0) * i / pieces;
			var db = a0 + (a1 - a0) * (i + 1) / pieces;
			Poly( c + Dir( da ) * outer, c + Dir( db ) * outer, c + Dir( db ) * inner, c + Dir( da ) * inner );
		}
	}

	static Vector2 Dir( float deg )
	{
		var a = deg * MathF.PI / 180f;
		return new Vector2( MathF.Cos( a ), MathF.Sin( a ) );
	}

	// ══ the same strokes, painted into a picture ════════════════════════════════════════════

	/// <summary>
	/// The stroke family's shapes — ("line", x0 y0 x1 y1 …), ("ring", x y r), ("arc", x y r a0 a1), ("dot", x y r) — painted
	/// into a picture round <paramref name="centre"/>: <paramref name="unit"/> pixels to one of the shapes' units,
	/// <paramref name="stroke"/> wide in those units, in one colour. Lines are round at their ends and bends and arcs cut
	/// as fine, as the mesh builds them. `RingsClue` paints its glyphs with it and `HexClue` its ammo icons.
	///
	/// ⚠️ THE SHAPES' Y RUNS UP AND THE PICTURE'S DOWN: every y is turned over here, so they come out upright.
	/// </summary>
	public static void Paint( Bitmap b, (string Kind, float[] P)[] shapes, Vector2 centre, float unit, float stroke, Color colour )
	{
		if ( shapes is null ) return;

		var w = stroke * unit;
		Vector2 At( float x, float y ) => new( centre.x + x * unit, centre.y - y * unit );

		foreach ( var (kind, p) in shapes )
		{
			switch ( kind )
			{
				case "line":
					b.SetFill( colour );
					for ( var i = 0; i + 3 < p.Length; i += 2 )
						PaintStroke( b, At( p[i], p[i + 1] ), At( p[i + 2], p[i + 3] ), w );
					break;

				case "ring":
				{
					var c = At( p[0], p[1] );
					b.SetPen( colour, w );
					b.DrawCircle( c.x, c.y, p[2] * unit );
					break;
				}

				case "arc":
				{
					float a0 = p[3], a1 = p[4];
					var pieces = Math.Max( 2, (int)MathF.Ceiling( MathF.Abs( a1 - a0 ) / (180f / Round) ) );
					Vector2 On( float deg ) { var d = Dir( deg ); return At( p[0] + p[2] * d.x, p[1] + p[2] * d.y ); }

					b.SetFill( colour );
					var from = On( a0 );
					for ( var i = 1; i <= pieces; i++ )
					{
						var to = On( a0 + (a1 - a0) * i / pieces );
						PaintStroke( b, from, to, w );
						from = to;
					}
					break;
				}

				case "dot":
				{
					var c = At( p[0], p[1] );
					b.SetFill( colour );
					b.DrawCircle( c.x, c.y, p[2] * unit );
					break;
				}
			}
		}
	}

	/// <summary>A straight stroke <paramref name="w"/> wide, round at both ends, in the fill last set.</summary>
	static void PaintStroke( Bitmap b, Vector2 a, Vector2 z, float w )
	{
		var d = z - a;
		var len = d.Length;
		if ( len < 0.01f ) return;

		var n = new Vector2( -d.y, d.x ) * (w / 2f / len);
		b.DrawPolygon( new[] { a - n, z - n, z + n, a + n } );
		b.DrawCircle( a.x, a.y, w / 2f );
		b.DrawCircle( z.x, z.y, w / 2f );
	}

	/// <summary>Everything drawn so far, as one model in <paramref name="material"/>. Null if nothing was drawn.</summary>
	public Model Build( Material material )
	{
		if ( _verts.Count == 0 ) return null;

		// ⛔ SET BY HAND, AFTER THE BUFFERS. A runtime mesh's bounds are empty otherwise, and it is culled against a point
		// (`DebrisMesh.Build`, `HexSidePanels.Build`). A unit either side of the plane, so the box is never flat.
		var bounds = new BBox( new Vector3( -1f, _u0, _v0 ), new Vector3( 1f, _u1, _v1 ) );

		var mesh = new Mesh( material );
		mesh.CreateVertexBuffer( _verts.Count, _verts );
		mesh.CreateIndexBuffer( _idx.Count, _idx );
		mesh.Bounds = bounds;

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