DiamondAtmosphere.cs
using System;
using System.Collections.Generic;
using Sandbox;

namespace Diamonds;

/// <summary>A small, damped velocity and five-dye field behind the playfield.</summary>
public sealed partial class DiamondAtmosphere
{
	public const int Columns = 96;
	public const int Rows = 96;
	public const float CellSize = 15;
	// Screen-aligned cells retain the fine-grid density and ample offscreen spawn space.
	const float FlowUnit = 30;
	const float FlowSpeed = 1.6f;
	const float DyeDensityScale = FlowUnit * FlowUnit / (CellSize * CellSize);
	const float WallBounce = 0.65f;
	const float SpawnBandWidth = 176f;
	const float SpawnBandY = -64f;
	const int Count = Columns * Rows;
	bool disabled, synchronizeOnEnable;
	float? opacity;
	public bool Enabled
	{
		get => !disabled;
		set
		{
			if ( value == Enabled ) return;
			disabled = !value;
			previousBoard = null;
			synchronizeOnEnable = true;
		}
	}
	public float Opacity { get => opacity ?? 1f; set => opacity = Math.Clamp( value, 0, 1 ); }
	float? spawnDyeAmount, shatterDyeAmount, dyeFadeRate;
	public float SpawnDyeAmount { get => spawnDyeAmount ?? 7f; set => spawnDyeAmount = MathF.Max( 0, value ); }
	public float ShatterDyeAmount { get => shatterDyeAmount ?? 2.5f; set => shatterDyeAmount = MathF.Max( 0, value ); }
	public float DyeFadeRate { get => dyeFadeRate ?? 0.3f; set => dyeFadeRate = Math.Clamp( value, 0, 100 ); }
	float[] velocityX = new float[Count], velocityY = new float[Count];
	float[] nextX = new float[Count], nextY = new float[Count];
	float[] dye = new float[Count * DiamondBoard.ColorCount];
	float[] nextDye = new float[Count * DiamondBoard.ColorCount];
	float[] pressure = new float[Count], nextPressure = new float[Count], divergence = new float[Count];
	float[] influence = new float[Count];
	readonly List<DiamondBoard.Diamond> previousSettled = new();
	Random layoutRandom = new();
	DiamondBoard.Piece previousActive;
	DiamondBoard previousBoard;
	int resetVersion, previousPlaced, previousSpawnVersion = -1, modelRevision;
	long previousShatter;
	bool hasPrevious;
	float accumulator;
	// Painter reads one contiguous view instead of calling Sample for every color of every tile.
	public ReadOnlySpan<float> Samples => DisableFluid && staticDye is not null ? staticDye : dye;
	// Rendering revision: velocity-only updates leave the uploaded colors reusable.
	public ulong DyeVersion { get; private set; }

	public float Sample( int x, int y, int color )
	{
		int index = (y * Columns + x) * DiamondBoard.ColorCount + color;
		return index < Samples.Length ? Samples[index] : 0;
	}
	public static Vector2 GridToWorld( int x, int y )
	{
		float u = (x + 0.5f - Columns * 0.5f) * CellSize;
		float v = (y + 0.5f - Rows * 0.5f) * CellSize;
		return new Vector2( DiamondBoard.BoardWidth * 0.5f + u,
			DiamondBoard.BoardHeight * 0.5f + v );
	}

	static (float X, float Y) WorldToGrid( float x, float y )
	{
		float dx = x - DiamondBoard.BoardWidth * 0.5f;
		float dy = y - DiamondBoard.BoardHeight * 0.5f;
		return (dx / CellSize + Columns * 0.5f - 0.5f,
			dy / CellSize + Rows * 0.5f - 0.5f);
	}

	static float ReflectX( float x ) => x < 0 ? -x : x > DiamondBoard.BoardWidth ? 2 * DiamondBoard.BoardWidth - x : x;
	static float ReflectBottom( float y ) => y > DiamondBoard.BoardHeight ? 2 * DiamondBoard.BoardHeight - y : y;

