DiamondMenuDye.cs
using System;
using Sandbox;

namespace Diamonds;

/// <summary>A menu-sized dye field with moving glyph walls and solid button boundaries.</summary>
public sealed class DiamondMenuDye
{
	public const int Columns = 96, Rows = 64;
	const int Count = Columns * Rows;
	readonly float[] dye = new float[Count * 5], transported = new float[Count * 5];
	readonly Vector2[] velocity = new Vector2[Count], nextVelocity = new Vector2[Count];
	readonly Vector2[] normals = new Vector2[Count], walls = new Vector2[Count];
	readonly float[] distance = new float[Count], divergence = new float[Count];
	float[] pressure = new float[Count], pressureNext = new float[Count];
	readonly Random random = new();
	readonly Vector2[] centers = new Vector2[2], wordVelocity = new Vector2[2];
	Rect viewport;
	Rect[] buttons = [];
	Vector2 cell;
	float accumulator;
	bool initialized;
	public float Sample( int x, int y, int color ) => dye[(y * Columns + x) * 5 + color];
	public ReadOnlySpan<float> Samples => dye;
	// Rendering revision: velocity-only updates leave the uploaded colors reusable.
	public ulong DyeVersion { get; private set; }
	public Vector2 Position( int x, int y ) => viewport.Position + new Vector2( (x + 0.5f) * cell.x, (y + 0.5f) * cell.y );
	public Vector2 CellSize => cell;
	public bool Ready( Rect bounds ) => initialized && viewport == bounds;
	public float TotalDye { get { float total = 0; foreach ( float value in dye ) total += value; return total; } }
	public float MotionEnergy { get { float total = 0; foreach ( var value in velocity ) total += value.LengthSquared; return total; } }
	public void Clear() { initialized = false; Array.Clear( dye ); Array.Clear( velocity ); accumulator = 0; DyeVersion++; }

	public void Update( float delta, Rect bounds, ReadOnlySpan<Rect> buttonWalls, Vector2 first, Vector2 second )
	{
		bool reset = !initialized || viewport != bounds;
		bool wallsChanged = reset || !buttonWalls.SequenceEqual( buttons );
		viewport = bounds;
		if ( wallsChanged ) buttons = buttonWalls.ToArray();
		cell = new Vector2( bounds.Width / Columns, bounds.Height / Rows );
		for ( int word = 0; word < 2; word++ )
		{
			var next = word == 0 ? first : second;
			var motion = !reset && delta > 0 ? (next - centers[word]) / delta : Vector2.Zero;
			motion = motion.Length > 1050 ? motion.Normal * 1050 : motion;
			// Include velocity changes: the first stationary update must clear moving walls.
			wallsChanged |= centers[word] != next || wordVelocity[word] != motion;
			wordVelocity[word] = motion;
			centers[word] = next;
		}
		if ( wallsChanged ) BuildWalls();
		if ( reset ) Seed();
		DisplaceCoveredDye();
		// Moving leading faces transfer momentum to the surrounding fluid, including the wake.
		float reach = MathF.Max( cell.x, cell.y ) * 2;
		for ( int i = 0; i < Count; i++ )
		{
			if ( distance[i] < 0 ) { velocity[i] = walls[i]; continue; }
			if ( distance[i] > reach ) continue;
			float approach = Vector2.Dot( velocity[i] - walls[i], normals[i] );
			if ( approach < 0 ) velocity[i] -= normals[i] * approach * (1 - distance[i] / reach) * Math.Clamp( delta * 120, 0, 1 );
		}
		accumulator += Math.Clamp( delta, 0, 0.1f );
		while ( accumulator >= 1f / 30 ) { Step( 1f / 30 ); accumulator -= 1f / 30; }
	}

