DiamondBoard.cs
using System;
using System.Collections.Generic;
namespace Diamonds;
/// <summary>Diamond geometry and gameplay, independent of rendering and screen resolution.</summary>
public sealed partial class DiamondBoard
{
public const float Width = 56;
public const float Height = 88;
public const int Valleys = 17;
public const int LaneCount = Valleys * 2 - 1;
// Controlled pieces stay inside the tooth tips, including above the open top.
// Only automatic settlement may carry gems into the outer wall pockets.
public const int MinControlledLane = 1;
public const int MaxControlledLane = LaneCount - 2;
public const float BoardWidth = Valleys * Width;
// Keep the current vertical capacity and gem scale when narrowing the board.
public const float BoardHeight = 981;
public const int ColorCount = 5;
public const int MaxHealth = 3;
public const float DamageSpeedThreshold = Height * 8;
public const float DamagePauseDuration = 0.10f;
public const float DamageFlashDuration = 0.18f;
public const float DamageShineDuration = 0.18f;
public const float BondPulseDuration = 0.6f;
// Lowest tip starts 30 units above the playfield, giving the spawn dye a short head start.
public const float SpawnY = -Height * 0.5f - 48;
const float Epsilon = 0.01f;
readonly List<Diamond> settled = new();
readonly Queue<int> shapeBag = new();
bool openCornerPockets;
/// <summary>Changing the arena boundary starts a fresh board to avoid trapping existing pieces.</summary>
public bool FillCornerPockets
{
get => !openCornerPockets;
set
{
if ( value == FillCornerPockets ) return;
openCornerPockets = !value;
Reset();
}
}
float groundedTime;
bool hasLanded;
Piece? pendingSideLanding;
// Seconds the landing step is drawn over; its impact speed is the glide's, not one arrival frame's.
float pendingSideDuration;
bool pendingRotationLanding;
float repeatTime;
float rotateRepeatTime;
bool rotateHeld;
float softDropTime;
float spawnLane = LaneCount / 2;
const float MoveRepeatDelay = 0.12f;
const float MoveRepeatInterval = 0.045f;
const float SoftDropStartSpeed = 12;
const float SoftDropMaxSpeed = 72;
const float SoftDropRampDuration = 0.2f;
const float FaceLandingDelay = 0.04f;
const float CornerLandingDelay = 0.16f;
int lastDirection;
readonly Random random;
readonly Random effectRandom = new();
const float ShatterDuration = 0.32f;
const float GameOverHitPause = 0.12f;
const float GameOverSpeedStep = 0.15f;
int gameOverHits;
readonly List<Diamond> shattering = new();
public readonly record struct ShatterBurst( long Id, Diamond Cell );
readonly List<ShatterBurst> shatterBursts = new();
long shatterSequence;
public IReadOnlyList<ShatterBurst> ShatterBursts => shatterBursts;
float shatterTime;
readonly Queue<DiamondMatching.SettlementMove> cascadeMoves = new();
float cascadeTime;
float damagePause;
readonly List<DamageFlash> damageFlashes = new();
long damageFlashSequence;
public readonly record struct DamageFlash( float Lane, float Y, int ColorIndex, float Age = 0, float Rotation = 0, int DamageCount = 1, float Brightness = 0.4f, long Id = 0 )
{
public float Scale => 1 + 0.5f * Math.Max( 0, DamageCount - 1 );
}
public IReadOnlyList<DamageFlash> DamageFlashes => damageFlashes;
public readonly record struct CornerSpark( DiamondDamage.Contact Contact, float Speed, long Id );
readonly List<CornerSpark> cornerSparks = new();
long cornerSparkSequence;
public IReadOnlyList<CornerSpark> CornerSparks => cornerSparks;
public readonly record struct FaceFlash( DiamondDamage.FaceContact Contact, float Age = 0,
float Seed = 0, float Brightness = 0.4f, int DamageCount = 1, long Id = 0 );
long faceFlashSequence;
readonly List<FaceFlash> faceFlashes = new();
public IReadOnlyList<FaceFlash> FaceFlashes => faceFlashes;
public sealed class DamageShine
{
public float Age { get; internal set; }
public int Direction { get; internal set; }
public float ShakeAngle { get; internal set; }
public List<int> SettledIndices { get; } = new();
public List<int> ShatteringIndices { get; } = new();
}
readonly List<DamageShine> damageShines = new();
public IReadOnlyList<DamageShine> DamageShines => damageShines;
public readonly record struct BondPulse( IReadOnlyList<DiamondOutline.Edge> Edges, float Age = 0 );
readonly List<BondPulse> bondPulses = new();
public IReadOnlyList<BondPulse> BondPulses => bondPulses;
public bool IsDamagePaused => damagePause > 0;
readonly List<Impact> impacts = new();
public readonly record struct Impact( float VelocityX, float VelocityY, int CellCount, DiamondContact.Faces Faces = DiamondContact.Faces.None )
{
/// <summary>Lowest health after this hit among its damaged receivers, including bonded propagation; null for harmless contacts.</summary>
public int? LowestDamagedHealth { get; init; }
/// <summary>The player's piece making first contact, by falling, stepping or turning.</summary>
public bool Active { get; init; }
/// <summary>The end of a diagonal settlement slide.</summary>
public bool Slide { get; init; }
/// <summary>A moving diamond reached the floor (or a filled corner pocket).</summary>
public bool Floor { get; init; }
public float Speed => MathF.Sqrt( VelocityX * VelocityX + VelocityY * VelocityY );
public (float X, float Y) ShakeAxis
{
get
{
// Only single-face vertical landings lean toward the struck face.
// Tip contacts, crannies, and diagonal slides retain the movement axis.
if ( MathF.Abs( VelocityX ) > Epsilon || VelocityY <= 0 ||
(Faces != DiamondContact.Faces.LowerLeft && Faces != DiamondContact.Faces.LowerRight) )
return (VelocityX / MathF.Max( 1, Speed ), VelocityY / MathF.Max( 1, Speed ));
float normalLength = MathF.Sqrt( Height * Height + Width * Width );
float x = (Faces == DiamondContact.Faces.LowerLeft ? -Height : Height) / normalLength;
float y = 1 + Width / normalLength;
// Bisect down and the outward lower-face normal (pointing into the support).
float length = MathF.Sqrt( x * x + y * y );
return (x / length, y / length);
}
}
}
/// <summary>Contacts in the most recent update, emitted at contact rather than after the lock delay.</summary>
public IReadOnlyList<Impact> Impacts => impacts;
public int ResetVersion { get; private set; }
public int SpawnVersion { get; private set; }
/// <summary>Changes when settled cell indices acquire new owners, even if the count stays the same.</summary>
public int SettledVersion { get; private set; }
public float SlideSpeed
{
get
{
if ( Paused || !IsSliding ) return 0;
var move = cascadeMoves.Peek();
var cell = move.Cells[0];
float dx = (cell.To.Lane - cell.From.Lane) * Width * 0.5f;
float dy = cell.To.Y - cell.From.Y;
return MathF.Sqrt( dx * dx + dy * dy ) * move.VelocityScale( cascadeTime / move.Duration, CascadeSpeed );
}
}
// Fractional lanes are used only while animating a diagonal slide.
// Each cell keeps its color through rotation (three bits per cell, up to four cells).
public readonly record struct Piece( float Lane, float Y, int ShapeIndex, int Rotation = 0, int Colors = 0, int Health = 255 )
{
public int CellColor( int index ) => (Colors >> (index * 3)) & 7;
public int CellHealth( int index ) => (Health >> (index * 2)) & 3;
}
public enum Corner { Top, Right, Bottom, Left }
public readonly record struct Diamond( float Lane, float Y, int ColorIndex, int Health = MaxHealth, Corner FirstCrack = Corner.Top );
public IReadOnlyList<Diamond> Settled => settled;
// Rendering uses the same frozen bonds as settling, never transient side contacts.
public IReadOnlyList<IReadOnlyList<int>> BondedGroups { get; private set; } = Array.Empty<IReadOnlyList<int>>();
public Piece Active { get; private set; }
public Piece Next { get; private set; }
public int NextShapeIndex => Next.ShapeIndex;
public int Placed { get; private set; }
public int Destroyed { get; private set; }
/// <summary>Diamonds shattered by play this game. Unlike <see cref="Destroyed"/>, game-over cleanup is excluded.</summary>
public int DiamondsBroken { get; private set; }
/// <summary>Shards crushed for points this game.</summary>
public int ShardsCrushed { get; private set; }
/// <summary>Most diamonds of one established bonded group damaged by a single collision this game. Game-over cleanup is excluded.</summary>
public int LargestDamagedGroup { get; private set; }
/// <summary>Most distinct colors shattered by play within one turn (a spawn until the next) this game.</summary>
public int MostColorsBrokenInTurn { get; private set; }
// Bit per color shattered during the current turn.
int turnBrokenColors;
/// <summary>Size of the largest bonded group resting in the arena the moment the game ended; zero until then.</summary>
public int LargestGroupAtGameOver { get; private set; }
/// <summary>Unpaused seconds of <see cref="FirstMinuteScore"/>'s window.</summary>
public const float FirstMinuteSeconds = 60;
/// <summary>Score reached within the first <see cref="FirstMinuteSeconds"/> of this game.</summary>
public long FirstMinuteScore { get; private set; }
public const int ShardCrushPoints = 1;
public long Score { get; private set; }
/// <summary>Debug score applied on the next new game or restart.</summary>
public long StartingScore { get; set; }
public float ElapsedSeconds { get; private set; }
float startingFallSpeed = 1.0f, fallRampMinutes = 10.0f, fallRampMultiplier = 3.0f, fallRampExponent = 2.2f;
public float StartingFallSpeed
{
get => startingFallSpeed > 0 ? startingFallSpeed : 1.0f;
set => startingFallSpeed = value > 0 ? value : 1.0f;
}
public float FallRampMinutes
{
get => fallRampMinutes > 0 ? fallRampMinutes : 10.0f;
set => fallRampMinutes = value > 0 ? value : 10.0f;
}
public float FallRampMultiplier
{
get => fallRampMultiplier >= 1 ? fallRampMultiplier : 3.0f;
set => fallRampMultiplier = value >= 1 ? value : 3.0f;
}
public float FallRampExponent
{
get => fallRampExponent > 0 ? fallRampExponent : 2.2f;
set => fallRampExponent = value > 0 ? value : 2.2f;
}
// The reference multiplier is reached at FallRampMinutes; the curve keeps rising afterward.
public float CurrentNormalFallSpeed => StartingFallSpeed *
(1 + (FallRampMultiplier - 1) * MathF.Pow( MathF.Max( 0, ElapsedSeconds / (FallRampMinutes * 60) ), FallRampExponent ));
float? colorMixStartMinutes, colorMixRampMinutes, colorMixChanceAtReference, colorMixRampExponent, fourColorShare;
public float ColorMixStartMinutes
{
get => colorMixStartMinutes ?? 5.0f;
set => colorMixStartMinutes = MathF.Max( 0, value );
}
public float ColorMixRampMinutes
{
get => colorMixRampMinutes ?? 10.0f;
set => colorMixRampMinutes = value > 0 ? value : 10.0f;
}
public float ColorMixChanceAtReference
{
get => colorMixChanceAtReference ?? 50.0f;
set => colorMixChanceAtReference = Math.Clamp( value, 0, 100 );
}
public float ColorMixRampExponent
{
get => colorMixRampExponent ?? 2.0f;
set => colorMixRampExponent = value > 0 ? value : 2.0f;
}
public float FourColorShare
{
get => fourColorShare ?? 50.0f;
set => fourColorShare = Math.Clamp( value, 0, 100 );
}
/// <summary>Chance that an eligible piece uses three or four distinct colors; reaches 100% eventually.</summary>
public float CurrentColorMixChance
{
get
{
float elapsed = ElapsedSeconds - ColorMixStartMinutes * 60;
if ( elapsed <= 0 ) return 0;
float progress = elapsed / (ColorMixRampMinutes * 60);
return Math.Clamp( ColorMixChanceAtReference * 0.01f * MathF.Pow( progress, ColorMixRampExponent ), 0, 1 );
}
}
public long NextShardPoints { get; private set; } = ShardCrushPoints;
// A spawn ends the turn, but debris steps after the board in the same frame, over the final move's last gap
// where crushes peak. Those still belong to the ending turn, so the award resets on the next board update.
bool shardTurnEnded;
public void AwardShardCrush( int count )
{
if ( count <= 0 || GameOver ) return;
Score += count * NextShardPoints + (long)count * (count - 1) / 2;
NextShardPoints += count;
ShardsCrushed += count;
if ( ElapsedSeconds <= FirstMinuteSeconds ) FirstMinuteScore = Score;
}
public int Chain { get; private set; }
public bool IsShattering => shattering.Count > 0;
public bool IsCascading => cascadeMoves.Count > 0;
public bool IsResolving => IsShattering || IsCascading || IsDamagePaused;
// Add half the base speed at each chain stage, capped at 3x.
// Derive this from the current reaction rather than carrying speed into the next turn.
float GameOverCleanupSpeed => MathF.Min( 3, 1 + GameOverSpeedStep * Math.Max( 0, gameOverHits - 1 ) );
public float CascadeSpeed => !IsResolving ? 1 : GameOver
? GameOverCleanupSpeed
: 1 + 0.5f * Math.Clamp( Chain - 1, 0, 4 );
public IReadOnlyList<Diamond> Shattering => shattering;
public float ShatterProgress => 1 - shatterTime / ShatterDuration;
public bool GameOver { get; private set; }
public int GameOverHits => gameOverHits;
public bool Paused { get; private set; }
public bool IsSliding => !IsDamagePaused && IsCascading && cascadeMoves.Peek().IsSlide;
/// <summary>The current rigid sliding assembly, for surface friction effects.</summary>
public IReadOnlyList<DiamondMatching.CellMove> SlidingCells => IsSliding ? cascadeMoves.Peek().Cells : Array.Empty<DiamondMatching.CellMove>();
public float LandingY => FindLandingY( Active );
// Player steps and turns still snap on the lattice; rendering trails each
// successful one briefly so it reads as motion. Collision is unaffected.
// Taps ease out. Held repeats are linear over exactly one repeat interval, so
// consecutive steps join into constant-speed motion instead of pulsing.
public readonly record struct ControlMotion( float Lane, float Y, float Turns, float Duration, float Age = 0, bool Linear = false )
{
/// <summary>The fraction of this step the drawn piece has yet to cover.</summary>
public float Remaining => Age >= Duration ? 0 : Linear ? 1 - Age / Duration : (1 - Age / Duration) * (1 - Age / Duration);
}
// Lazily created: hotload keeps existing boards without running new initializers.
List<ControlMotion> controlMotions;
float? moveSmoothing, rotateSmoothing, rotateRepeatDelay, rotateRepeatInterval;
/// <summary>Seconds a sideways step takes to draw; zero snaps.</summary>
public float MoveSmoothing
{
get => moveSmoothing ?? 0.07f;
set => moveSmoothing = Math.Clamp( value, 0, 0.5f );
}
/// <summary>Seconds a quarter turn takes to draw; zero snaps.</summary>
public float RotateSmoothing
{
get => rotateSmoothing ?? 0.09f;
set => rotateSmoothing = Math.Clamp( value, 0, 0.5f );
}
public float RotateRepeatDelay
{
get => rotateRepeatDelay ?? 0.22f;
set => rotateRepeatDelay = Math.Clamp( value, 0.05f, 2 );
}
public float RotateRepeatInterval
{
get => rotateRepeatInterval ?? 0.12f;
set => rotateRepeatInterval = Math.Clamp( value, 0.03f, 2 );
}
/// <summary>Whether the drawn active piece is still catching up with its lattice position.</summary>
public bool IsControlAnimating => controlMotions?.Count > 0;
/// <summary>How far the drawn active piece trails its lattice position, in lanes and units.</summary>
public (float Lane, float Y) ControlOffset
{
get
{
float lane = 0, y = 0;
if ( controlMotions is not null )
foreach ( var motion in controlMotions )
{
lane += motion.Lane * motion.Remaining;
y += motion.Y * motion.Remaining;
}
return (lane, y);
}
}
/// <summary>Clockwise quarter turns the drawn active piece still trails its lattice rotation.</summary>
public float ControlTurns
{
get
{
float turns = 0;
if ( controlMotions is not null )
foreach ( var motion in controlMotions ) turns += motion.Turns * motion.Remaining;
// A bump turns the drawn piece ahead of its lattice rotation, so it counts negative.
return turns - bump.Amount( 0 );
}
}
/// <summary>A turn succeeded during the latest update, with the same piece still active.</summary>
public bool RotatedThisUpdate { get; private set; }
/// <summary>A turn was refused (drawn as a bump) during the latest update.</summary>
public bool RotationRefusedThisUpdate { get; private set; }
void AddControlMotion( float lane, float y, float turns, float duration, float age = 0, bool linear = false )
{
if ( duration > 0 && age < duration ) (controlMotions ??= new()).Add( new( lane, y, turns, duration, age, linear ) );
}
/// <summary>When a held repeat stops, decelerate its remaining glide from the same speed.</summary>
void EaseOutRepeatMotions()
{
// Mid-turn, slowing a glide changes where the turning piece is drawn; keep it linear if that would hit something.
if ( CanEaseRepeatMotions() ) EaseRepeatMotionsInPlace();
}
void EaseRepeatMotionsInPlace()
{
if ( controlMotions is null ) return;
for ( int i = 0; i < controlMotions.Count; i++ )
{
var motion = controlMotions[i];
if ( !motion.Linear ) continue;
// A quadratic ease-out starts at twice its average speed; stretch it to match.
float remaining = motion.Remaining;
controlMotions[i] = new( motion.Lane * remaining, motion.Y * remaining, motion.Turns * remaining, 2 * remaining * motion.Duration );
}
}
void AdvanceControlMotions( float delta )
{
AdvanceBump( delta );
if ( controlMotions is null ) return;
for ( int i = controlMotions.Count - 1; i >= 0; i-- )
{
var motion = controlMotions[i] with { Age = controlMotions[i].Age + delta };
if ( motion.Age >= motion.Duration ) controlMotions.RemoveAt( i );
else controlMotions[i] = motion;
}
}
public float LockFlash { get; private set; }
public DiamondBoard( int? randomSeed = null )
{
random = randomSeed.HasValue ? new Random( randomSeed.Value ) : new Random();
Reset();
}
public static float LaneX( float lane ) => Width * 0.5f + lane * Width * 0.5f;
public static float FloorY( float lane ) => BoardHeight - Height * 0.5f
- (1 - MathF.Abs( lane % 2 - 1 )) * Height * 0.5f;
// Half-diamonds protrude from each wall, in phase with the floor valleys.
public static BoundarySequence SideWallTeeth() => new( walls: true, corners: false );
// Fill the last diamond-sized pocket at either end of the floor.
public static BoundarySequence CornerFills( bool fillCornerPockets = true ) => new( walls: false, corners: fillCornerPockets );
public static BoundarySequence BoundaryDiamonds( bool fillCornerPockets = true ) => new( walls: true, corners: fillCornerPockets );
/// <summary>Direct foreach loops enumerate the fixed boundary without allocating or caching hotload-sensitive geometry.</summary>
public readonly struct BoundarySequence : IEnumerable<Diamond>
{
readonly bool walls, corners;
internal BoundarySequence( bool walls, bool corners ) { this.walls = walls; this.corners = corners; }
public Enumerator GetEnumerator() => new( walls, corners );
// Preserve IEnumerable compatibility for setup-time LINQ and existing iterator callers.
IEnumerator<Diamond> IEnumerable<Diamond>.GetEnumerator() => GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator();
public struct Enumerator : IEnumerator<Diamond>
{
readonly int wallCount, start, end;
int index;
internal Enumerator( bool walls, bool corners )
{
wallCount = 2 * (int)MathF.Ceiling( (BoardHeight + Height * 0.5f) / Height );
start = walls ? 0 : wallCount;
end = wallCount + (corners ? 2 : 0);
index = start - 1;
}
public Diamond Current => index < wallCount
? new( index % 2 == 0 ? -1 : LaneCount, BoardHeight - index / 2 * Height, 0 )
: new( index == wallCount ? 0 : LaneCount - 1, BoardHeight - Height * 0.5f, 0 );
object System.Collections.IEnumerator.Current => Current;
public bool MoveNext() => ++index < end;
public void Reset() => index = start - 1;
public void Dispose() { }
}
}
public static float SideWallInset( float y, bool fillCornerPockets = true )
{
float inset = fillCornerPockets && y >= BoardHeight - Height && y <= BoardHeight
? Width * (1 - MathF.Abs( y - (BoardHeight - Height * 0.5f) ) / Height) : 0;
for ( float center = BoardHeight; center + Height * 0.5f > 0; center -= Height )
inset = MathF.Max( inset, Width * 0.5f * (1 - MathF.Abs( y - center ) / (Height * 0.5f)) );
return inset;
}
public static bool ClearsSideWalls( float lane, float y, float direction = 0, float drop = 0, bool fillCornerPockets = true )
{
foreach ( var tooth in BoundaryDiamonds( fillCornerPockets ) )
{
float crossX = direction == 0 ? 0 : Math.Clamp( (tooth.Lane - lane) / direction, 0, 1 );
float crossY = drop == 0 ? 0 : Math.Clamp( (tooth.Y - y) / drop, 0, 1 );
if ( !ClearAt( 0 ) || !ClearAt( 1 ) || !ClearAt( crossX ) || !ClearAt( crossY ) ) return false;
bool ClearAt( float t ) => MathF.Abs( lane + direction * t - tooth.Lane ) * 0.5f
+ MathF.Abs( y + drop * t - tooth.Y ) / Height >= 1 - Epsilon / Height;
}
return true;
}
public static float BoundaryLandingY( float lane, float fromY, bool fillCornerPockets = true )
{
float result = FloorY( lane );
foreach ( var tooth in BoundaryDiamonds( fillCornerPockets ) )
{
float dx = MathF.Abs( lane - tooth.Lane ) * 0.5f;
if ( dx >= 1 ) continue;
float contact = tooth.Y - Height * (1 - dx);
if ( contact >= fromY - Epsilon ) result = MathF.Min( result, contact );
}
return result;
}
IEnumerable<Diamond> CollisionObstacles()
{
foreach ( var cell in settled ) yield return cell;
foreach ( var tooth in BoundaryDiamonds( FillCornerPockets ) ) yield return tooth;
}
public void Reset()
{
ResetVersion++;
SettledVersion++;
impacts.Clear();
damageFlashes.Clear();
cornerSparks.Clear();
faceFlashes.Clear();
damageShines.Clear();
shatterBursts.Clear();
bondPulses.Clear();
damagePause = 0;
settled.Clear();
BondedGroups = Array.Empty<IReadOnlyList<int>>();
shapeBag.Clear();
Placed = 0;
Score = Math.Max( 0, StartingScore );
ElapsedSeconds = 0;
Destroyed = Chain = 0;
DiamondsBroken = ShardsCrushed = LargestDamagedGroup = MostColorsBrokenInTurn = LargestGroupAtGameOver = 0;
FirstMinuteScore = 0;
gameOverHits = 0;
shattering.Clear();
cascadeMoves.Clear();
cascadeTime = 0;
shatterTime = 0;
Next = CreatePiece();
GameOver = false;
Paused = false;
groundedTime = repeatTime = rotateRepeatTime = LockFlash = 0;
lastDirection = 0;
rotateHeld = RotatedThisUpdate = RotationRefusedThisUpdate = false;
spawnLane = LaneCount / 2;
Spawn();
// A new game starts its first turn at once.
NextShardPoints = ShardCrushPoints;
shardTurnEnded = false;
}
#if STANDALONE
#endif
/// <summary>Debug: start a fresh game over a packed stack whose top leaves <paramref name="freeHeights"/> diamond heights clear, continuing from <paramref name="score"/>.</summary>
public void FillToNearTop( float freeHeights, long score )
{
Reset();
Score = Math.Max( 0, score );
var colors = new Random();
// Row r sits r half-heights above the floor valleys, on lanes matching its parity.
int rows = Math.Max( 0, (int)MathF.Floor( (BoardHeight - Height - MathF.Max( 0, freeHeights ) * Height) / (Height * 0.5f) ) + 1 );
for ( int row = 0; row < rows; row++ )
for ( int lane = row % 2; lane < LaneCount; lane += 2 )
{
// Filled corner pockets already occupy the outermost floor valleys.
if ( row == 0 && FillCornerPockets && (lane == 0 || lane == LaneCount - 1) ) continue;
settled.Add( new( lane, BoardHeight - Height * 0.5f - row * Height * 0.5f, colors.Next( ColorCount ) ) );
}
SettledVersion++;
BondedGroups = DiamondMatching.FindGroups( settled );
}
public void TogglePause()
{
if ( !GameOver ) Paused = !Paused;
}
void Spawn()
{
SpawnVersion++;
shardTurnEnded = true;
turnBrokenColors = 0;
Active = Next;
float minLane = float.MaxValue, maxLane = float.MinValue, bottom = float.MinValue;
foreach ( var cell in CellsOf( Active ) )
{
minLane = MathF.Min( minLane, cell.Lane - Active.Lane );
maxLane = MathF.Max( maxLane, cell.Lane - Active.Lane );
bottom = MathF.Max( bottom, cell.Y - Active.Y );
}
Active = Active with { Lane = Math.Clamp( spawnLane, MinControlledLane - minLane, MaxControlledLane - maxLane ), Y = SpawnY - bottom };
Next = CreatePiece();
groundedTime = 0;
hasLanded = false;
pendingSideLanding = null;
pendingRotationLanding = false;
softDropTime = 0;
controlMotions?.Clear();
bump = default;
RotatedThisUpdate = RotationRefusedThisUpdate = false;
// A held key carries into the new piece at its usual cadence; drop steps a hitch left overdue when landing.
repeatTime = MathF.Max( 0, repeatTime );
rotateRepeatTime = MathF.Max( 0, rotateRepeatTime );
}
Piece CreatePiece()
{
int shape = TakeShape();
var cells = DiamondShapes.All[shape].Cells;
float mixChance = CurrentColorMixChance;
// Do not consume an extra random draw while the original color rules apply.
if ( cells.Count >= 3 && mixChance > 0 && random.NextDouble() < mixChance )
return CreateVariedPiece( shape, cells );
int first = random.Next( ColorCount );
int second = (first + random.Next( 1, ColorCount )) % ColorCount;
var colors = new int[cells.Count];
// Four-cell pieces choose 3+1 or 2+2 with equal probability.
Array.Fill( colors, second );
var connected = new List<int> { random.Next( colors.Length ) };
int count = colors.Length == 4 && random.Next( 2 ) == 0 ? 2 : Math.Min( 3, colors.Length );
if ( count == 2 && colors.Length == 4 )
{
var pairs = new List<(int A, int B)>();
var pairedHalves = new List<(int A, int B)>();
for ( int a = 0; a < cells.Count; a++ )
for ( int b = a + 1; b < cells.Count; b++ )
{
if ( !Adjacent( a, b ) ) continue;
pairs.Add( (a, b) );
var rest = new List<int>();
for ( int i = 0; i < cells.Count; i++ ) if ( i != a && i != b ) rest.Add( i );
if ( Adjacent( rest[0], rest[1] ) ) pairedHalves.Add( (a, b) );
}
// Prefer two bonded pairs. Trident has no such partition: its other
// two arms share a color but remain separate until they meet later.
var choices = pairedHalves.Count > 0 ? pairedHalves : pairs;
var pair = choices[random.Next( choices.Count )];
connected.Clear();
connected.Add( pair.A );
connected.Add( pair.B );
}
while ( connected.Count < count )
{
var candidates = new List<int>();
for ( int i = 0; i < cells.Count; i++ )
{
if ( connected.Contains( i ) ) continue;
foreach ( int j in connected )
if ( Adjacent( i, j ) )
{
candidates.Add( i );
break;
}
}
connected.Add( candidates[random.Next( candidates.Count )] );
}
foreach ( int i in connected ) colors[i] = first;
return PieceWithColors( shape, colors );
bool Adjacent( int a, int b ) => Math.Abs( cells[a].Lane - cells[b].Lane ) == 1 && Math.Abs( cells[a].Row - cells[b].Row ) == 1;
}
Piece CreateVariedPiece( int shape, IReadOnlyList<DiamondShapes.Cell> cells )
{
// Draw distinct colors without replacement from the five-color palette.
var palette = new int[ColorCount];
for ( int i = 0; i < palette.Length; i++ ) palette[i] = i;
for ( int i = 0; i < cells.Count; i++ )
{
int swap = i + random.Next( ColorCount - i );
(palette[i], palette[swap]) = (palette[swap], palette[i]);
}
var colors = new int[cells.Count];
if ( cells.Count == 3 || random.NextDouble() < FourColorShare * 0.01f )
{
for ( int i = 0; i < colors.Length; i++ ) colors[i] = palette[i];
}
else
{
// Three colors in a four-cell piece retain one connected same-color pair.
var pairs = new List<(int A, int B)>();
for ( int a = 0; a < cells.Count; a++ )
for ( int b = a + 1; b < cells.Count; b++ )
if ( Math.Abs( cells[a].Lane - cells[b].Lane ) == 1 && Math.Abs( cells[a].Row - cells[b].Row ) == 1 )
pairs.Add( (a, b) );
var pair = pairs[random.Next( pairs.Count )];
colors[pair.A] = colors[pair.B] = palette[0];
int nextColor = 1;
for ( int i = 0; i < colors.Length; i++ )
if ( i != pair.A && i != pair.B ) colors[i] = palette[nextColor++];
}
return PieceWithColors( shape, colors );
}
static Piece PieceWithColors( int shape, IReadOnlyList<int> colors )
{
int packed = 0;
for ( int i = 0; i < colors.Count; i++ ) packed |= colors[i] << (i * 3);
return new Piece( LaneCount / 2, SpawnY, shape, Colors: packed );
}
int TakeShape()
{
if ( shapeBag.Count == 0 )
{
var indices = new int[DiamondShapes.All.Count];
for ( int i = 0; i < indices.Length; i++ ) indices[i] = i;
for ( int i = indices.Length - 1; i > 0; i-- )
{
int j = random.Next( i + 1 );
(indices[i], indices[j]) = (indices[j], indices[i]);
}
foreach ( int index in indices ) shapeBag.Enqueue( index );
}
return shapeBag.Dequeue();
}
public static IEnumerable<Diamond> CellsOf( Piece piece )
{
var shape = DiamondShapes.All[piece.ShapeIndex];
for ( int i = 0; i < shape.Cells.Count; i++ )
{
if ( piece.CellHealth( i ) <= 0 ) continue;
yield return CellOf( piece, shape, i );
}
}
/// <summary>Replace a reusable cell list without allocating an iterator.</summary>
public static void CopyCellsOf( Piece piece, List<Diamond> target )
{
target.Clear();
var shape = DiamondShapes.All[piece.ShapeIndex];
for ( int i = 0; i < shape.Cells.Count; i++ )
if ( piece.CellHealth( i ) > 0 ) target.Add( CellOf( piece, shape, i ) );
}
static Diamond CellOf( Piece piece, DiamondShapes.Shape shape, int index )
{
var cell = shape.RotatedCell( index, piece.Rotation );
return new Diamond( piece.Lane + cell.Lane, piece.Y + cell.Row * Height * 0.5f, piece.CellColor( index ), piece.CellHealth( index ) );
}
public bool CanOccupy( float lane, float y ) => CanOccupy( Active with { Lane = lane, Y = y } );
public bool CanOccupy( Piece candidate )
{
foreach ( var cell in CellsOf( candidate ) )
if ( !CanDiamondOccupy( cell.Lane, cell.Y ) ) return false;
return true;
}
bool CanDiamondOccupy( float lane, float y )
{
if ( lane < MinControlledLane || lane > MaxControlledLane || y > FloorY( lane ) + Epsilon ) return false;
foreach ( var piece in CollisionObstacles() )
{
// Minkowski sum of two identical diamonds: |dx| / W + |dy| / H >= 1.
float separation = MathF.Abs( LaneX( lane ) - LaneX( piece.Lane ) ) / Width
+ MathF.Abs( y - piece.Y ) / Height;
if ( separation < 1 - Epsilon / Height ) return false;
}
return true;
}
float FindLandingY( Piece piece )
{
float result = float.MaxValue;
foreach ( var cell in CellsOf( piece ) )
result = MathF.Min( result, FindDiamondLandingY( cell.Lane, cell.Y ) - (cell.Y - piece.Y) );
return result;
}
float FindDiamondLandingY( float lane, float fromY )
{
float result = BoundaryLandingY( lane, fromY, FillCornerPockets );
foreach ( var piece in settled )
{
float dx = MathF.Abs( LaneX( lane ) - LaneX( piece.Lane ) );
if ( dx >= Width ) continue;
float contact = piece.Y - Height * (1 - dx / Width);
if ( contact >= fromY - Epsilon ) result = MathF.Min( result, contact );
}
return result;
}
public bool TryMove( int direction ) => TryMove( direction, null );
/// <summary>A held repeat passes how long ago its step was due, keeping the glide in sub-frame time.</summary>
bool TryMove( int direction, float? repeatAge )
{
if ( GameOver || Paused || IsResolving || hasLanded || (direction != -1 && direction != 1) ) return false;
float lane = Active.Lane + direction;
foreach ( float offset in SideStepOffsets( direction ) )
{
if ( !CanTranslate( direction, offset ) ) continue;
bool repeat = repeatAge.HasValue && MoveSmoothing > 0;
var step = repeat ? new ControlMotion( -direction, -offset, 0, MoveRepeatInterval, repeatAge.Value, true )
: new ControlMotion( -direction, -offset, 0, MoveSmoothing );
// A step while a turn is still drawing carries the turning piece sideways; keep that path clear too.
if ( TurnDrawing && FirstDrawnHit( Active with { Lane = lane, Y = Active.Y + offset }, step, bump ) is not null ) continue;
var before = Active;
Active = Active with { Lane = lane, Y = Active.Y + offset };
AddControlMotion( step.Lane, step.Y, 0, step.Duration, step.Age, step.Linear );
// Commit before another repeat can move away from the support. Keep
// the motion until Update so direct input calls do not lose feedback.
if ( CommitLanding() )
{
pendingSideLanding = before;
pendingSideDuration = repeat ? MoveRepeatInterval : MoveSmoothing;
}
return true;
}
return false;
}
bool CommitLanding()
{
if ( hasLanded || Active.Y < LandingY - Epsilon ) return false;
hasLanded = true;
spawnLane = Active.Lane;
return true;
}
// Near sloped edges, a flat horizontal move can collide even though the next
// pocket fits. Only downward adjustments are allowed, following a slope by
// up to half a diamond; horizontal input must never lift the piece.
// Every candidate still has to pass a swept collision check for the full shape.
List<float> SideStepOffsets( int direction )
{
var offsets = new List<float> { 0 };
void Add( float offset )
{
if ( offset < Epsilon || offset > Height * 0.5f + Epsilon ) return;
if ( !offsets.Contains( offset ) ) offsets.Add( offset );
}
foreach ( var cell in CellsOf( Active ) )
{
float lane = cell.Lane + direction;
Add( FloorY( lane ) - cell.Y );
foreach ( var other in CollisionObstacles() )
{
float dx = MathF.Abs( lane - other.Lane ) * 0.5f;
if ( dx >= 1 ) continue;
float separation = Height * (1 - dx);
Add( other.Y - separation - cell.Y );
Add( other.Y + separation - cell.Y );
}
}
offsets.Sort();
return offsets;
}
public bool TryRotate()
{
if ( GameOver || Paused || IsResolving || hasLanded ) return false;
// Turn in place about the fixed pivot, never shifted or lifted to make it fit: repositioning the piece moved
// it without the player asking. A turn that does not fit, or whose drawn rotation would pass through
// anything, is refused and bumps instead: it swings as far as it is clear and back.
var rotated = Active with { Rotation = (Active.Rotation + 1) & 3 };
if ( RotateSmoothing <= 0 ) return ApplyTurn( rotated ) || RefuseTurn();
var hit = FirstDrawnHit( rotated, TurnMotion(), bump );
if ( hit is null && ApplyTurn( rotated ) ) return true;
StartBump( hit?.LastClear ?? RotateSmoothing );
return RefuseTurn();
}
bool RefuseTurn()
{
RotationRefusedThisUpdate = true;
return false;
}
ControlMotion TurnMotion() => new( 0, 0, 1, RotateSmoothing );
bool ApplyTurn( Piece candidate )
{
if ( !CanOccupy( candidate ) ) return false;
// The pivot stays fixed, so the drawn piece only turns.
AddControlMotion( 0, 0, 1, RotateSmoothing );
Active = candidate;
RotatedThisUpdate = true;
// A turn can make first contact before Update.
// Lock immediately so this frame's movement cannot escape that landing.
if ( CommitLanding() ) pendingRotationLanding = true;
return true;
}
bool CanTranslate( int direction, float drop )
{
float lane = Active.Lane + direction;
float y = Active.Y + drop;
if ( !CanOccupy( lane, y ) ) return false;
// Along a straight path the diamond separation is piecewise linear.
// Check its two possible interior minima as well as the destination.
foreach ( var cell in CellsOf( Active ) )
foreach ( var piece in CollisionObstacles() )
{
float crossX = Math.Clamp( (piece.Lane - cell.Lane) / direction, 0, 1 );
float crossY = drop == 0 ? 0 : Math.Clamp( (piece.Y - cell.Y) / drop, 0, 1 );
if ( !ClearAt( crossX ) || !ClearAt( crossY ) ) return false;
bool ClearAt( float t ) => MathF.Abs( cell.Lane + direction * t - piece.Lane ) * 0.5f
+ MathF.Abs( cell.Y + drop * t - piece.Y ) / Height >= 1 - Epsilon / Height;
}
return true;
}
void LockPiece()
{
int incomingStart = settled.Count;
settled.AddRange( CellsOf( Active ) );
SettledVersion++;
Placed++;
LockFlash = 0.3f;
Chain = 0;
BeginSettlement( incomingStart );
}
void BeginSettlement( int incomingStart = -1, bool preserveBonds = false )
{
#if STANDALONE
#endif
// Pulses snapshot resting geometry; retire them before the stack moves again.
bondPulses.Clear();
if ( !preserveBonds ) BondedGroups = DiamondMatching.FindGroups( settled, incomingStart );
foreach ( var move in DiamondMatching.PlanSettlement( settled, random, frozenBonds: BondedGroups, fillCornerPockets: FillCornerPockets ) ) cascadeMoves.Enqueue( move );
cascadeTime = 0;
if ( !IsCascading ) FinishSettlement();
}
void FinishSettlement()
{
#if STANDALONE
#endif
var previousGroup = new int[settled.Count];
for ( int group = 0; group < BondedGroups.Count; group++ )
foreach ( int index in BondedGroups[group] ) previousGroup[index] = group;
var restingGroups = DiamondMatching.FindGroups( settled );
foreach ( var group in restingGroups )
{
// Only a union of previously separate bonds celebrates a connection.
// Internal bonds in a spawned piece and transient contacts do not pulse.
if ( !group.Exists( index => previousGroup[index] != previousGroup[group[0]] ) ) continue;
bondPulses.Add( new( DiamondOutline.Build( settled, new IReadOnlyList<int>[] { group } ) ) );
}
BondedGroups = restingGroups;
// Give every fall, slide, and destruction chain a chance to clear the top.
// Only the final resting stack determines whether another piece can spawn.
foreach ( var cell in settled )
if ( cell.Y - Height * 0.5f < -Epsilon )
{
GameOver = true;
foreach ( var group in BondedGroups ) LargestGroupAtGameOver = Math.Max( LargestGroupAtGameOver, group.Count );
BeginGameOverCleanup();
return;
}
if ( GameOver )
{
BeginGameOverCleanup();
return;
}
Spawn();
}
void BeginGameOverCleanup()
{
if ( settled.Count == 0 ) return;
// Work upward through pieces that are not still reacting to their last hit.
// A bond is one target: even an unshaking member must not re-hit its shaking partners.
var shaking = new HashSet<int>();
float nextReady = DamageShineDuration;
foreach ( var shine in damageShines )
{
if ( shine.Age >= DamageShineDuration || shine.SettledIndices.Count == 0 ) continue;
shaking.UnionWith( shine.SettledIndices );
nextReady = MathF.Min( nextReady, DamageShineDuration - shine.Age );
}
foreach ( var group in BondedGroups )
{
bool blocked = false;
foreach ( int member in group ) if ( shaking.Contains( member ) ) { blocked = true; break; }
if ( blocked ) shaking.UnionWith( group );
}
int target = -1;
for ( int i = 0; i < settled.Count; i++ )
if ( !shaking.Contains( i ) && (target < 0 || settled[i].Y > settled[target].Y + Epsilon) ) target = i;
if ( target < 0 )
{
// Keep this wait in real time, even when cleanup has reached its speed cap.
damagePause = nextReady;
return;
}
var cell = settled[target];
var targets = new Dictionary<int, Corner> { [target] = Corner.Bottom };
foreach ( var group in BondedGroups )
{
if ( !group.Contains( target ) ) continue;
foreach ( int member in group ) targets[member] = Corner.Bottom;
break;
}
bool destroyed = DiamondDamage.Apply( settled, targets );
int lowestHealth = MaxHealth;
foreach ( int index in targets.Keys ) lowestHealth = Math.Min( lowestHealth, settled[index].Health );
AddDamageFlashes( [new DiamondDamage.Contact( cell.Lane, cell.Y + Height * 0.5f, cell.ColorIndex )], targets.Count );
AddDamageShines( targets, BondedGroups );
impacts.Add( new Impact( 0, -Height * 12, targets.Count ) { LowestDamagedHealth = lowestHealth } );
gameOverHits++;
damagePause = GameOverHitPause / GameOverCleanupSpeed;
Chain = 0;
if ( destroyed ) DestroyBrokenDiamonds();
}
// A resting bottom tip at or below the floor peaks: the floor itself, or a filled corner pocket beside it.
static bool TouchesFloor( IEnumerable<Diamond> cells )
{
foreach ( var cell in cells ) if ( cell.Y + Height >= BoardHeight - Epsilon ) return true;
return false;
}
static bool TouchesFloor( IReadOnlyList<DiamondMatching.CellMove> cells )
{
foreach ( var cell in cells ) if ( cell.To.Y + Height >= BoardHeight - Epsilon ) return true;
return false;
}
void DestroyBrokenDiamonds()
{
bondPulses.Clear();
// Cancel future geometry before indices or supports change.
cascadeMoves.Clear();
cascadeTime = 0;
shattering.Clear();
var survivors = new List<Diamond>();
var remap = new int[settled.Count];
var brokenRemap = new int[settled.Count];
for ( int i = 0; i < settled.Count; i++ )
{
remap[i] = -1;
brokenRemap[i] = -1;
if ( settled[i].Health <= 0 ) { brokenRemap[i] = shattering.Count; shattering.Add( settled[i] ); }
else { remap[i] = survivors.Count; survivors.Add( settled[i] ); }
}
settled.Clear();
settled.AddRange( survivors );
SettledVersion++;
foreach ( var shine in damageShines )
{
shine.ShatteringIndices.Clear();
for ( int i = shine.SettledIndices.Count - 1; i >= 0; i-- )
{
int old = shine.SettledIndices[i];
if ( remap[old] >= 0 ) shine.SettledIndices[i] = remap[old];
else { shine.ShatteringIndices.Add( brokenRemap[old] ); shine.SettledIndices.RemoveAt( i ); }
}
}
BondedGroups = DiamondMatching.SurvivingGroups( settled, BondedGroups, remap );
Destroyed += shattering.Count;
if ( !GameOver )
{
DiamondsBroken += shattering.Count;
foreach ( var cell in shattering ) turnBrokenColors |= 1 << cell.ColorIndex;
MostColorsBrokenInTurn = Math.Max( MostColorsBrokenInTurn, System.Numerics.BitOperations.PopCount( (uint)turnBrokenColors ) );
}
Chain++;
shatterTime = ShatterDuration;
}
/// <summary>Sharp point contacts if the active piece continues straight down, at any speed.</summary>
public IReadOnlyList<DiamondDamage.Contact> GetLandingPointContacts()
{
if ( GameOver || Paused || IsResolving ) return Array.Empty<DiamondDamage.Contact>();
var contacts = new List<DiamondDamage.Contact>();
CopyLandingPointContacts( contacts );
return contacts;
}
readonly List<Diamond> landingCombined = new(), landingCells = new( 4 );
readonly List<DiamondMatching.CellMove> landingMoves = new( 4 );
readonly DiamondDamage.PointContactScratch landingContactScratch = new();
/// <summary>Replace caller-owned preview contacts, reusing the board's scratch buffers.</summary>
public void CopyLandingPointContacts( List<DiamondDamage.Contact> contacts )
{
contacts.Clear();
if ( GameOver || Paused || IsResolving ) return;
float landing = LandingY;
float remaining = landing - Active.Y;
if ( remaining <= Epsilon ) return;
var combined = landingCombined;
var moves = landingMoves;
combined.Clear(); combined.AddRange( settled ); moves.Clear();
CopyCellsOf( Active, landingCells );
foreach ( var cell in landingCells )
{
var target = cell with { Y = cell.Y + remaining };
moves.Add( new( combined.Count, cell, target ) );
combined.Add( target );
}
DiamondDamage.CopyPointContacts( combined, moves, contacts, landingContactScratch, FillCornerPockets );
}
float FallSpeedAtImpact( float remaining, float hold, bool softDrop )
{
if ( !softDrop ) return CurrentNormalFallSpeed;
float distance = MathF.Max( 0, remaining ) / Height;
float low = hold, high = SoftDropRampDuration;
float startDistance = SoftDropDistance( hold );
if ( distance >= SoftDropDistance( high ) - startDistance ) return SoftDropMaxSpeed;
// Invert the integrated quadratic ramp at the actual contact, keeping
// damage and feedback velocity independent of frame size and overshoot.
for ( int i = 0; i < 20; i++ )
{
float middle = (low + high) * 0.5f;
if ( SoftDropDistance( middle ) - startDistance < distance ) low = middle;
else high = middle;
}
float t = (low + high) * 0.5f / SoftDropRampDuration;
return SoftDropStartSpeed + (SoftDropMaxSpeed - SoftDropStartSpeed) * t * t;
}
static float SoftDropDistance( float time )
{
float ramp = Math.Clamp( time, 0, SoftDropRampDuration );
return SoftDropStartSpeed * ramp + (SoftDropMaxSpeed - SoftDropStartSpeed)
* ramp * ramp * ramp / (3 * SoftDropRampDuration * SoftDropRampDuration)
+ SoftDropMaxSpeed * MathF.Max( 0, time - SoftDropRampDuration );
}
// Damage happens at first contact, before the incoming cells can bond to the stack.
bool DamageActiveLanding( Piece before, float velocityY, float velocityX = 0 )
{
#if STANDALONE
#endif
var combined = new List<Diamond>( settled );
var previous = new List<Diamond>( CellsOf( before ) );
int incomingStart = combined.Count;
combined.AddRange( CellsOf( Active ) );
var moves = new List<DiamondMatching.CellMove>();
for ( int i = incomingStart; i < combined.Count; i++ ) moves.Add( new( i, previous[i - incomingStart], combined[i] ) );
var bonds = DiamondMatching.FindGroups( combined, incomingStart );
var contacts = new List<DiamondDamage.Contact>();
var targets = DiamondDamage.Targets( combined, moves, bonds, velocityX, velocityY, contacts );
AddCornerSparks( combined, moves, contacts, MathF.Sqrt( velocityX * velocityX + velocityY * velocityY ) );
if ( targets.Count == 0 ) return false;
RecordDamagedGroups( targets, bonds );
AddDamageFlashes( contacts, targets.Count );
AddDamageShines( targets, bonds );
bool destroyed = ApplyImpactDamage( combined, targets );
if ( destroyed )
{
damagePause = DamagePauseDuration;
settled.Clear();
settled.AddRange( combined );
BondedGroups = bonds;
Placed++;
LockFlash = 0.3f;
Chain = 0;
DestroyBrokenDiamonds();
return true;
}
for ( int i = 0; i < incomingStart; i++ ) settled[i] = combined[i];
return false;
}
bool ApplyImpactDamage( List<Diamond> cells, IReadOnlyDictionary<int, Corner> targets )
{
bool destroyed = DiamondDamage.Apply( cells, targets );
if ( targets.Count > 0 )
{
int lowestHealth = MaxHealth;
foreach ( int index in targets.Keys ) lowestHealth = Math.Min( lowestHealth, cells[index].Health );
// Capture the result before destruction/replanning changes the receiver list.
impacts[^1] = impacts[^1] with { LowestDamagedHealth = lowestHealth };
}
return destroyed;
}
void AddCornerSparks( IReadOnlyList<Diamond> cells, IReadOnlyList<DiamondMatching.CellMove> moves,
IReadOnlyList<DiamondDamage.Contact> damagingContacts, float speed )
{
foreach ( var contact in DiamondDamage.SparkContacts( cells, moves, FillCornerPockets ) )
{
// Damaging tips already emit through DamageFlashes. Keep floor and slow
// contacts independent of damage, health, sunbursts and the damage pause.
if ( damagingContacts.Contains( contact ) ) continue;
cornerSparks.Add( new( contact, speed, ++cornerSparkSequence ) );
}
}
void AddDamageFlashes( IReadOnlyList<DiamondDamage.Contact> contacts, int damageCount )
{
// Cosmetic randomness must not change the shape bag or settling direction.
foreach ( var contact in contacts ) damageFlashes.Add( new( contact.Lane, contact.Y, contact.ColorIndex,
Rotation: (float)effectRandom.NextDouble() * MathF.PI * 2, DamageCount: damageCount,
Brightness: 0.32f + (float)effectRandom.NextDouble() * 0.16f, Id: ++damageFlashSequence ) );
}
// Damage spreads through a whole established bond, so the damaged members of one group are that group's size.
void RecordDamagedGroups( IReadOnlyDictionary<int, Corner> targets, IReadOnlyList<IReadOnlyList<int>> bonds )
{
int largest = 0;
foreach ( var group in bonds )
{
int damaged = 0;
foreach ( int index in group ) if ( targets.ContainsKey( index ) ) damaged++;
largest = Math.Max( largest, damaged );
}
// Cells outside every listed group still count as one-diamond pieces.
LargestDamagedGroup = Math.Max( LargestDamagedGroup, Math.Max( largest, Math.Min( targets.Count, 1 ) ) );
}
void AddDamageShines( IReadOnlyDictionary<int, Corner> targets, IReadOnlyList<IReadOnlyList<int>> bonds )
{
var remaining = new HashSet<int>( targets.Keys );
foreach ( var group in bonds )
{
var shine = new DamageShine { Direction = effectRandom.Next( 2 ) == 0 ? -1 : 1,
ShakeAngle = (float)effectRandom.NextDouble() * MathF.PI * 2 };
foreach ( int index in group ) if ( remaining.Remove( index ) ) shine.SettledIndices.Add( index );
if ( shine.SettledIndices.Count > 0 ) damageShines.Add( shine );
}
foreach ( int index in remaining )
{
var shine = new DamageShine { Direction = effectRandom.Next( 2 ) == 0 ? -1 : 1,
ShakeAngle = (float)effectRandom.NextDouble() * MathF.PI * 2 };
shine.SettledIndices.Add( index );
damageShines.Add( shine );
}
}
// Return unused real time so the next move can begin within the same frame.
float AdvanceCascade( float delta )
{
var move = cascadeMoves.Peek();
float timeLeft = (move.Duration - cascadeTime) / CascadeSpeed;
bool finished = delta >= timeLeft;
cascadeTime = finished ? move.Duration : cascadeTime + delta * CascadeSpeed;
float t = cascadeTime / move.Duration;
// One ease-in spans the entire uninterrupted slope, including intermediate lanes.
// Falls also accelerate into contact instead of braking to zero before impact.
float blend = move.Blend( t );
foreach ( var cell in move.Cells )
{
settled[cell.Index] = settled[cell.Index] with
{
Lane = cell.From.Lane + (cell.To.Lane - cell.From.Lane) * blend,
Y = cell.From.Y + (cell.To.Y - cell.From.Y) * blend
};
}
if ( !finished ) return 0;
var contact = move.Cells[0];
float velocity = move.VelocityScale( 1, CascadeSpeed );
var faces = DiamondContact.Faces.None;
if ( !move.IsSlide )
{
var moving = new List<Diamond>( move.Cells.Count );
var excluded = new HashSet<int>();
foreach ( var cell in move.Cells ) { moving.Add( cell.To ); excluded.Add( cell.Index ); }
faces = DiamondContact.Find( moving, settled, excluded, FillCornerPockets );
}
impacts.Add( new( (contact.To.Lane - contact.From.Lane) * Width * 0.5f * velocity,
(contact.To.Y - contact.From.Y) * velocity, move.Cells.Count, faces ) { Slide = move.IsSlide, Floor = TouchesFloor( move.Cells ) } );
foreach ( var cell in move.Cells ) settled[cell.Index] = settled[cell.Index] with { Lane = cell.To.Lane, Y = cell.To.Y };
cascadeMoves.Dequeue();
cascadeTime = 0;
var damageContacts = new List<DiamondDamage.Contact>();
var faceContacts = new List<DiamondDamage.FaceContact>();
var targets = GameOver ? new Dictionary<int, Corner>() : DiamondDamage.Targets( settled, move.Cells, BondedGroups,
(contact.To.Lane - contact.From.Lane) * Width * 0.5f * velocity, (contact.To.Y - contact.From.Y) * velocity, damageContacts, faceContacts );
AddCornerSparks( settled, move.Cells, damageContacts, impacts[^1].Speed );
if ( targets.Count > 0 )
{
damagePause = DamagePauseDuration;
RecordDamagedGroups( targets, BondedGroups );
AddDamageFlashes( damageContacts, targets.Count );
foreach ( var face in faceContacts ) faceFlashes.Add( new( face,
Seed: (float)effectRandom.NextDouble() * MathF.PI * 2,
Brightness: 0.32f + (float)effectRandom.NextDouble() * 0.16f, DamageCount: targets.Count, Id: ++faceFlashSequence ) );
AddDamageShines( targets, BondedGroups );
}
if ( ApplyImpactDamage( settled, targets ) ) DestroyBrokenDiamonds();
else if ( !IsCascading && !IsDamagePaused ) FinishSettlement();
return MathF.Max( 0, delta - timeLeft );
}
void AdvanceResolution( float delta )
{
// Scale the shatter beat with the chain too (320/213/160/128/107 ms), and never
// discard a partial frame at a move boundary.
while ( delta > 0 && IsResolving )
{
// Give every damaging chain contact an unscaled beat, even at 3x speed.
if ( IsDamagePaused )
{
float consumed = MathF.Min( delta, damagePause );
damagePause = MathF.Max( 0, damagePause - consumed );
delta -= consumed;
if ( IsDamagePaused ) return;
if ( !IsCascading && !IsShattering )
{
int hitsBefore = gameOverHits;
FinishSettlement();
if ( GameOver && (gameOverHits != hitsBefore || IsDamagePaused) ) return;
}
continue;
}
if ( IsCascading )
{
int hitsBefore = gameOverHits;
delta = AdvanceCascade( delta );
if ( GameOver && (gameOverHits != hitsBefore || IsDamagePaused) ) return;
continue;
}
// Emit once when the actual shatter starts, after the damage pause.
// Keep every stage's event even if its entire animation fits this frame.
if ( shatterTime == ShatterDuration )
foreach ( var cell in shattering ) shatterBursts.Add( new( ++shatterSequence, cell ) );
float timeLeft = shatterTime / CascadeSpeed;
if ( delta < timeLeft )
{
shatterTime -= delta * CascadeSpeed;
return;
}
delta -= timeLeft;
shatterTime = 0;
shattering.Clear();
foreach ( var shine in damageShines ) shine.ShatteringIndices.Clear();
int hitsBeforeShatterSettlement = gameOverHits;
BeginSettlement( preserveBonds: true );
// Let each game-over hit reach the screen, even when a frame has
// enough leftover time to start another hit.
if ( GameOver && (gameOverHits != hitsBeforeShatterSettlement || IsDamagePaused) ) return;
}
}
public void Update( float delta, int direction, bool softDrop, bool rotate = false )
{
#if STANDALONE
#endif
impacts.Clear();
RotatedThisUpdate = RotationRefusedThisUpdate = false;
cornerSparks.Clear();
shatterBursts.Clear();
if ( shardTurnEnded )
{
NextShardPoints = ShardCrushPoints;
shardTurnEnded = false;
}
if ( Paused ) return;
// Game-over cleanup keeps updating the board, but the run's time has ended.
if ( !GameOver ) ElapsedSeconds += MathF.Max( 0, delta );
delta = Math.Clamp( delta, 0, 0.1f );
frameDelta = delta;
AdvanceControlMotions( delta );
for ( int i = faceFlashes.Count - 1; i >= 0; i-- )
{
var flash = faceFlashes[i] with { Age = faceFlashes[i].Age + delta };
if ( flash.Age >= DamageFlashDuration ) faceFlashes.RemoveAt( i );
else faceFlashes[i] = flash;
}
for ( int i = damageShines.Count - 1; i >= 0; i-- )
{
var shine = damageShines[i];
int previousSample = (int)(shine.Age * 60);
shine.Age += delta;
if ( shine.Age >= DamageShineDuration ) damageShines.RemoveAt( i );
// One cosmetic sample per group, shared by its gems, outlines and shine.
// Sampling on the effect clock also freezes the offset while paused.
else if ( (int)(shine.Age * 60) != previousSample )
shine.ShakeAngle = (float)effectRandom.NextDouble() * MathF.PI * 2;
}
for ( int i = bondPulses.Count - 1; i >= 0; i-- )
{
var pulse = bondPulses[i] with { Age = bondPulses[i].Age + delta };
if ( pulse.Age >= BondPulseDuration ) bondPulses.RemoveAt( i );
else bondPulses[i] = pulse;
}
for ( int i = damageFlashes.Count - 1; i >= 0; i-- )
{
var flash = damageFlashes[i] with { Age = damageFlashes[i].Age + delta };
if ( flash.Age >= DamageFlashDuration ) damageFlashes.RemoveAt( i );
else damageFlashes[i] = flash;
}
LockFlash = MathF.Max( 0, LockFlash - delta );
if ( IsResolving )
{
AdvanceResolution( delta );
return;
}
if ( GameOver ) return;
// Holding rotate repeats like sideways movement, with its own slower cadence.
if ( !rotate )
{
rotateHeld = false;
rotateRepeatTime = 0;
}
else if ( !rotateHeld )
{
rotateHeld = true;
TryRotate();
rotateRepeatTime = RotateRepeatDelay;
}
// Repeats only count down while the piece is controllable, so the landing wait never banks steps that
// would all fire on the next piece's first frame.
else if ( !hasLanded )
{
rotateRepeatTime -= delta;
while ( rotateRepeatTime <= 0 && !hasLanded )
{
TryRotate();
rotateRepeatTime += RotateRepeatInterval;
}
}
if ( direction == 0 )
{
if ( lastDirection != 0 ) EaseOutRepeatMotions();
lastDirection = 0;
repeatTime = 0;
}
else if ( direction != lastDirection )
{
EaseOutRepeatMotions();
TryMove( direction );
lastDirection = direction;
repeatTime = MoveRepeatDelay;
}
else if ( !hasLanded )
{
repeatTime -= delta;
while ( repeatTime <= 0 && !hasLanded )
{
// A blocked step ends the glide, so let it come to rest smoothly.
if ( !TryMove( direction, -repeatTime ) ) EaseOutRepeatMotions();
repeatTime += MoveRepeatInterval;
}
}
float landing = LandingY;
// While a turn is drawing, pressing or releasing soft drop changes where the turning piece is drawn; the change
// waits (at most until the drawn turn finishes) unless the turn stays clear at the new speed.
if ( softDrop != softDropTime > 0 && TurnDrawing && !FallChangeClear( softDrop ) ) softDrop = !softDrop;
// Integrate the increasingly steep speed ramp exactly, including its cap.
float previousHold = softDropTime;
softDropTime = softDrop ? MathF.Min( SoftDropRampDuration, softDropTime + delta ) : 0;
float distance = CurrentNormalFallSpeed * delta;
if ( softDrop )
distance = SoftDropDistance( previousHold + delta ) - SoftDropDistance( previousHold );
float remaining = landing - Active.Y;
var beforeFall = Active;
Active = Active with { Y = MathF.Min( landing, Active.Y + Height * distance ) };
KeepDrawnTurnClear();
var contacts = Active.Y >= landing - Epsilon
? DiamondContact.Find( CellsOf( Active ), settled, fillCornerPockets: FillCornerPockets ) : DiamondContact.Faces.None;
bool firstContact = CommitLanding();
// Report a step or turn into contact once the drawn piece arrives there.
if ( pendingRotationLanding && delta > 0 && !IsControlAnimating )
{
pendingRotationLanding = false;
// Rotation is not a vertical fall: report contact once without assigning
// the held-drop speed or applying point-impact damage to the receivers.
impacts.Add( new( 0, 0, DiamondShapes.All[Active.ShapeIndex].Cells.Count, contacts ) { Active = true, Floor = TouchesFloor( CellsOf( Active ) ) } );
}
else if ( pendingSideLanding is Piece beforeSideStep && delta > 0 && !IsControlAnimating )
{
pendingSideLanding = null;
// A held repeat glides at constant speed and a tap eases out over its duration, so use the glide's average
// speed; dividing by the arrival frame alone made gentle steps read as slams, harder at higher frame rates.
// A snapped step (no smoothing) still moves within the one frame.
float duration = MathF.Max( pendingSideDuration, delta );
float velocityX = (Active.Lane - beforeSideStep.Lane) * Width * 0.5f / duration;
float velocityY = (Active.Y - beforeSideStep.Y) / duration;
impacts.Add( new( velocityX, velocityY, DiamondShapes.All[Active.ShapeIndex].Cells.Count, contacts ) { Active = true, Floor = TouchesFloor( CellsOf( Active ) ) } );
if ( DamageActiveLanding( beforeSideStep, velocityY, velocityX ) ) return;
}
else if ( firstContact )
{
// Solve speed at the actual contact time, not from the clipped frame displacement.
float speed = FallSpeedAtImpact( remaining, previousHold, softDrop );
impacts.Add( new( 0, speed * Height, DiamondShapes.All[Active.ShapeIndex].Cells.Count, contacts ) { Active = true, Floor = TouchesFloor( CellsOf( Active ) ) } );
if ( DamageActiveLanding( beforeFall, speed * Height ) ) return;
}
if ( Active.Y >= landing - Epsilon )
{
// The landing wait starts once the drawn piece has caught up with a step
// or turn, so it never jumps into place and still pauses visibly on contact.
if ( !IsControlAnimating ) groundedTime += delta;
// A face support makes the direction unambiguous even if another cell's tip touches.
bool onFace = (contacts & (DiamondContact.Faces.LowerLeft | DiamondContact.Faces.LowerRight)) != 0;
if ( groundedTime >= (onFace ? FaceLandingDelay : CornerLandingDelay) )
{
LockPiece();
}
}
else groundedTime = 0;
}
}