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

namespace Diamonds;

public sealed partial class DiamondAtmosphere
{
	const float ObstacleReach = CellSize * 4;
	const float MaxFlowVelocity = 1050 / (FlowUnit * FlowSpeed);
	float[] wallDistance = new float[Count];
	Vector2[] wallNormal = new Vector2[Count], wallVelocity = new Vector2[Count];
	float[] stationaryDistance = new float[Count];
	Vector2[] stationaryNormal = new Vector2[Count];
	int[] escape = new int[Count], floodQueue = new int[Count];
	readonly List<int> solidCells = new();
	readonly List<int> stationaryCells = new();
	readonly List<int> stationaryInfluenceCells = new();
	readonly List<Vector2> stationaryPositions = new();
	bool stationaryCacheValid;
	readonly List<FluidBody> fluidBodies = new();
	readonly List<DiamondBoard.Diamond> oldActiveCells = new();
	readonly List<DiamondBoard.Diamond> activeCells = new();
	bool previousActiveVisible;
	int obstacleSpawnVersion = -1;
	bool reuseStationaryWalls;
	readonly record struct FluidBody( Vector2 From, Vector2 To, Vector2 Velocity );

	void ClearObstacles()
	{
		if ( wallDistance.Length != Count )
		{
			wallDistance = new float[Count]; wallNormal = new Vector2[Count]; wallVelocity = new Vector2[Count];
			stationaryDistance = new float[Count]; stationaryNormal = new Vector2[Count];
			escape = new int[Count]; floodQueue = new int[Count];
		}
		Array.Fill( wallDistance, float.PositiveInfinity );
		Array.Clear( wallVelocity ); Array.Clear( wallNormal );
		solidCells.Clear(); fluidBodies.Clear(); oldActiveCells.Clear(); activeCells.Clear();
		stationaryCells.Clear(); reuseStationaryWalls = false;
		stationaryInfluenceCells.Clear(); stationaryPositions.Clear(); stationaryCacheValid = false;
		previousActiveVisible = false;
		obstacleSpawnVersion = -1;
	}

	void UpdateObstacles( DiamondBoard board, float delta )
	{
		fluidBodies.Clear(); oldActiveCells.Clear();
		if ( hasPrevious && previousActiveVisible ) DiamondBoard.CopyCellsOf( previousActive, oldActiveCells );
		bool activeVisible = !board.GameOver && !board.IsResolving;
		bool sameActive = activeVisible && previousActiveVisible && obstacleSpawnVersion == board.SpawnVersion;
		float longest = 0;
		int activeIndex = 0;
		if ( activeVisible )
		{
			DiamondBoard.CopyCellsOf( board.Active, activeCells );
			foreach ( var cell in activeCells )
			{
				Add( cell, sameActive && activeIndex < oldActiveCells.Count ? oldActiveCells[activeIndex] : cell );
				activeIndex++;
			}
		}
		bool sameStack = hasPrevious && previousSettled.Count == board.Settled.Count;
		bool justLanded = hasPrevious && board.Placed == previousPlaced + 1 &&
			board.Settled.Count == previousSettled.Count + oldActiveCells.Count;
		for ( int i = 0; i < board.Settled.Count; i++ )
		{
			var cell = board.Settled[i];
			var before = cell;
			if ( (sameStack || justLanded) && i < previousSettled.Count && previousSettled[i].ColorIndex == cell.ColorIndex )
				before = previousSettled[i];
			else if ( justLanded && i >= previousSettled.Count ) before = oldActiveCells[i - previousSettled.Count];
			Add( cell, before );
		}
		// Dead gems remain intact during the damage pause, then release their space.
		if ( board.IsDamagePaused ) foreach ( var cell in board.Shattering ) Add( cell, cell );
		int sweeps = Math.Max( 1, (int)MathF.Ceiling( longest / (CellSize * 0.75f) ) );
		int stationaryStart = 0;
		if ( sweeps == 1 )
		{
			// Cache only the stationary suffix so overlapping faces retain their original
			// tie order. During settling, any earlier static gems are rebuilt with movers.
			stationaryStart = fluidBodies.Count;
			while ( stationaryStart > 0 && fluidBodies[stationaryStart - 1].From == fluidBodies[stationaryStart - 1].To ) stationaryStart--;
		}
		PrepareStationaryWalls( stationaryStart );
		reuseStationaryWalls = sweeps > 1;
		for ( int sweep = 1; sweep <= sweeps; sweep++ )
		{
			BuildWalls( sweep / (float)sweeps, endBody: sweeps > 1 ? fluidBodies.Count : stationaryStart );
			if ( sweeps == 1 ) MergeStationaryWalls();
			DisplaceCoveredDye();
			PushMovingWalls( delta / sweeps );
		}
		reuseStationaryWalls = false;
		previousActiveVisible = activeVisible;
		obstacleSpawnVersion = board.SpawnVersion;

		void Add( DiamondBoard.Diamond cell, DiamondBoard.Diamond before )
		{
			var to = new Vector2( DiamondBoard.LaneX( cell.Lane ), cell.Y );
			var from = new Vector2( DiamondBoard.LaneX( before.Lane ), before.Y );
			var motion = to - from;
			// Reindexed survivors/teleports must not sweep across the entire board.
			if ( motion.Length > DiamondBoard.BoardHeight * 0.8f ) { from = to; motion = Vector2.Zero; }
			longest = MathF.Max( longest, motion.Length );
			var worldVelocity = motion / delta;
			if ( worldVelocity.Length > 1050 ) worldVelocity = worldVelocity.Normal * 1050;
			var v = worldVelocity / (FlowUnit * FlowSpeed);
			fluidBodies.Add( new( from, to, v ) );
		}
	}