	(float X, float Y) Backtrace( int x, int y, float vx, float vy, float dt )
	{
		Vector2 point = GridToWorld( x, y );
		// Faster transport without extra simulation steps or faster dye fading.
		dt *= FlowSpeed;
		float worldX = point.x - vx * FlowUnit * dt;
		float worldY = point.y - vy * FlowUnit * dt;
		var target = WorldToGrid( ReflectX( worldX ), ReflectBottom( worldY ) );
		return TraceFluid( x, y, target.X, target.Y );
	}

	public void Clear()
	{
		staticBoard = null;
		staticDye = null;
		previousBoard = null;
		if ( DisableFluid ) { DyeVersion++; return; }
		ClearFluid();
	}

	void ClearFluid()
	{
		// Defer even field seeding until dye is enabled again.
		if ( !Enabled ) { previousBoard = null; return; }
		DyeVersion++;
		// Fast hotload can retain a field created with the previous grid size.
		if ( velocityX.Length != Count )
		{
			velocityX = new float[Count]; velocityY = new float[Count];
			nextX = new float[Count]; nextY = new float[Count];
			dye = new float[Count * DiamondBoard.ColorCount];
			nextDye = new float[dye.Length];
			pressure = new float[Count]; nextPressure = new float[Count];
			divergence = new float[Count]; influence = new float[Count];
		}
		Array.Clear( velocityX ); Array.Clear( velocityY );
		Array.Clear( nextX ); Array.Clear( nextY );
		Array.Clear( dye ); Array.Clear( nextDye );
		Array.Clear( pressure ); Array.Clear( nextPressure ); Array.Clear( divergence );
		Array.Clear( influence );
		ClearObstacles();
		previousSettled.Clear();
		hasPrevious = false;
		previousSpawnVersion = -1;
		previousShatter = 0;
		shatterPushCooldown = 0;
		accumulator = 0;
		layoutRandom ??= new Random();
		float foldPhase = RandomPhase(), foldBend = RandomPhase();
		float curlPhase = RandomPhase(), curlBend = RandomPhase();
		float swirlPhase = RandomPhase(), swirlBend = RandomPhase();
		float colorPhase = RandomPhase();
		float RandomPhase() => (float)(layoutRandom.NextDouble() * Math.PI * 2);
		// Start as one continuous mixed field. Broad, overlapping waves vary the
		// five dyes without placing a separate cloud at each color's location.
		// Cosmetic phases change on every new game without affecting the piece bag.
		for ( int y = 0; y < Rows; y++ )
		for ( int x = 0; x < Columns; x++ )
		{
			float u = (x + 0.5f) / Columns - 0.5f;
			float v = (y + 0.5f) / Rows - 0.5f;
			float fold = 0.5f + 0.5f * MathF.Sin( u * 9 + foldPhase + MathF.Sin( v * 6 + foldBend ) * 1.3f );
			float curl = 0.5f + 0.5f * MathF.Sin( v * 10 - u * 4.5f + curlPhase + MathF.Cos( u * 5 + curlBend ) * 0.9f );
			float cloud = Math.Clamp( (fold * 0.6f + curl * 0.4f - 0.22f) / 0.58f, 0, 1 );
			cloud = cloud * cloud * (3 - 2 * cloud);
			float swirl = MathF.Sin( u * 6.2f + v * 1.8f + swirlPhase ) * 1.7f +
				MathF.Cos( v * 6.8f - u * 2.5f + swirlBend ) * 1.4f + u * 3.1f;
			for ( int color = 0; color < DiamondBoard.ColorCount; color++ )
			{
				float phase = color * MathF.PI * 2 / DiamondBoard.ColorCount;
				float band = MathF.Max( 0, MathF.Cos( swirl - phase + colorPhase ) );
				int index = (y * Columns + x) * DiamondBoard.ColorCount + color;
				dye[index] = cloud * (0.012f + 0.23f * band * band);
			}
		}
	}

