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