	void PrepareStationaryWalls( int firstBody )
	{
		int count = 0;
		bool unchanged = stationaryCacheValid;
		for ( int i = firstBody; i < fluidBodies.Count; i++ )
		{
			var body = fluidBodies[i];
			if ( body.From != body.To ) continue;
			if ( count >= stationaryPositions.Count || stationaryPositions[count] != body.To ) unchanged = false;
			count++;
		}
		if ( unchanged && count == stationaryPositions.Count ) return;
		stationaryPositions.Clear();
		for ( int i = firstBody; i < fluidBodies.Count; i++ )
			if ( fluidBodies[i].From == fluidBodies[i].To ) stationaryPositions.Add( fluidBodies[i].To );
		BuildWalls( 1, stationaryOnly: true, firstBody: firstBody );
		Array.Copy( wallDistance, stationaryDistance, Count );
		Array.Copy( wallNormal, stationaryNormal, Count );
		stationaryCells.Clear(); stationaryCells.AddRange( solidCells );
		stationaryInfluenceCells.Clear();
		for ( int n = 0; n < Count; n++ )
			if ( !float.IsPositiveInfinity( stationaryDistance[n] ) ) stationaryInfluenceCells.Add( n );
		stationaryCacheValid = true;
	}

	void MergeStationaryWalls()
	{
		// Preserve the same solid-cell traversal order as drawing the suffix after
		// the moving prefix; the displacement flood uses this order to resolve ties.
		foreach ( int n in stationaryCells )
			if ( wallDistance[n] >= 0 ) solidCells.Add( n );
		foreach ( int n in stationaryInfluenceCells )
		{
			if ( stationaryDistance[n] >= wallDistance[n] ) continue;
			wallDistance[n] = stationaryDistance[n];
			wallNormal[n] = stationaryNormal[n];
			wallVelocity[n] = Vector2.Zero;
		}
	}

	void BuildWalls( float fraction, bool stationaryOnly = false, int firstBody = 0, int endBody = int.MaxValue )
	{
		solidCells.Clear();
		if ( reuseStationaryWalls )
		{
			Array.Copy( stationaryDistance, wallDistance, Count );
			Array.Copy( stationaryNormal, wallNormal, Count );
			Array.Clear( wallVelocity );
			solidCells.AddRange( stationaryCells );
		}
		else Array.Fill( wallDistance, float.PositiveInfinity );
		const float halfWidth = DiamondBoard.Width * 0.5f, halfHeight = DiamondBoard.Height * 0.5f;
		float inverseNormalLength = 1 / MathF.Sqrt( 1 / (halfWidth * halfWidth) + 1 / (halfHeight * halfHeight) );
		for ( int bodyIndex = firstBody; bodyIndex < fluidBodies.Count && bodyIndex < endBody; bodyIndex++ )
		{
			var body = fluidBodies[bodyIndex];
			bool stationary = body.From == body.To;
			if ( stationaryOnly && !stationary || reuseStationaryWalls && stationary ) continue;
			var center = body.From + (body.To - body.From) * fraction;
			var (gx, gy) = WorldToGrid( center.x, center.y );
			int reach = (int)MathF.Ceiling( (halfHeight + ObstacleReach) / CellSize );
			int left = Math.Max( 1, (int)gx - reach ), right = Math.Min( Columns - 2, (int)gx + reach + 1 );
			int top = Math.Max( 1, (int)gy - reach ), bottom = Math.Min( Rows - 2, (int)gy + reach + 1 );
			for ( int y = top; y <= bottom; y++ )
			for ( int x = left; x <= right; x++ )
			{
				var offset = GridToWorld( x, y ) - center;
				// The four sloped face half-planes give exact occupancy and face normals.
				float distance = (MathF.Abs( offset.x ) / halfWidth + MathF.Abs( offset.y ) / halfHeight - 1) * inverseNormalLength;
				int n = y * Columns + x;
				if ( distance > ObstacleReach || distance >= wallDistance[n] ) continue;
				if ( distance < 0 && wallDistance[n] >= 0 ) solidCells.Add( n );
				wallDistance[n] = distance;
				float nx = (offset.x < 0 ? -1 : 1) * inverseNormalLength / halfWidth;
				float ny = (offset.y < 0 ? -1 : 1) * inverseNormalLength / halfHeight;
				wallNormal[n] = new Vector2( nx, ny );
				wallVelocity[n] = body.Velocity;
			}
		}
	}