	public void Update( DiamondBoard board, float delta )
	{
		if ( !Enabled ) return;
		if ( DisableFluid ) { PrepareStaticBackground( board ); return; }
		if ( board != previousBoard || board.ResetVersion != resetVersion || modelRevision != 17 )
		{
			ClearFluid();
			previousBoard = board;
			resetVersion = board.ResetVersion;
			modelRevision = 17;
		}
		if ( synchronizeOnEnable )
		{
			// Discard events from the disabled interval and rebuild obstacles from current positions.
			previousSpawnVersion = board.SpawnVersion;
			foreach ( var burst in board.ShatterBursts ) previousShatter = Math.Max( previousShatter, burst.Id );
			synchronizeOnEnable = false;
		}
		if ( board.Paused ) return;
		delta = Math.Clamp( delta, 0, 0.1f );
		if ( delta <= 0 ) return;
		shatterPushCooldown = MathF.Max( 0, shatterPushCooldown - delta );
		if ( !board.GameOver && board.SpawnVersion != previousSpawnVersion )
		{
			EmitSpawn( board.Active );
			previousSpawnVersion = board.SpawnVersion;
		}
		int firstShatter = board.ShatterBursts.Count;
		for ( int i = 0; i < board.ShatterBursts.Count; i++ )
		{
			var burst = board.ShatterBursts[i];
			if ( burst.Id <= previousShatter ) continue;
			firstShatter = Math.Min( firstShatter, i );
			EmitShatterDye( burst.Cell );
			previousShatter = burst.Id;
		}

		UpdateObstacles( board, delta );
		// Push only after dead gems have released their fluid obstacles.
		PushShatterDye( board, firstShatter );

		previousSettled.Clear();
		previousSettled.AddRange( board.Settled );
		previousActive = board.Active;
		previousPlaced = board.Placed;
		hasPrevious = true;

		accumulator = MathF.Min( accumulator + delta, 0.12f );
		while ( accumulator >= 1f / 30 )
		{
			Step( 1f / 30 );
			accumulator -= 1f / 30;
		}
	}

	void EmitSpawn( DiamondBoard.Piece piece )
	{
		int[] counts = new int[DiamondBoard.ColorCount];
		int count = 0;
		float centerX = 0;
		foreach ( var cell in DiamondBoard.CellsOf( piece ) )
		{
			counts[cell.ColorIndex]++;
			centerX += DiamondBoard.LaneX( cell.Lane );
			count++;
		}
		if ( count == 0 ) return;
		centerX /= count;
		float left = Math.Clamp( centerX - SpawnBandWidth * 0.5f, 0, DiamondBoard.BoardWidth - SpawnBandWidth );
		float right = left + SpawnBandWidth;
		// Each slot carries the same dye mass. Grouping equal colors into adjacent
		// slots gives exact 3:1, 2:2, or four-color proportions without blending
		// all colors at the emission point.
		int[] slotColors = new int[count];
		int slotIndex = 0;
		for ( int color = 0; color < counts.Length; color++ )
		for ( int n = 0; n < counts[color]; n++ ) slotColors[slotIndex++] = color;
		float[] weightSums = new float[count];
		for ( int y = 1; y < Rows - 1; y++ )
		for ( int x = 1; x < Columns - 1; x++ )
		{
			Vector2 point = GridToWorld( x, y );
			float weight = BandWeight( point );
			if ( weight > 0 ) weightSums[Slot( point.x )] += weight;
		}
		float massPerSlot = SpawnDyeAmount * DyeDensityScale / count;
		bool changed = false;
		for ( int y = 1; y < Rows - 1; y++ )
		for ( int x = 1; x < Columns - 1; x++ )
		{
			Vector2 point = GridToWorld( x, y );
			float weight = BandWeight( point );
			if ( weight <= 0 ) continue;
			int slot = Slot( point.x );
			if ( weightSums[slot] > 0 )
			{
				int index = (y * Columns + x) * DiamondBoard.ColorCount + slotColors[slot];
				float before = dye[index];
				dye[index] += massPerSlot * weight / weightSums[slot];
				changed |= dye[index] != before;
			}
		}
		if ( changed ) DyeVersion++;
		for ( int slot = 0; slot < count; slot++ )
			Splat( left + SpawnBandWidth * (slot + 0.5f) / count, SpawnBandY, 0, 6f / count, true );

		int Slot( float x ) => Math.Min( count - 1, (int)((x - left) * count / SpawnBandWidth) );
		float BandWeight( Vector2 point )
		{
			// Keep the entire source above the visible tiles. Its downward impulse
			// carries the color through the top edge before the new piece arrives.
			if ( point.x <= left || point.x >= right || MathF.Abs( point.y - SpawnBandY ) > 41 ) return 0;
			float edge = Math.Clamp( MathF.Min( point.x - left, right - point.x ) / FlowUnit, 0, 1 );
			float dy = (point.y - SpawnBandY) / 25;
			return edge * MathF.Exp( -0.5f * dy * dy );
		}
	}

