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