	void Seed()
	{
		DyeVersion++;
		Array.Clear( velocity ); Array.Clear( dye );
		accumulator = 0;
		float a = Phase(), b = Phase(), c = Phase(), d = Phase(), hue = Phase();
		float Phase() => (float)random.NextDouble() * MathF.PI * 2;
		for ( int y = 0; y < Rows; y++ )
		for ( int x = 0; x < Columns; x++ )
		{
			int n = y * Columns + x;
			if ( distance[n] < 0 ) continue;
			float u = x / (float)Columns, v = y / (float)Rows;
			float fold = MathF.Sin( u * 11 + a + MathF.Sin( v * 7 + b ) * 1.3f );
			float curl = MathF.Cos( v * 10 - u * 4 + c + MathF.Cos( u * 8 + d ) );
			float cloud = Math.Clamp( 0.5f + fold * 0.3f + curl * 0.24f, 0, 1 );
			float swirl = MathF.Sin( u * 6 + a ) * 1.7f + MathF.Cos( v * 7 + b ) * 1.4f + u * 5;
			for ( int color = 0; color < 5; color++ )
			{
				float band = MathF.Max( 0, MathF.Cos( swirl - color * MathF.PI * 2 / 5 + hue ) );
				dye[n * 5 + color] = cloud * (0.01f + 0.29f * band * band);
			}
		}
		initialized = true;
	}

	void BuildWalls()
	{
		for ( int y = 0; y < Rows; y++ )
		for ( int x = 0; x < Columns; x++ )
		{
			int n = y * Columns + x;
			distance[n] = Obstacle( Position( x, y ), out normals[n], out walls[n] );
		}
	}

	void DisplaceCoveredDye()
	{
		bool changed = false;
		for ( int y = 0; y < Rows; y++ )
		for ( int x = 0; x < Columns; x++ )
		{
			int n = y * Columns + x;
			if ( distance[n] >= 0 ) continue;
			var target = Position( x, y ) + normals[n] * (-distance[n] + MathF.Max( cell.x, cell.y ) * 1.3f);
			int tx = Math.Clamp( (int)((target.x - viewport.Left) / cell.x), 0, Columns - 1 );
			int ty = Math.Clamp( (int)((target.y - viewport.Top) / cell.y), 0, Rows - 1 );
			int receiver = ty * Columns + tx;
			for ( int color = 0; color < 5; color++ )
			{
				changed |= dye[n * 5 + color] != 0;
				if ( distance[receiver] >= 0 ) dye[receiver * 5 + color] += dye[n * 5 + color];
				dye[n * 5 + color] = 0;
			}
		}
		if ( changed ) DyeVersion++;
	}

	void Step( float dt )
	{
		Array.Clear( pressure );
		for ( int y = 0; y < Rows; y++ )
		for ( int x = 0; x < Columns; x++ )
		{
			int n = y * Columns + x;
			if ( distance[n] < 0 ) { nextVelocity[n] = walls[n]; continue; }
			var sample = Trace( Position( x, y ), -velocity[n] * dt );
			nextVelocity[n] = SampleVelocity( sample ) * 0.983f;
		}
		for ( int y = 0; y < Rows; y++ )
		for ( int x = 0; x < Columns; x++ )
		{
			int n = y * Columns + x;
			divergence[n] = -0.5f * ((NeighborVelocity( x + 1, y, n ).x - NeighborVelocity( x - 1, y, n ).x) / cell.x
				+ (NeighborVelocity( x, y + 1, n ).y - NeighborVelocity( x, y - 1, n ).y) / cell.y);
		}
		for ( int iteration = 0; iteration < 10; iteration++ )
		{
			for ( int y = 0; y < Rows; y++ )
			for ( int x = 0; x < Columns; x++ )
			{
				int n = y * Columns + x;
				pressureNext[n] = distance[n] < 0 ? 0 : (divergence[n] * cell.x * cell.y
					+ NeighborPressure( x - 1, y, n ) + NeighborPressure( x + 1, y, n )
					+ NeighborPressure( x, y - 1, n ) + NeighborPressure( x, y + 1, n )) * 0.25f;
			}
			(pressure, pressureNext) = (pressureNext, pressure);
		}
		for ( int y = 0; y < Rows; y++ )
		for ( int x = 0; x < Columns; x++ )
		{
			int n = y * Columns + x;
			if ( distance[n] < 0 ) { velocity[n] = walls[n]; continue; }
			var v = nextVelocity[n] - new Vector2( (NeighborPressure( x + 1, y, n ) - NeighborPressure( x - 1, y, n )) / cell.x,
				(NeighborPressure( x, y + 1, n ) - NeighborPressure( x, y - 1, n )) / cell.y ) * 0.5f;
			// Reflect incoming flow in the wall's moving frame instead of letting it leak through.
			float incoming = Vector2.Dot( v - walls[n], normals[n] );
			if ( distance[n] < MathF.Max( cell.x, cell.y ) && incoming < 0 ) v -= normals[n] * incoming * 1.65f;
			if ( x == 0 && v.x < 0 || x == Columns - 1 && v.x > 0 ) v.x *= -0.65f;
			if ( y == 0 && v.y < 0 || y == Rows - 1 && v.y > 0 ) v.y *= -0.65f;
			velocity[n] = v.Length > 1050 ? v.Normal * 1050 : v;
			var source = Trace( Position( x, y ), -velocity[n] * dt );
			TransportDye( source, n );
		}
		bool changed = false;
		for ( int n = 0; n < Count; n++ )
		for ( int color = 0; color < 5; color++ )
		{
			int index = n * 5 + color;
			float value = distance[n] < 0 ? 0 : transported[index];
			changed |= dye[index] != value;
			dye[index] = value;
		}
		if ( changed ) DyeVersion++;
	}

