EasterEgg/RingsClue.cs

Renders and manages the Color Rings clue image and related live clue utilities. Draws a 1024x1024 image of four coloured concentric rings with eight glyphs around them, computes positions, converts to Texture, caches it, provides commands to save PNG, place/scale the clue on a wall from a wallbuy, photograph it, and helpers for hit-testing dots/glyphs and finding flat surfaces on walls.

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

namespace NZombies;

/// <summary>
/// COLOR RINGS' CLUE (BASALT STEP 3), DRAWN LIVE: four rings, one per colour — blue outermost, then red, green and yellow — each with a
/// dot of its colour on it, at one of 8 positions an eighth of a turn apart. Asked for as *"another dynamic image … each
/// circle can move around in the line its on, each one has 8 positions, all at equal distance"*, from the user's own
/// sketch. The dots are where the puzzle stands (<see cref="HexPlatforms.RingsShown"/>): an ammo mod triggered over a
/// Color Smash tile moves its colour's dot one position on.
///
/// ⛔ A CLOCK, WITH GLYPHS FOR NUMBERS: the eight glyphs (<see cref="GlyphStrokes"/>) stand round the rings, one at each
/// position — glyph 1 at the top, then clockwise — each upright. Asked for as *"one per position in the rings, like a
/// clock but instead of numbers it's these"*. The rings were drawn in, from 0.92 of the half-width to 0.64, to make room.
///
/// ⚠️ A CLUE PLACED WITH `materials/clues/basalt_rings.png` IS DRAWN HERE INSTEAD, the way `HexClue` draws the hex map:
/// placing the clue is all a mapper does. Until the puzzle begins — Bonfire done — it shows the rings and glyphs alone.
///
/// ⛔ THE SAME NUMBERS AS `Tools/basalt_rings_clue.py`, which draws the PNG a clue shows if this cannot draw: 1024 across,
/// the rings at 0.64, 0.50, 0.36 and 0.22 of the half-width, 7px lines over a halo and a glow, 22px dots; the glyphs at
/// 0.84, 70px to a glyph unit. Change one, change both. The glyphs' own shapes the tools READ from here.
///
/// ⚠️ LOCAL. Every machine draws its own from the positions it was sent, and only when they change.
/// </summary>
public static class RingsClue
{
	/// <summary>The image's file name — all a mapper need type in the Clue tool's Image row.</summary>
	public const string FileName = "basalt_rings.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 rings — 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 );
	}

	// ══ the drawing — Tools/basalt_rings_clue.py's numbers ═══════════════════════════════════

	const int Size = 1024;
	const float Line = 7f, GlowBlur = 10f, HaloBlur = 3f, GlowA = 0.85f, HaloA = 0.9f;

	/// <summary>A position's mark on its ring, and the dot that stands at one.</summary>
	const float TickR = 5f, TickA = 0.55f, DotR = 22f;

	/// <summary>
	/// The rings from the outside in, by colour number (blue, red, green, yellow), and each one's radius as a share of the
	/// half-width — drawn in from 0.92, 0.73, 0.54, 0.35 to make room for the glyphs round them.
	///
	/// ⚠️ A PROPERTY, NOT A STATIC ARRAY: a static's initialiser does not run again on a hotload (INSTRUCTIONS.md §1), and
	/// this one did change.
	/// </summary>
	static (int Colour, float Radius)[] Rings => new[] { (0, 0.64f), (3, 0.50f), (2, 0.36f), (1, 0.22f) };

	// ══ the glyphs — a clock's numbers ══════════════════════════════════════════════════════════
	//
	// ⛔ EIGHT GLYPHS THAT MEAN NOTHING, ONE FAMILY, EACH UNLIKE THE OTHERS: a bar across the middle, two or three short
	// strokes off points along it (its ends, its quarters, its middle), straight or leaning at 60°, and one dot or small
	// ring. Asked for as *"8 symbols, none of them should mean anything, they should be distinguishable enough, but still
	// all similar, and be quite cryptic"*. `Tools/basalt_glyphs.py` says how they were chosen, checks that no two are
	// alike however they are turned or mirrored, and draws them as pictures — reading them from here.

	/// <summary>How far out the glyphs stand, as a share of the half-width, and how many pixels one glyph unit is.</summary>
	const float GlyphR = 0.84f, GlyphUnit = 70f;

	/// <summary>In glyph units: the bar's half-length, a stroke's length, a dot's radius and a ring's.</summary>
	const float GlyphBar = 0.62f, GlyphLen = 0.5f, GlyphDot = 0.085f, GlyphRing = 0.11f;

	/// <summary>How wide a glyph's strokes are, in glyph units — here, and on the platforms (`HexPlatforms.BuildPlatformMarks`).</summary>
	public const float GlyphStroke = 0.085f;

	/// <summary>
	/// A glyph's strokes, 1-8: each off a point on the bar (x, in glyph units, the bar running -0.62..0.62), at an angle
	/// in degrees (0 along the bar to the right, 90 straight up). ⚠️ `Tools/basalt_glyphs.py` READS THESE LINES: keep each
	/// glyph on one line, as `k => new[] { (x, deg), … },`.
	/// </summary>
	static (float X, float Deg)[] GlyphStrokes( int glyph ) => glyph switch
	{
		1 => new[] { (0.31f, 90f), (-0.62f, -120f), (0.62f, 90f) },
		2 => new[] { (-0.31f, 60f), (0.31f, -60f) },
		3 => new[] { (0.31f, 120f), (0.62f, -60f), (0.00f, 60f) },
		4 => new[] { (-0.62f, 90f), (0.31f, -60f), (-0.31f, -60f) },
		5 => new[] { (0.62f, 90f), (-0.31f, -60f) },
		6 => new[] { (-0.31f, -120f), (-0.62f, 90f), (0.31f, 60f) },
		7 => new[] { (0.00f, -60f), (0.62f, 120f), (0.31f, 90f) },
		8 => new[] { (0.00f, 120f), (-0.31f, -90f), (0.62f, 90f) },
		_ => Array.Empty<(float, float)>(),
	};

	/// <summary>A glyph's one mark, 1-8: a ring or a dot, and where, in glyph units. ⚠️ Read by the tools too, one a line.</summary>
	static (bool Ring, float X, float Y) GlyphMark( int glyph ) => glyph switch
	{
		1 => (true, 0.00f, 0.55f),
		2 => (true, 0.00f, 0.55f),
		3 => (false, 0.00f, -0.55f),
		4 => (false, -0.45f, 0.55f),
		5 => (true, -0.45f, 0.55f),
		6 => (false, 0.45f, -0.55f),
		7 => (false, -0.45f, -0.55f),
		8 => (false, 0.00f, 0.55f),
		_ => (false, 0f, 0f),
	};

	/// <summary>How far out the glyphs reach, as a share of the half-width: a side glyph's end — the clue's outermost ink.</summary>
	static float OuterReach => GlyphR + GlyphUnit * 0.92f / (Size / 2f);

	/// <summary>
	/// A glyph, 1-8, as the stroke family's shapes — ("line", x0 y0 x1 y1), ("ring", x y r), ("dot", x y r) — in glyph units,
	/// y up: the bar, its strokes, its mark. The one construction, for the clue's picture and the platforms' light alike.
	/// </summary>
	public static (string Kind, float[] P)[] GlyphShapes( int glyph )
	{
		var shapes = new List<(string Kind, float[] P)> { ("line", new[] { -GlyphBar, 0f, GlyphBar, 0f }) };
		foreach ( var (x, deg) in GlyphStrokes( glyph ) )
		{
			var a = deg * MathF.PI / 180f;
			shapes.Add( ("line", new[] { x, 0f, x + GlyphLen * MathF.Cos( a ), GlyphLen * MathF.Sin( a ) }) );
		}

		var (ring, mx, my) = GlyphMark( glyph );
		shapes.Add( ring ? ("ring", new[] { mx, my, GlyphRing }) : ("dot", new[] { mx, my, GlyphDot }) );
		return shapes.ToArray();
	}

	/// <summary>One glyph painted upright round its centre, in a colour — by `LightStrokes.Paint`, as the hex clue paints its icons.</summary>
	static void DrawGlyph( Bitmap b, int glyph, Vector2 centre, Color colour )
		=> LightStrokes.Paint( b, GlyphShapes( glyph ), centre, GlyphUnit, GlyphStroke, colour );

	/// <summary>Where position <paramref name="pos"/> of a ring of this radius lies: 0 straight up, then clockwise.</summary>
	public static Vector2 PositionOf( float radius, int pos )
	{
		var half = Size / 2f;
		var a = pos * MathF.PI / 4f;
		return new Vector2( half + radius * half * MathF.Sin( a ), half - radius * half * MathF.Cos( a ) );
	}

	/// <summary>A colour's ring radius, as a fraction of the half-width.</summary>
	public static float RadiusOf( int colour )
	{
		foreach ( var (c, r) in Rings ) if ( c == colour ) return r;
		return 0f;
	}

	/// <summary>
	/// The picture: the four rings, their positions marked, and — once the puzzle has begun — each ring's dot where
	/// <paramref name="shown"/> puts it. The caller disposes it.
	/// </summary>
	public static Bitmap Draw( HexPlatforms.RingState shown )
	{
		var glow = Layer();
		var halo = Layer();
		var line = Layer();
		var dots = Layer();
		var half = Size / 2f;

		foreach ( var (colour, radius) in Rings )
		{
			var hue = HexPlatforms.HueOf( colour );
			var r = radius * half;

			glow.SetPen( hue.WithAlpha( GlowA ), Line );
			glow.DrawCircle( half, half, r );
			halo.SetPen( hue.WithAlpha( HaloA ), Line );
			halo.DrawCircle( half, half, r );
			line.SetPen( hue, Line );
			line.DrawCircle( half, half, r );

			// the eight places a dot can stand, marked faintly
			line.SetFill( hue.WithAlpha( TickA ) );
			for ( var k = 0; k < HexPlatforms.RingPositions; k++ )
			{
				var p = PositionOf( radius, k );
				line.DrawCircle( p.x, p.y, TickR );
			}

			if ( !shown.Active ) continue;

			var at = PositionOf( radius, shown.PositionOf( colour ) );
			glow.SetFill( hue.WithAlpha( GlowA ) );
			glow.DrawCircle( at.x, at.y, DotR + 4f );
			dots.SetFill( hue );
			dots.DrawCircle( at.x, at.y, DotR );
		}

		// the eight glyphs round the rings, one at each position, as a clock has its numbers
		var glyphGlow = Layer();
		var glyphs = Layer();
		for ( var k = 0; k < HexPlatforms.RingPositions; k++ )
		{
			var c = PositionOf( GlyphR, k );
			DrawGlyph( glyphGlow, k + 1, c, Color.White.WithAlpha( GlowA ) );
			DrawGlyph( glyphs, k + 1, c, Color.White );
		}

		glow.Blur( GlowBlur );
		halo.Blur( HaloBlur );
		glyphGlow.Blur( GlowBlur );

		var pic = Layer();
		var all = new Rect( 0, 0, Size, Size );
		foreach ( var layer in new[] { glow, halo, glyphGlow, line, glyphs, dots } )
		{
			pic.DrawBitmap( layer, all );
			layer.Dispose();
		}

		return pic;
	}

	static Bitmap Layer()
	{
		var b = new Bitmap( Size, Size );
		b.Clear( new Color( 0f, 0f, 0f, 0f ) );
		b.SetAntialias( true );
		return b;
	}

	/// <summary>
	/// Is this colour's dot drawn at this position — `nz_hex_selftest`'s proof that the picture shows what it was given.
	///
	/// ⚠️ READ OFF THE RING, NOT ON IT: the ring's own line runs through every position at full strength, so its middle
	/// cannot tell a dot from none. 18u out from the line, inside the dot's 30 but past the line's glow, only a dot is solid.
	/// </summary>
	public static bool DotAt( Bitmap pic, int colour, int pos )
	{
		var p = PositionOf( RadiusOf( colour ), pos );
		var outward = (p - new Vector2( Size / 2f, Size / 2f )).Normal;
		var q = p + outward * (DotR * 0.6f);
		return pic.GetPixel( (int)q.x, (int)q.y ).a > 0.9f;
	}

	/// <summary>Is a glyph drawn at this position: its bar's middle, which every glyph has, solid white? The selftest's proof.</summary>
	public static bool GlyphAt( Bitmap pic, int pos )
	{
		var c = PositionOf( GlyphR, pos );
		var px = pic.GetPixel( (int)c.x, (int)c.y );
		return px.a > 0.9f && px.r > 0.8f && px.g > 0.8f && px.b > 0.8f;
	}

	// ══ what the clue shows ═══════════════════════════════════════════════════════════════════
	//
	// ⚠️ A STATIC CACHE, AND LOCAL: one picture per machine, shared by every clue showing it — render resources, not state.

	static Texture _texture;
	static HexPlatforms.RingState _drawn;
	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 a dot moves. 2: the glyphs round the rings, as a clock (2026-09-26).
	/// </summary>
	const int DrawVersion = 2;

	/// <summary>
	/// The picture for what this machine's rings show now, drawn again only when that changes, or when a colour is
	/// retuned. Null if it cannot be drawn: the panel then keeps the plain PNG.
	/// </summary>
	public static Texture Current()
	{
		var shown = HexPlatforms.Instance.IsValid() ? HexPlatforms.Instance.RingsShown : default;
		if ( _texture is not null && shown == _drawn && _drawnTune == HexPlatforms.TuneVersion && _drawnVersion == DrawVersion )
			return _texture;

		try
		{
			using var pic = Draw( shown );
			_texture = pic.ToTexture();
			_drawn = shown;
			_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 rings could not be drawn ({e.Message}) — the clue shows its plain PNG" );
			_drawnTune = -2;
			return null;
		}

		return _texture;
	}

	/// <summary>
	/// `nz_hex_rings_png [demo]` — save the rings' picture as it stands, or with every dot at a random place (`demo`), to
	/// look at without placing it. Needs no game: it only draws.
	/// </summary>
	[ConCmd( "nz_hex_rings_png" )]
	public static void SavePng( string what = "" )
	{
		var shown = HexPlatforms.Instance.IsValid() ? HexPlatforms.Instance.RingsShown : default;
		if ( what.Trim().ToLowerInvariant() == "demo" ) shown = HexPlatforms.RingState.Random();

		using var pic = Draw( shown );
		const string file = "nz_hex_rings.png";
		FileSystem.Data.WriteAllBytes( file, pic.ToPng() );

		Log.Info( $"[nz-hex] Color Rings ({( shown.Active ? shown.Describe() : "not begun — no dots" )}) -> {FileSystem.Data.GetFullPath( file )}" );
	}

	// ══ on a wall ═══════════════════════════════════════════════════════════════════════════
	//
	// Asked for as *"i've put a wallbuy for a ksg in a hexagon surface, i need that surface to be the one that has the
	// circles we made"*. The wall buy marks the surface, and the rings take its place: this clue, centred on the flat the
	// wall buy stood on, and as big as the flat has room for.
	//
	// ⚠️ A CLUE'S SIZE IS IN PANEL UNITS, NOT WORLD UNITS: a world panel draws 0.05 world units to each, MEASURED. The engine
	// keeps the figure in `ScenePanelObject.ScreenToWorldScale`, which game code cannot read (CS0122: the type is internal).
	// ⛔ 0.1 WAS INFERRED FIRST, FROM THE MENU'S REWARD CARDS (300×400 panel units, set 35u apart at 100u from the camera),
	// AND IT WAS TWICE TOO BIG: the cards read as well at 15u wide as at 30u. The first rings clue, fitted to a hexagon of
	// 119.6u apothem, came out half the size meant. Its photo (`nz_hex_rings_photo`, from 306.5u at 60° over 960 pixels:
	// 2.713 px a unit) shows the blue ring 275 px across, 101.4u, for a line 2023 panel units across: 0.0501. The size
	// is worked out in world units and turned into panel units by <see cref="PanelScale"/>.

	static float? _fill;
	/// <summary>
	/// How far out the clue's outermost ink reaches — a side glyph's end — as a share of the flat's room to its nearest
	/// edge: 0.85.
	/// </summary>
	public static float Fill { get => _fill ?? 0.85f; set => _fill = value; }

	/// <summary>How far round a point the flat is looked for, in units.</summary>
	const float MaxReach = 400f;

	/// <summary>A step this big or bigger, in or out of the wall's plane, is the flat's edge: the hexagon's rim, in units.</summary>
	const float EdgeStep = 1f;

	static float? _panelScale;
	/// <summary>World units to a panel unit: 0.05, measured — see the note above.</summary>
	public static float PanelScale { get => _panelScale ?? 0.05f; set => _panelScale = value; }

	/// <summary>
	/// `nz_hex_rings_from_wallbuy [weapon] [across]` — HOST: the wall buy selling this weapon (`ksg` unless told otherwise;
	/// matched anywhere in its prefab's path) becomes the Color Rings clue on its surface. The clue is centred on the flat the
	/// wall buy stood on — its edges found by tracing — and made as big as that flat has room for, the outer ring
	/// <see cref="Fill"/> of the way to its nearest edge; or `across` world units wide, if given. The wall buy goes. In memory,
	/// like every edit: Save (nz_save) keeps it.
	/// </summary>
	[ConCmd( "nz_hex_rings_from_wallbuy" )]
	public static void FromWallBuyCmd( string weapon = "ksg", float across = -1f )
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }

		var cfg = ActiveConfig.Current;
		var scene = Game.ActiveScene;
		var match = weapon?.Trim() ?? "";
		if ( cfg is null || !scene.IsValid() || match == "" ) { Log.Warning( "[nz-hex] no config here" ); return; }

		var hits = cfg.WallBuys
			.Where( b => b.WeaponPrefab is not null && b.WeaponPrefab.Contains( match, StringComparison.OrdinalIgnoreCase ) )
			.ToList();
		if ( hits.Count == 0 )
		{
			Log.Warning( $"[nz-hex] no wall buy sells '{match}' — nz_wallbuy_list shows what they sell" );
			return;
		}

		// ⚠️ ONE SURFACE, ONE CLUE: with more than one, the one nearest you
		var me = NZPlayer.Local;
		var w = me.IsValid() ? hits.OrderBy( b => b.Position.Distance( me.WorldPosition ) ).First() : hits[0];
		if ( hits.Count > 1 ) Log.Info( $"[nz-hex] {hits.Count} wall buys sell '{match}' — the one nearest you, at {w.Position}" );

		var rot = w.Angles.ToRotation();

		// ⚠️ A WALL BUY STANDS 1.5u OFF THE SURFACE IT WAS PLACED ON (`MapEditor`), facing out of it: the surface is behind it
		var onWall = w.Position - rot.Forward * 1.5f;

		// ⚠️ NOTHING OF THE WALL BUY'S OWN, AND NOT YOU, may read as something standing on the wall
		var ignore = scene.GetAllComponents<WallBuy>()
			.Where( b => b.IsValid() && b.WorldPosition.Distance( w.Position ) < 16f )
			.Select( b => b.GameObject )
			.Append( me.IsValid() ? me.GameObject : null )
			.Where( g => g.IsValid() )
			.ToArray();

		var flat = FlatAround( onWall, rot, scene, ignore );
		var size = across > 0f ? across : 2f * Fill * flat.Room / OuterReach;

		// ⚠️ NO EDGE ANYWHERE ROUND IT — a hexagon flush with its wall, in the same material — gives no size to go by
		if ( !flat.Bounded && across <= 0f )
		{
			size = MathF.Min( size, 128f );
			Log.Warning( $"[nz-hex] no edge found round it within {MaxReach:0}u — the rings are {size:0}u across, where the wall buy"
				+ " stood; give the size you want: nz_hex_rings_clue <across>" );
		}

		var scale = PanelScale;
		cfg.Clues.Add( new ClueSpot
		{
			Position = flat.Centre,
			Yaw = rot.Yaw(),
			Normal = rot.Forward,
			Text = "",
			Image = ImagePath,
			Size = new Vector2( size / scale, size / scale ),
			Tint = Color.White,
		} );
		cfg.WallBuys.Remove( w );

		WallBuyManager.Ensure()?.Rebuild();
		ClueManager.Ensure()?.Rebuild();

		var off = flat.Centre - onWall;
		Log.Info( $"[nz-hex] the '{match}' wall buy became the Color Rings clue, {size:0.#}u across — its glyphs reach {size * OuterReach / 2f:0.#}u"
			+ $" out · the flat round it runs {flat.Room:0.#}u to its nearest edge, {Heading( flat.Deg )}, where {flat.Why}"
			+ $" · centred {off.Dot( rot.Left ):0.#}u right and {off.Dot( rot.Up ):0.#}u up of where the wall buy stood"
			+ " · in memory: Save (nz_save) keeps it · nz_hex_rings_photo to look at it" );
	}

	/// <summary>
	/// The flat round a point on a wall, and its middle. Every 10°, how far the surface runs before it steps in or out by
	/// <see cref="EdgeStep"/> or more, ends, or turns to another surface — traced through the wall, as
	/// `HexSlotManager.RoomFor` does, but with a hexagon's rim for an edge rather than a slot's 4u, and out to
	/// <see cref="MaxReach"/>. The middle is found by moving to the middle of what was measured, up to four times.
	/// Room is the nearest edge from there: the widest circle the flat holds. Bounded is whether every direction found one.
	/// </summary>
	static (Vector3 Centre, float Room, int Deg, string Why, bool Bounded) FlatAround( Vector3 onWall, Rotation rot, Scene scene,
		GameObject[] ignore )
	{
		var n = rot.Forward;
		var home = SurfaceAt( onWall, n, scene, ignore );
		var centre = onWall;
		var best = (Room: 0f, Deg: 0, Why: "", Bounded: false);

		for ( var pass = 0; pass < 4; pass++ )
		{
			var sum = Vector3.Zero;
			best = (float.MaxValue, 0, "", true);

			for ( var deg = 0; deg < 360; deg += 10 )
			{
				var a = deg * MathF.PI / 180f;
				var dir = rot.Left * MathF.Cos( a ) + rot.Up * MathF.Sin( a );     // in the wall's face: u right, v up

				var (free, why) = Reach( centre, dir, n, home, scene, ignore );
				sum += dir * free;
				if ( free >= MaxReach ) best.Bounded = false;
				if ( free < best.Room ) best = (free, deg, why, best.Bounded);
			}

			// ⚠️ TO THE MIDDLE. Measured off-centre, the flat is short on one side and long on the other, and the mean of where
			// it ends lies half way back toward its middle: twice that is the step. Not when it runs on past the reach
			// somewhere, where the mean would only lean toward the open side.
			// ⚠️ THE LAST PASS DOES NOT MOVE, so the centre returned is the one its room was measured from.
			var step = sum * (2f / 36f);
			if ( !best.Bounded || step.Length < 0.5f || pass == 3 ) break;
			centre += step;
		}

		return (centre, best.Room, best.Deg, best.Why, best.Bounded);
	}

	/// <summary>How far the flat runs from a point in one direction, a unit at a time, and what ends it.</summary>
	static (float Free, string Why) Reach( Vector3 onWall, Vector3 dir, Vector3 n, string home, Scene scene, GameObject[] ignore )
	{
		var free = 0f;
		for ( var d = 1f; d <= MaxReach; d += 1f )
		{
			var why = EdgeAt( onWall + dir * d, n, home, scene, ignore );
			if ( why is not null ) return (free, why);
			free = d;
		}

		return (MaxReach, $"the wall runs on past {MaxReach:0}u");
	}

	/// <summary>
	/// Why the wall here is no longer the flat — or null while it is. A short ray through it, from 4.5u in front of its plane
	/// to 12u behind: the flat stops it 4.5u in.
	/// </summary>
	static string EdgeAt( Vector3 onWall, Vector3 n, string home, Scene scene, GameObject[] ignore )
	{
		var tr = Through( onWall, n, scene, ignore );
		if ( !tr.Hit ) return "the wall ends";

		var step = 4.5f - tr.Distance;                                         // + out of the wall's plane, − into it
		if ( step <= -EdgeStep ) return $"it steps {-step:0.#}u back";
		if ( step >= EdgeStep ) return $"something stands {step:0.#}u out of it ({tr.GameObject?.Name ?? "?"})";

		var surface = tr.Surface?.ResourceName;
		if ( home is not null && surface is not null && surface != home ) return $"it turns from {home} to {surface}";

		return null;
	}

	/// <summary>The surface the flat is made of, at a point on it — null if it says none.</summary>
	static string SurfaceAt( Vector3 onWall, Vector3 n, Scene scene, GameObject[] ignore )
	{
		var tr = Through( onWall, n, scene, ignore );
		return tr.Hit ? tr.Surface?.ResourceName : null;
	}

	static SceneTraceResult Through( Vector3 onWall, Vector3 n, Scene scene, GameObject[] ignore )
	{
		var t = scene.Trace.Ray( onWall + n * 4.5f, onWall - n * 12f );
		foreach ( var g in ignore ) t = t.IgnoreGameObjectHierarchy( g );
		return t.Run();
	}

	/// <summary>A direction in a wall's face, as whoever faces it sees it.</summary>
	static string Heading( int deg ) => deg switch
	{
		0 => "to the right", 90 => "up", 180 => "to the left", 270 => "down",
		< 90 => $"up-right ({deg}°)", < 180 => $"up-left ({deg}°)", < 270 => $"down-left ({deg}°)", _ => $"down-right ({deg}°)",
	};

	/// <summary>The rings clues in the config — normally one.</summary>
	static ClueSpot[] Placed() => ActiveConfig.Current?.Clues?.Where( c => Is( c.Image ) ).ToArray() ?? Array.Empty<ClueSpot>();

	/// <summary>
	/// How wide the Color Rings clue on the wall is, in world units: the first one, if there are several, and 0 if none.
	/// Setting it resizes every one placed, live — the Clue tool's "Rings size" row. Asked for as *"i want to be able to
	/// change the scale"*. HOST: the config is its.
	/// </summary>
	public static float Across
	{
		get
		{
			var c = Placed().FirstOrDefault();
			return c is null ? 0f : c.Size.x * PanelScale;
		}
		set => Resize( value );
	}

	/// <summary>Every Color Rings clue placed made this wide, in world units, and built again. False if none is, or this is not the host.</summary>
	static bool Resize( float across )
	{
		if ( NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return false; }

		var placed = Placed();
		if ( placed.Length == 0 )
		{
			Log.Info( $"[nz-hex] no Color Rings clue placed — nz_hex_rings_from_wallbuy, or the Clue tool with '{FileName}'" );
			return false;
		}

		var units = MathF.Max( 1f, across ) / PanelScale;
		foreach ( var c in placed ) c.Size = new Vector2( units, units );
		ClueManager.Ensure()?.Rebuild();
		return true;
	}

	/// <summary>
	/// `nz_hex_rings_scale [factor]` — HOST: the Color Rings clue this many times as big — 1.5 is half as big again, 0.5 half
	/// the size. Bare, it says how big it is. The Clue tool's "Rings size" row does the same by eye. In memory: Save
	/// (nz_save) keeps it.
	/// </summary>
	[ConCmd( "nz_hex_rings_scale" )]
	public static void ScaleCmd( float factor = -1f )
	{
		var was = Across;
		if ( factor <= 0f )
		{
			Log.Info( was > 0f
				? $"[nz-hex] the Color Rings clue is {was:0.#}u across · nz_hex_rings_scale 1.5 makes it half as big again"
				: "[nz-hex] no Color Rings clue placed — nz_hex_rings_from_wallbuy" );
			return;
		}

		if ( Resize( was * factor ) )
			Log.Info( $"[nz-hex] the Color Rings clue: {was:0.#}u across -> {Across:0.#}u · in memory: Save (nz_save) keeps it" );
	}

	/// <summary>
	/// `nz_hex_rings_clue [across] [right] [up]` — HOST: the Color Rings clue's size, in world units across, and a nudge along
	/// its wall, in units: to the right as you face it (negative to the left), and up. Bare, it says where it is and how big.
	/// In memory: Save (nz_save) keeps it.
	/// </summary>
	[ConCmd( "nz_hex_rings_clue" )]
	public static void ClueCmd( float across = -1f, float right = 0f, float up = 0f )
	{
		var placed = Placed();
		if ( placed.Length == 0 )
		{
			Log.Info( $"[nz-hex] no Color Rings clue placed — nz_hex_rings_from_wallbuy, or the Clue tool with '{FileName}'" );
			return;
		}

		var change = across > 0f || right != 0f || up != 0f;
		if ( change && NZGame.IsClient ) { Log.Warning( "[nz-hex] the config is the host's" ); return; }

		var scale = PanelScale;
		foreach ( var c in placed )
		{
			var n = c.Normal.IsNearlyZero() ? Vector3.Up : c.Normal.Normal;
			var rot = Rotation.LookAt( n );
			if ( across > 0f ) c.Size = new Vector2( across / scale, across / scale );
			c.Position += rot.Left * right + rot.Up * up;                     // its left is the right of whoever faces it

			Log.Info( $"[nz-hex] the Color Rings clue: {c.Size.x * scale:0.#}u across (panel {c.Size.x:0}), at {c.Position},"
				+ $" facing {n}" );
		}

		if ( !change ) return;

		ClueManager.Ensure()?.Rebuild();
		Log.Info( "[nz-hex] in memory: Save (nz_save) keeps it" );
	}

	/// <summary>
	/// `nz_hex_rings_photo [distance]` — a picture of the Color Rings clue from in front of it, saved as `nz_hex_rings_clue.png`
	/// in the game's data folder, to check it without walking there: from far enough back to take it all in, unless told.
	/// A throwaway camera rendered once, as `nz_hex_slot_photo`'s. Needs no game: it only renders.
	/// </summary>
	[ConCmd( "nz_hex_rings_photo" )]
	public static void PhotoCmd( float distance = -1f )
	{
		var c = Placed().FirstOrDefault();
		var scene = Game.ActiveScene;
		if ( c is null || !scene.IsValid() ) { Log.Warning( "[nz-hex] no Color Rings clue placed" ); return; }

		var n = c.Normal.IsNearlyZero() ? Vector3.Up : c.Normal.Normal;
		var across = c.Size.x * PanelScale;
		var d = distance > 0f ? distance : MathF.Max( 48f, across * 1.4f );     // 60° across: 1.4 of it takes in the whole

		var go = scene.CreateObject();
		go.Name = "nz_hex_rings_photo camera";
		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.WorldPosition = c.Position + n * d;
		go.WorldRotation = Rotation.LookAt( -n, MathF.Abs( n.z ) > 0.7f ? Vector3.Forward : Vector3.Up );

		var cam = go.Components.Create<CameraComponent>();
		cam.IsMainCamera = false;
		cam.Priority = -1000;
		cam.FieldOfView = 60f;

		try
		{
			using var pic = new Bitmap( 960, 960 );
			cam.RenderToBitmap( pic );

			const string file = "nz_hex_rings_clue.png";
			FileSystem.Data.WriteAllBytes( file, pic.ToPng() );
			Log.Info( $"[nz-hex] the Color Rings clue ({across:0.#}u across) from {d:0}u in front -> {FileSystem.Data.GetFullPath( file )}" );
		}
		catch ( Exception e )
		{
			Log.Warning( $"[nz-hex] could not photograph the Color Rings clue: {e.Message}" );
		}
		finally
		{
			go.Destroy();
		}
	}
}

/// <summary>
/// The clue images drawn live rather than from their PNG: basalt's hex map (<see cref="HexClue"/>) and its rings
/// (<see cref="RingsClue"/>). `CluePanel` and `ClueManager` ask here, so a new one is one line in each method.
/// </summary>
public static class LiveClues
{
	/// <summary>Is this clue image one of the live ones?</summary>
	public static bool Is( string image ) => HexClue.Is( image ) || RingsClue.Is( image );

	/// <summary>The live picture for this image now, or null — not a live one, or it could not be drawn.</summary>
	public static Texture Current( string image )
		=> HexClue.Is( image ) ? HexClue.Current() : RingsClue.Is( image ) ? RingsClue.Current() : null;

	/// <summary>Has this live clue gone from the walls? The hex map, once Color Rings is done (`HexClue.Gone`); the rings never.</summary>
	public static bool Gone( string image ) => HexClue.Is( image ) && HexClue.Gone;
}