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