	Vector2 NeighborVelocity( int x, int y, int own ) => x < 0 || x >= Columns || y < 0 || y >= Rows ? -nextVelocity[own] : nextVelocity[y * Columns + x];
	float NeighborPressure( int x, int y, int own ) => x < 0 || x >= Columns || y < 0 || y >= Rows || distance[y * Columns + x] < 0 ? pressure[own] : pressure[y * Columns + x];

	Vector2 Trace( Vector2 point, Vector2 motion )
	{
		int steps = Math.Max( 1, (int)MathF.Ceiling( motion.Length / 5 ) );
		var result = point;
		for ( int i = 1; i <= steps; i++ )
		{
			var next = point + motion * (i / (float)steps);
			if ( !viewport.IsInside( next ) || Obstacle( next, out _, out _ ) < 1 ) break;
			result = next;
		}
		return result;
	}

	void TransportDye( Vector2 point, int destination )
	{
		Weights( point, out int x, out int y, out float fx, out float fy );
		int a = y * Columns + x, b = a + 1, c = a + Columns, d = c + 1;
		// All five colors share the same open neighbors and interpolation weights.
		float wa = distance[a] < 0 ? 0 : (1 - fx) * (1 - fy);
		float wb = distance[b] < 0 ? 0 : fx * (1 - fy);
		float wc = distance[c] < 0 ? 0 : (1 - fx) * fy;
		float wd = distance[d] < 0 ? 0 : fx * fy;
		float total = wa + wb + wc + wd;
		for ( int color = 0; color < 5; color++ )
		{
			float value = 0;
			value += dye[a * 5 + color] * wa;
			value += dye[b * 5 + color] * wb;
			value += dye[c * 5 + color] * wc;
			value += dye[d * 5 + color] * wd;
			transported[destination * 5 + color] = (total > 0 ? value / total : 0) * 0.9999f;
		}
	}

	Vector2 SampleVelocity( Vector2 point )
	{
		Weights( point, out int x, out int y, out float fx, out float fy );
		return velocity[y * Columns + x] * ((1 - fx) * (1 - fy)) + velocity[y * Columns + x + 1] * (fx * (1 - fy))
			+ velocity[(y + 1) * Columns + x] * ((1 - fx) * fy) + velocity[(y + 1) * Columns + x + 1] * (fx * fy);
	}

	void Weights( Vector2 point, out int x, out int y, out float fx, out float fy )
	{
		float gx = Math.Clamp( (point.x - viewport.Left) / cell.x - 0.5f, 0, Columns - 1.001f );
		float gy = Math.Clamp( (point.y - viewport.Top) / cell.y - 0.5f, 0, Rows - 1.001f );
		x = (int)gx; y = (int)gy; fx = gx - x; fy = gy - y;
	}