	void EmitShatterDye( DiamondBoard.Diamond cell )
	{
		float x = DiamondBoard.LaneX( cell.Lane ), y = cell.Y;
		const float radius = 84f, spread = 36f;
		// Only cells within this local square can receive dye; retain the radial test below and
		// the original summation order so the result matches a full-grid scan.
		var (gx, gy) = WorldToGrid( x, y );
		float reach = radius / CellSize;
		int left = Math.Max( 1, (int)MathF.Floor( gx - reach ) );
		int right = Math.Min( Columns - 2, (int)MathF.Ceiling( gx + reach ) );
		int top = Math.Max( 1, (int)MathF.Floor( gy - reach ) );
		int bottom = Math.Min( Rows - 2, (int)MathF.Ceiling( gy + reach ) );
		float weightSum = 0;
		for ( int row = top; row <= bottom; row++ )
		for ( int column = left; column <= right; column++ )
			weightSum += Weight( GridToWorld( column, row ) );
		if ( weightSum <= 0 ) return;
		bool changed = false;
		for ( int row = top; row <= bottom; row++ )
		for ( int column = left; column <= right; column++ )
		{
			float weight = Weight( GridToWorld( column, row ) );
			if ( weight > 0 )
			{
				int index = (row * Columns + column) * DiamondBoard.ColorCount + cell.ColorIndex;
				float before = dye[index];
				dye[index] += ShatterDyeAmount * DyeDensityScale * weight / weightSum;
				changed |= dye[index] != before;
			}
		}
		if ( changed ) DyeVersion++;

		float Weight( Vector2 point )
		{
			float dx = point.x - x, dy = point.y - y;
			float distanceSquared = dx * dx + dy * dy;
			return distanceSquared < radius * radius
				? MathF.Exp( -0.5f * distanceSquared / (spread * spread) ) : 0;
		}
	}

	void Splat( float x, float y, float vx, float vy, bool spread )
	{
		var (gx, gy) = WorldToGrid( x, y );
		int reach = (int)MathF.Ceiling( 2 * FlowUnit / CellSize );
		int left = Math.Max( 1, (int)MathF.Floor( gx ) - reach );
		int right = Math.Min( Columns - 2, (int)MathF.Ceiling( gx ) + reach );
		int top = Math.Max( 1, (int)MathF.Floor( gy ) - reach );
		int bottom = Math.Min( Rows - 2, (int)MathF.Ceiling( gy ) + reach );
		for ( int j = top; j <= bottom; j++ )
		for ( int i = left; i <= right; i++ )
		{
			float ox = (i - gx) * CellSize / FlowUnit, oy = (j - gy) * CellSize / FlowUnit;
			float weight = MathF.Exp( -(ox * ox + oy * oy) * (spread ? 0.14f : 0.275f) );
			int n = j * Columns + i;
			velocityX[n] += vx * weight;
			velocityY[n] += vy * weight;
			influence[n] = MathF.Max( influence[n], MathF.Min( 1, weight * 1.25f ) );
		}
	}

	float Bilinear( float[] field, float x, float y, int stride = 1, int channel = 0 )
	{
		x = Math.Clamp( x, 1, Columns - 2.001f ); y = Math.Clamp( y, 1, Rows - 2.001f );
		int i = (int)x, j = (int)y;
		float fx = x - i, fy = y - j;
		int n = (j * Columns + i) * stride + channel;
		return (field[n] * (1 - fx) + field[n + stride] * fx) * (1 - fy) +
			(field[n + Columns * stride] * (1 - fx) + field[n + (Columns + 1) * stride] * fx) * fy;
	}

