Static utility that draws the in-game hex clue image (basalt_hex_tiles.png). It computes layout from HexPlatforms, renders tile faces, glows, outlines and optional ammo mod icons into a Bitmap, caches a Texture, provides helpers to read pixels and save a PNG via a console command.
using System;
using System.Linq;
using Sandbox;
namespace NZombies;
/// <summary>
/// BASALT SEAL 1'S CLUE, DRAWN LIVE: every hex tile the step can pick, top-down and north up — and once the power is on,
/// this round's four picks in their colours, drawn again at every reset. Asked for as *"when the power is turned on, 4
/// of those hexagons become colored accordingly … that image must be dynamic and change every round according to the
/// step reset"*.
///
/// ⚠️ A CLUE PLACED WITH `materials/clues/basalt_hex_tiles.png` IS DRAWN HERE INSTEAD. That PNG is what the Clue tool's
/// picker offers and what `Tools/basalt_hex_clue.py` draws; `CluePanel` sees a clue showing it and gives its panel this
/// picture instead. Placing the clue is all a mapper does — where is theirs to choose.
///
/// ⛔ THE SAME NUMBERS AS THE PYTHON TOOL, so the live clue and the picker's picture are one drawing: the tiles out of
/// `HexPlatforms.Tiles`, 2048 across with 220u of margin, each drawn at 0.86 of its size, a 5.5px outline over a halo
/// and a glow, the face faintly filled. Change one, change both.
///
/// ⛔ ONCE COLOR RINGS HAS BEGUN, EACH PLATFORM'S AMMO MOD IS SHOWN HERE, AND ONLY HERE: the mod's icon
/// (`HexPlatforms.ModIcon`) in the middle of the platform's tile, in the tile's colour. The platforms themselves show only
/// their glyphs. Asked for as *"the ammo mod icon only appears on the map … i mean as the ammo mod only appears in the clue
/// map we made"*, and before it *"the icons must be able to be any of the 4 colors to match the tile it's in"*.
///
/// ⚠️ LOCAL. Every machine draws its own from the clue set it was sent (`HexPlatforms.ClueState`) and the mods it was
/// dealt (`HexPlatforms.RingsShown`), and only when those change: a 2048px picture with three blurs is not a per-frame
/// thing.
/// </summary>
public static class HexClue
{
/// <summary>The image's file name, which is also all a mapper need type in the Clue tool's Image row.</summary>
public const string FileName = "basalt_hex_tiles.png";
/// <summary>The image a clue is placed with to be this one.</summary>
public const string ImagePath = "materials/clues/" + FileName;
/// <summary>Is this clue image the live hex map — its path, or its bare name as typed?</summary>
public static bool Is( string image )
{
if ( string.IsNullOrWhiteSpace( image ) ) return false;
var p = image.Trim().Replace( '\\', '/' ).TrimStart( '/' );
return p.Equals( ImagePath, StringComparison.OrdinalIgnoreCase ) || p.Equals( FileName, StringComparison.OrdinalIgnoreCase );
}
/// <summary>
/// Is the hex map gone — Color Rings done? Asked for as *"this map must disappear after the color ring step
/// completion"*: it has told what it had to tell. From the rings as this machine was told of them
/// (`HexPlatforms.RingsShown`), so it goes from every wall on every machine at once; a new game, which deals Color Rings
/// afresh, brings it back. `CluePanel` fades it out and in.
/// </summary>
public static bool Gone => HexPlatforms.Instance.IsValid() && HexPlatforms.Instance.RingsShown.Done;
// ══ the drawing — Tools/basalt_hex_clue.py's numbers ═════════════════════════════════════
const int Width = 2048;
const float Margin = 220f, Inset = 0.86f, Line = 5.5f;
const float GlowBlur = 9f, HaloBlur = 3f;
const float FillA = 0.10f, GlowA = 0.85f, HaloA = 0.90f;
/// <summary>
/// How strongly a pick's face is filled in its colour — here only, the Python tool draws no picks. Strong enough that
/// the tile reads as its colour from across a room, faint enough that its outline still shows as an outline.
/// </summary>
const float LitFillA = 0.55f;
/// <summary>
/// A platform's ammo icon on its tile (<see cref="Draw"/>): world units to one of the icon's units, so its -1..1 spans
/// 150u of the 214u the tile is drawn across; and its stroke, in the icon's units — bolder than the 0.085 it has
/// elsewhere, since a unit is only 32 pixels here: a stroke of 3.5, against the outlines' 5.5.
///
/// ⚠️ `Tools/basalt_hex_clue.py --deal` READS THESE and the three below, to draw the same.
/// </summary>
const float IconUnit = 75f, IconStroke = 0.11f;
/// <summary>
/// The icon's light, as a light strip's: its core the tile's colour taken this share of the way to white, over a glow
/// of the colour itself, this strong and this soft. In the colour as it is, it would be lost on the tile's face, which
/// is filled with it.
/// </summary>
const float IconWhiten = 0.55f, IconGlowA = 0.9f, IconGlowBlur = 2.5f;
/// <summary>World to picture, north up: the left edge, the top edge, pixels per unit, and the picture's size.</summary>
static (float MinX, float MaxY, float Scale, int W, int H) Layout()
{
var t = HexPlatforms.Tiles;
float minx = t.Min( x => x.X ) - 124f - Margin, maxx = t.Max( x => x.X ) + 124f + Margin;
float miny = t.Min( x => x.Y ) - 144f - Margin, maxy = t.Max( x => x.Y ) + 144f + Margin;
var scale = Width / (maxx - minx);
return (minx, maxy, scale, Width, (int)MathF.Round( (maxy - miny) * scale ));
}
/// <summary>A tile's centre, in the picture's pixels.</summary>
public static Vector2 PixelOf( int tile )
{
var (minx, maxy, scale, _, _) = Layout();
var t = HexPlatforms.Tiles[tile];
return new Vector2( (t.X - minx) * scale, (maxy - t.Y) * scale );
}
/// <summary>
/// The picture: every tile white, and each tile in <paramref name="shown"/> in its colour — and once Color Rings has
/// begun (<paramref name="rings"/>), on each tile in a platform's colour the icon of the mod that platform wants. The
/// caller disposes it.
///
/// ⚠️ FOUR LAYERS, LAID OVER EACH OTHER AS LIGHT OVER LIGHT — the face, a wide glow, a tight halo, the outline — which
/// is what the Python tool's 1 − Π(1 − a) is for white, and what drawing one over another does for any colour.
/// Each strength is in its pen's alpha BEFORE the blur, which comes to the same as scaling after it: a blur is linear.
/// The icons go over them all, in two more: their glow, then their core.
/// </summary>
public static Bitmap Draw( HexPlatforms.Lit shown, HexPlatforms.RingState rings = default )
{
var (minx, maxy, scale, w, h) = Layout();
var fill = Layer( w, h );
var glow = Layer( w, h );
var halo = Layer( w, h );
var line = Layer( w, h );
for ( var i = 0; i < HexPlatforms.Tiles.Length; i++ )
{
var t = HexPlatforms.Tiles[i];
var pts = HexSidePanels.Corners
.Select( c => new Vector2( (t.X + c.x * Inset - minx) * scale, (maxy - (t.Y + c.y * Inset)) * scale ) )
.ToArray();
var lit = shown.Has( i );
var c = lit ? HexPlatforms.HueOf( shown.ColourOf( i ) ) : Color.White;
fill.SetFill( c.WithAlpha( lit ? LitFillA : FillA ) );
fill.DrawPolygon( pts );
glow.SetPen( c.WithAlpha( GlowA ), Line );
glow.DrawPolygon( pts );
halo.SetPen( c.WithAlpha( HaloA ), Line );
halo.DrawPolygon( pts );
line.SetPen( c, Line );
line.DrawPolygon( pts );
}
// each platform's ammo icon, once Color Rings has dealt them: on the tile shown in that platform's colour
var iconGlow = Layer( w, h );
var icons = Layer( w, h );
for ( var i = 0; i < HexPlatforms.Tiles.Length; i++ )
{
var colour = shown.Has( i ) ? shown.ColourOf( i ) : -1;
var shapes = colour >= 0 && colour < HexPlatforms.Colours ? HexPlatforms.ModIcon( rings.ModOf( colour ) ) : null;
if ( shapes is null ) continue;
var at = PixelOf( i );
LightStrokes.Paint( iconGlow, shapes, at, IconUnit * scale, IconStroke, HexPlatforms.HueOf( colour ).WithAlpha( IconGlowA ) );
LightStrokes.Paint( icons, shapes, at, IconUnit * scale, IconStroke, IconCore( colour ) );
}
glow.Blur( GlowBlur );
halo.Blur( HaloBlur );
iconGlow.Blur( IconGlowBlur );
var pic = Layer( w, h );
var all = new Rect( 0, 0, w, h );
foreach ( var layer in new[] { fill, glow, halo, line, iconGlow, icons } )
{
pic.DrawBitmap( layer, all );
layer.Dispose();
}
return pic;
}
static Bitmap Layer( int w, int h )
{
var b = new Bitmap( w, h );
b.Clear( new Color( 0f, 0f, 0f, 0f ) );
b.SetAntialias( true );
return b;
}
/// <summary>
/// Is this tile's face drawn in this colour — white for <see cref="HexPlatforms.White"/>? `nz_hex_selftest`'s proof
/// that the picture shows what it was given. Read at the tile's centre, which only its face reaches: the glow off its
/// outline dies out well before it. The hue is compared, not the brightness, so it holds however the pixel is stored.
/// </summary>
public static bool Reads( Bitmap pic, int tile, int colour )
{
var p = PixelOf( tile );
var got = pic.GetPixel( (int)p.x, (int)p.y );
var want = colour == HexPlatforms.White ? Color.White : HexPlatforms.HueOf( colour );
if ( got.a < 0.03f ) return false;
float gm = MathF.Max( got.r, MathF.Max( got.g, got.b ) ), wm = MathF.Max( want.r, MathF.Max( want.g, want.b ) );
if ( gm <= 0f || wm <= 0f ) return false;
return MathF.Abs( got.r / gm - want.r / wm ) < 0.15f
&& MathF.Abs( got.g / gm - want.g / wm ) < 0.15f
&& MathF.Abs( got.b / gm - want.b / wm ) < 0.15f;
}
/// <summary>An icon's core on a tile of this colour: the colour taken <see cref="IconWhiten"/> of the way to white.</summary>
static Color IconCore( int colour )
{
var h = HexPlatforms.HueOf( colour );
return new Color( h.r + (1f - h.r) * IconWhiten, h.g + (1f - h.g) * IconWhiten, h.b + (1f - h.b) * IconWhiten, 1f );
}
/// <summary>How many of a core's pixels make an icon, to <see cref="IconOn"/>. Every icon has well over a hundred.</summary>
const int IconPixels = 30;
/// <summary>
/// Is an ammo icon drawn on this tile, its core this colour's? `nz_hex_selftest`'s proof. ⚠️ COUNTED, NOT READ AT ONE
/// POINT, since no one point lies on every icon: the solid pixels of that core within the icon's reach of the tile's
/// centre. An icon has hundreds; a bare face none, since nothing else on it is that pale, and another colour's core
/// none either — each core is 0.35 or more from every other in some channel, near three times the 0.12 allowed here.
/// </summary>
public static bool IconOn( Bitmap pic, int tile, int colour )
{
var (_, _, scale, w, h) = Layout();
var p = PixelOf( tile );
var want = IconCore( colour );
var reach = (int)MathF.Ceiling( IconUnit * scale );
var found = 0;
for ( var y = Math.Max( 0, (int)p.y - reach ); y <= Math.Min( h - 1, (int)p.y + reach ); y++ )
for ( var x = Math.Max( 0, (int)p.x - reach ); x <= Math.Min( w - 1, (int)p.x + reach ); x++ )
{
var q = pic.GetPixel( x, y );
if ( q.a > 0.9f && MathF.Abs( q.r - want.r ) < 0.12f && MathF.Abs( q.g - want.g ) < 0.12f
&& MathF.Abs( q.b - want.b ) < 0.12f )
found++;
}
return found >= IconPixels;
}
// ══ what the clue shows ═══════════════════════════════════════════════════════════════════
//
// ⚠️ A STATIC CACHE, AND LOCAL: one picture per machine, shared by every clue showing it — render resources, not
// state. A static's VALUE survives a hotload, and a stale picture is redrawn the next time it is asked for.
static Texture _texture;
static HexPlatforms.Lit _drawn;
static long _drawnMods;
static int _drawnTune = -1;
static int _drawnVersion;
/// <summary>
/// ⚠️ BUMP THIS WHEN THE DRAWING CHANGES. The cache above is static, and a static survives a hotload: without it the
/// old picture stays up until the picks change. 1: the platforms' ammo icons on their tiles (2026-09-26).
/// </summary>
const int DrawVersion = 1;
/// <summary>
/// The picture for what this machine's clue shows now — the tiles alone, or with the picks, and with the platforms'
/// icons once Color Rings has dealt them — drawn again only when that changes, or when a colour is retuned. Not when a
/// ring's dot moves: the map shows the mods, not the dots (`RingState.ModsShown`). Null if it cannot be drawn: the
/// panel then keeps the plain PNG.
/// </summary>
public static Texture Current()
{
var m = HexPlatforms.Instance;
var shown = m.IsValid() ? m.ClueState : default;
var rings = m.IsValid() ? m.RingsShown : default;
if ( _texture is not null && shown == _drawn && rings.ModsShown == _drawnMods && _drawnTune == HexPlatforms.TuneVersion
&& _drawnVersion == DrawVersion )
return _texture;
try
{
using var pic = Draw( shown, rings );
_texture = pic.ToTexture();
_drawn = shown;
_drawnMods = rings.ModsShown;
_drawnTune = HexPlatforms.TuneVersion;
_drawnVersion = DrawVersion;
}
catch ( Exception e )
{
// ⚠️ ONCE, NOT EVERY FRAME: the panel asks every frame, and a failure stays failed until something changes.
if ( _drawnTune != -2 ) Log.Warning( $"[nz-hex] the clue could not be drawn ({e.Message}) — it shows the plain map" );
_drawnTune = -2;
return null;
}
return _texture;
}
/// <summary>
/// `nz_hex_clue_png [demo]` — save the clue's picture as it stands, or with four tiles in their colours, each with an
/// ammo mod's icon as Color Rings deals them (`demo`), to look at without placing it. Needs no game: it only draws.
/// </summary>
[ConCmd( "nz_hex_clue_png" )]
public static void SavePng( string what = "" )
{
var m = HexPlatforms.Instance;
var shown = m.IsValid() ? m.ClueState : default;
var rings = m.IsValid() ? m.RingsShown : default;
if ( what.Trim().ToLowerInvariant() == "demo" )
{
shown = default;
var pool = Enumerable.Range( 0, HexPlatforms.Tiles.Length ).OrderBy( _ => Game.Random.Next() ).Take( HexPlatforms.Colours ).ToList();
for ( var k = 0; k < pool.Count; k++ ) shown = shown.With( pool[k], k );
rings = HexPlatforms.RingState.Of( 0, 0, 0, 0 ).WithMods( HexPlatforms.RingState.DealMods() );
}
using var pic = Draw( shown, rings );
const string file = "nz_hex_clue.png";
FileSystem.Data.WriteAllBytes( file, pic.ToPng() );
var lit = Enumerable.Range( 0, HexPlatforms.Tiles.Length ).Where( shown.Has )
.Select( i => $"{HexPlatforms.Tiles[i].Id} {HexPlatforms.ColourName( shown.ColourOf( i ) )}"
+ ( rings.ModOf( shown.ColourOf( i ) ) is { Length: > 0 } mod ? $" ({HexPlatforms.ModName( mod )})" : "" ) )
.ToList();
Log.Info( $"[nz-hex] the clue ({pic.Size.x}x{pic.Size.y}, {( lit.Count > 0 ? string.Join( ", ", lit ) : "no picks shown" )})"
+ $" -> {FileSystem.Data.GetFullPath( file )}" );
}
}