	public float Obstacle( Vector2 point, out Vector2 normal, out Vector2 wall )
	{
		float nearest = float.PositiveInfinity;
		normal = Vector2.Zero;
		wall = Vector2.Zero;
		foreach ( var button in buttons )
		{
			float d = BoxDistance( point, button, out var other );
			if ( d < nearest ) { nearest = d; normal = other; }
		}
		for ( int word = 0; word < 2; word++ )
		{
			var offset = point - centers[word];
			var local = new Vector2( offset.x * 0.62932f - offset.y * 0.777146f, offset.x * 0.777146f + offset.y * 0.62932f );
			float d = GlyphDistance( word, local, out var n );
			if ( d >= nearest ) continue;
			nearest = d;
			normal = new Vector2( n.x * 0.62932f + n.y * 0.777146f, -n.x * 0.777146f + n.y * 0.62932f );
			wall = wordVelocity[word];
		}
		return nearest;
	}

	static float BoxDistance( Vector2 point, Rect box, out Vector2 normal )
	{
		var nearest = new Vector2( Math.Clamp( point.x, box.Left, box.Right ), Math.Clamp( point.y, box.Top, box.Bottom ) );
		var offset = point - nearest;
		if ( offset.Length > 0 ) { normal = offset.Normal; return offset.Length; }
		float left = point.x - box.Left, right = box.Right - point.x, top = point.y - box.Top, bottom = box.Bottom - point.y;
		float min = MathF.Min( MathF.Min( left, right ), MathF.Min( top, bottom ) );
		normal = min == left ? new Vector2( -1, 0 ) : min == right ? new Vector2( 1, 0 ) : min == top ? new Vector2( 0, -1 ) : new Vector2( 0, 1 );
		return -min;
	}

	const int GlyphWidth = 365, GlyphHeight = 69;
	static readonly float[][] glyphDistance = new float[2][];
	static readonly Vector2[][] glyphNormal = new Vector2[2][];
	static float GlyphDistance( int word, Vector2 point, out Vector2 normal )
	{
		normal = Vector2.Zero;
		if ( point.x < -364 || point.x > 364 || point.y < -68 || point.y > 68 ) return 10000;
		if ( glyphDistance[word] is null ) BuildGlyph( word );
		int x = Math.Clamp( (int)MathF.Round( (point.x + 364) / 2 ), 0, GlyphWidth - 1 );
		int y = Math.Clamp( (int)MathF.Round( (point.y + 68) / 2 ), 0, GlyphHeight - 1 );
		int index = y * GlyphWidth + x;
		normal = glyphNormal[word][index];
		return glyphDistance[word][index];
	}

	static void BuildGlyph( int word )
	{
		glyphDistance[word] = new float[GlyphWidth * GlyphHeight];
		glyphNormal[word] = new Vector2[GlyphWidth * GlyphHeight];
		for ( int y = 0; y < GlyphHeight; y++ )
		for ( int x = 0; x < GlyphWidth; x++ )
		{
			var point = new Vector2( x * 2 - 364, y * 2 - 68 );
			bool inside = false;
			float best = float.PositiveInfinity;
			var direction = Vector2.Zero;
			foreach ( var contour in DiamondTitleGlyphs.Words[word] )
			for ( int i = 0; i < contour.Length; i++ )
			{
				var a = contour[i]; var b = contour[(i + 1) % contour.Length];
				if ( (a.y > point.y) != (b.y > point.y) && point.x < (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x ) inside = !inside;
				var edge = b - a;
				float fraction = Math.Clamp( Vector2.Dot( point - a, edge ) / MathF.Max( edge.LengthSquared, 0.0001f ), 0, 1 );
				var offset = point - (a + edge * fraction);
				if ( offset.LengthSquared >= best ) continue;
				best = offset.LengthSquared; direction = offset.Normal;
			}
			int n = y * GlyphWidth + x;
			glyphDistance[word][n] = MathF.Sqrt( best ) * (inside ? -1 : 1);
			glyphNormal[word][n] = direction * (inside ? -1 : 1);
		}
	}
}