	void DisplaceCoveredDye()
	{
		// Most frames leave the already-empty solid cells untouched. Avoid rebuilding
		// the escape flood and clearing its scratch buffers unless dye needs moving.
		bool covered = false;
		foreach ( int n in solidCells )
		{
			for ( int c = 0; c < DiamondBoard.ColorCount; c++ ) covered |= dye[n * DiamondBoard.ColorCount + c] > 0;
			if ( covered ) break;
		}
		if ( !covered ) return;
		DyeVersion++;
		// Flood inward from the union's exposed boundary, so a shared face never
		// pushes dye into the adjacent gem. Every solid cell gets an open receiver.
		Array.Fill( escape, -1 );
		int head = 0, tail = 0;
		foreach ( int n in solidCells )
		{
			int receiver = -1;
			float best = float.NegativeInfinity;
			Choose( n - 1, -wallNormal[n].x ); Choose( n + 1, wallNormal[n].x );
			Choose( n - Columns, -wallNormal[n].y ); Choose( n + Columns, wallNormal[n].y );
			if ( receiver >= 0 ) { escape[n] = receiver; floodQueue[tail++] = n; }
			void Choose( int other, float score )
			{
				if ( wallDistance[other] < 0 || score <= best ) return;
				best = score; receiver = other;
			}
		}
		while ( head < tail )
		{
			int n = floodQueue[head++];
			Visit( n - 1 ); Visit( n + 1 ); Visit( n - Columns ); Visit( n + Columns );
			void Visit( int other )
			{
				if ( wallDistance[other] >= 0 || escape[other] >= 0 ) return;
				escape[other] = escape[n]; floodQueue[tail++] = other;
			}
		}
		// The flood traversal is complete; reuse its scratch array to count contributors.
		Array.Clear( floodQueue );
		foreach ( int n in solidCells )
		{
			int receiver = escape[n];
			bool hasDye = false;
			for ( int c = 0; c < DiamondBoard.ColorCount; c++ ) hasDye |= dye[n * DiamondBoard.ColorCount + c] > 0;
			// Already-empty solid cells must not dilute their neighbors every frame.
			if ( !hasDye ) continue;
			if ( receiver >= 0 ) floodQueue[receiver]++;
			for ( int c = 0; c < DiamondBoard.ColorCount; c++ )
			{
				if ( receiver >= 0 ) dye[receiver * DiamondBoard.ColorCount + c] += dye[n * DiamondBoard.ColorCount + c];
				dye[n * DiamondBoard.ColorCount + c] = 0;
			}
		}
		// Backward transport copies concentrations, so additive compression at moving
		// walls would amplify the same dye repeatedly. Blend covered cells into each
		// receiver instead, keeping concentrations within the local source range.
		foreach ( int n in solidCells )
		{
			int receiver = escape[n];
			if ( receiver < 0 || floodQueue[receiver] == 0 ) continue;
			float weight = 1f / (floodQueue[receiver] + 1);
			for ( int c = 0; c < DiamondBoard.ColorCount; c++ ) dye[receiver * DiamondBoard.ColorCount + c] *= weight;
			floodQueue[receiver] = 0;
		}
	}

	void PushMovingWalls( float delta )
	{
		for ( int n = 0; n < Count; n++ )
		{
			if ( wallDistance[n] < 0 )
			{
				velocityX[n] = wallVelocity[n].x; velocityY[n] = wallVelocity[n].y;
				continue;
			}
			if ( wallDistance[n] >= CellSize * 2 ) continue;
			float approach = (velocityX[n] - wallVelocity[n].x) * wallNormal[n].x +
				(velocityY[n] - wallVelocity[n].y) * wallNormal[n].y;
			if ( approach >= 0 ) continue;
			float push = approach * (1 - wallDistance[n] / (CellSize * 2)) * Math.Clamp( delta * 120, 0, 1 );
			velocityX[n] -= wallNormal[n].x * push;
			velocityY[n] -= wallNormal[n].y * push;
		}
	}

	(float X, float Y) TraceFluid( float x, float y, float tx, float ty )
	{
		int origin = (int)y * Columns + (int)x;
		// Cells outside every obstacle's influence need no collision trace or length calculation.
		if ( float.IsPositiveInfinity( wallDistance[origin] ) ) return (tx, ty);
		float dx = tx - x, dy = ty - y;
		float length = MathF.Sqrt( dx * dx + dy * dy );
		if ( wallDistance[origin] > (length + 1) * CellSize ) return (tx, ty);
		int steps = Math.Max( 1, (int)MathF.Ceiling( length * 2 ) );
		float px = x, py = y;
		for ( int i = 1; i <= steps; i++ )
		{
			float nx = x + dx * i / steps, ny = y + dy * i / steps;
			int ix = Math.Clamp( (int)MathF.Round( nx ), 0, Columns - 1 );
			int iy = Math.Clamp( (int)MathF.Round( ny ), 0, Rows - 1 );
			if ( wallDistance[iy * Columns + ix] < 0 ) break;
			px = nx; py = ny;
		}
		return (px, py);
	}

	float NeighborPressure( int other, int own ) => wallDistance[other] < 0 ? pressure[own] : pressure[other];
}