	void Step( float dt )
	{
		// Every color and source fades equally, including during game-over cleanup.
		float dyeRetention = MathF.Pow( 1 - DyeFadeRate * 0.01f, dt );
		// Damp, diffuse, then project the velocity so a push curls around nearby
		// gems instead of remaining a straight colored streak.
		for ( int y = 1; y < Rows - 1; y++ )
		for ( int x = 1; x < Columns - 1; x++ )
		{
			int n = y * Columns + x;
			if ( wallDistance[n] < 0 )
			{
				nextX[n] = wallVelocity[n].x; nextY[n] = wallVelocity[n].y;
				continue;
			}
			var (bx, by) = Backtrace( x, y, velocityX[n], velocityY[n], dt );
			nextX[n] = (Bilinear( velocityX, bx, by ) * 0.985f +
				(velocityX[n - 1] + velocityX[n + 1] + velocityX[n - Columns] + velocityX[n + Columns]) * 0.0035f) * 0.992f;
			nextY[n] = (Bilinear( velocityY, bx, by ) * 0.985f +
				(velocityY[n - 1] + velocityY[n + 1] + velocityY[n - Columns] + velocityY[n + Columns]) * 0.0035f) * 0.992f;
		}
		for ( int y = 1; y < Rows - 1; y++ )
		for ( int x = 1; x < Columns - 1; x++ )
		{
			int n = y * Columns + x;
			divergence[n] = wallDistance[n] < 0 ? 0 : -0.5f * (nextX[n + 1] - nextX[n - 1] + nextY[n + Columns] - nextY[n - Columns]);
			pressure[n] = 0;
		}
		for ( int iteration = 0; iteration < 10; iteration++ )
		{
			for ( int y = 1; y < Rows - 1; y++ )
			for ( int x = 1; x < Columns - 1; x++ )
			{
				int n = y * Columns + x;
				nextPressure[n] = wallDistance[n] < 0 ? 0 : (divergence[n] + NeighborPressure( n - 1, n ) +
					NeighborPressure( n + 1, n ) + NeighborPressure( n - Columns, n ) + NeighborPressure( n + Columns, n )) * 0.25f;
			}
			// Both buffers have zero boundary cells; swap instead of copying the whole grid.
			(pressure, nextPressure) = (nextPressure, pressure);
		}
		for ( int y = 1; y < Rows - 1; y++ )
		for ( int x = 1; x < Columns - 1; x++ )
		{
			int n = y * Columns + x;
			if ( wallDistance[n] < 0 )
			{
				velocityX[n] = wallVelocity[n].x; velocityY[n] = wallVelocity[n].y;
				continue;
			}
			// Project throughout the fluid, with no flow through the gem boundaries.
			float local = Math.Clamp( influence[n] * 1.1f, 0, 1 );
			float vx = nextX[n] - 0.5f * (NeighborPressure( n + 1, n ) - NeighborPressure( n - 1, n ));
			// Preserve the same upward drift after aligning the velocity axes to the screen.
			float vy = nextY[n] - 0.5f * (NeighborPressure( n + Columns, n ) - NeighborPressure( n - Columns, n )) - 0.0011313708f * local;
			if ( wallDistance[n] < CellSize )
			{
				float incoming = (vx - wallVelocity[n].x) * wallNormal[n].x + (vy - wallVelocity[n].y) * wallNormal[n].y;
				if ( incoming < 0 ) { vx -= wallNormal[n].x * incoming * 1.65f; vy -= wallNormal[n].y * incoming * 1.65f; }
			}
			float speed = MathF.Sqrt( vx * vx + vy * vy );
			float limit = speed > MaxFlowVelocity ? MaxFlowVelocity / speed : 1;
			velocityX[n] = vx * limit; velocityY[n] = vy * limit;
			Vector2 point = GridToWorld( x, y );
			float worldX = velocityX[n];
			float worldY = velocityY[n];
			if ( (point.x <= FlowUnit && worldX < 0) ||
				(point.x >= DiamondBoard.BoardWidth - FlowUnit && worldX > 0) ) worldX *= -WallBounce;
			if ( point.y >= DiamondBoard.BoardHeight - FlowUnit && worldY > 0 ) worldY *= -WallBounce;
			velocityX[n] = worldX;
			velocityY[n] = worldY;
			influence[n] *= 0.96f;
		}
		TransportDye( dt, dyeRetention );
	}
}