DiamondBoard.TurnSweep.cs
using System;
using System.Collections.Generic;
namespace Diamonds;
// Turns snap on the lattice for collision, but DiamondPanel.DrawActivePiece draws each one rotating about the
// shape pivot for RotateSmoothing seconds. Keep that drawn sweep out of the stack, walls and floor: a turn whose
// drawn path would pass through anything is refused and shown as a bump, turning only as far as it fits and back.
//
// Everything is measured in lattice units (half a diamond wide and tall). There every diamond is a square turned
// 45 degrees and a drawn turn is a rigid rotation, so a pose's clearance is an exact square-against-square
// separating-axis distance. The sweep advances conservatively: never further in time than the fastest point of the
// drawn piece needs to cross the current clearance, so no contact can slip between two checks.
public sealed partial class DiamondBoard
{
// Drawn poses may touch what they pass, never overlap it by more than this (in lattice units, under a pixel).
const float SweepTolerance = 0.02f;
const float MinSweepStep = 0.00002f;
// A sweep that needs more steps than this (a long graze along a face) is treated as blocked.
const int MaxSweepSteps = 8000;
const float HalfHeight = Height * 0.5f;
/// <summary>A refused turn's drawn swing toward the turn and back, in place; the logical piece never turns.</summary>
readonly record struct Bump( float Peak, float Duration, float Age = 0 )
{
/// <summary>Quarter turns shown toward the refused turn, <paramref name="t"/> seconds from now.</summary>
public float Amount( float t ) => Duration > 0 && Age + t < Duration ? Peak * MathF.Sin( MathF.PI * (Age + t) / Duration ) : 0;
/// <summary>An upper bound on how fast <see cref="Amount"/> changes, in quarter turns per second.</summary>
public float Rate => Duration > 0 && Age < Duration ? Peak * MathF.PI / Duration : 0;
public bool Active => Duration > 0 && Age < Duration;
}
/// <summary>Where a drawn path first overlaps something, and the latest moment before that it was still clear.</summary>
readonly record struct SweepHit( float Time, float LastClear );
// Default (idle) survives hotload.
Bump bump;
// The latest update's delta: a turn or step pressed during an update is first drawn after that update's fall.
float frameDelta;
List<(float Lane, float Row)> sweepObstacles;
List<ControlMotion> easeScratch;
/// <summary>Frames where input the prediction could not foresee (such as soft drop pressed mid-turn) would have
/// pushed the drawn piece into something, so it was snapped to its logical pose instead.</summary>
public int DrawnPoseSnaps { get; private set; }
/// <summary>The drawn active piece is clear of the stack, walls and floor.</summary>
public bool DrawnPieceClear
{
get
{
var trail = ControlOffset;
CollectSweepObstacles( Active, Active, 0 );
return DrawnClearance( Active, Active.Y, trail.Lane, trail.Y, ControlTurns ) >= -SweepTolerance;
}
}
bool TurnDrawing
{
get
{
if ( bump.Active ) return true;
if ( controlMotions is not null )
foreach ( var motion in controlMotions )
if ( motion.Turns != 0 ) return true;
return false;
}
}
void AdvanceBump( float delta ) => bump = bump.Active && bump.Age + delta < bump.Duration ? bump with { Age = bump.Age + delta } : default;
/// <summary>Heights the active piece will fall in the next <paramref name="t"/> seconds at the current speed.</summary>
float FallHeights( float t ) => softDropTime > 0
? SoftDropDistance( softDropTime + t ) - SoftDropDistance( softDropTime )
: CurrentNormalFallSpeed * t;
/// <summary>
/// Follow the drawn piece as DrawActivePiece will show it with no further input: <paramref name="logical"/> keeps
/// falling at the current speed until it lands, trailed by the running control motions plus <paramref name="extra"/>
/// and swung by <paramref name="drawnBump"/>, until every motion has finished. Returns where it first overlaps
/// something by more than the tolerance, or null when the whole path is clear.
/// </summary>
SweepHit? FirstDrawnHit( Piece logical, ControlMotion? extra, Bump drawnBump )
{
float duration = drawnBump.Active ? drawnBump.Duration - drawnBump.Age : 0;
if ( controlMotions is not null )
foreach ( var motion in controlMotions ) duration = MathF.Max( duration, motion.Duration - motion.Age );
if ( extra is ControlMotion added ) duration = MathF.Max( duration, added.Duration - added.Age );
float landing = FindLandingY( logical );
CollectSweepObstacles( Active, logical, landing - logical.Y );
var shape = DiamondShapes.All[logical.ShapeIndex];
// The farthest any cell corner sits from the pivot, for the turning speed bound.
float reach = 0;
for ( int i = 0; i < shape.Cells.Count; i++ )
{
var cell = shape.Cells[i];
float du = cell.Lane - shape.Pivot.Lane, dv = cell.Row - shape.Pivot.Row;
reach = MathF.Max( reach, MathF.Sqrt( du * du + dv * dv ) + 1 );
}
// The fall is at most the top soft-drop speed, or the steady normal speed, in rows per second.
float fallRate = 2 * (softDropTime > 0 ? SoftDropMaxSpeed : CurrentNormalFallSpeed);
float t = 0, lastClear = 0;
for ( int step = 0; ; step++ )
{
float lane = 0, y = 0, turns = 0, laneRate = 0, rowRate = fallRate, turnRate = 0;
if ( controlMotions is not null )
foreach ( var motion in controlMotions ) Add( motion with { Age = motion.Age + t } );
if ( extra is ControlMotion next ) Add( next with { Age = next.Age + t } );
var swing = drawnBump with { Age = drawnBump.Age + t };
turns -= swing.Amount( 0 );
turnRate += swing.Rate;
// Never above the piece: a pose that already overlaps has its landing point above it.
float fallen = MathF.Max( logical.Y, MathF.Min( landing, logical.Y + Height * FallHeights( t + frameDelta ) ) );
float clearance = DrawnClearance( logical, fallen, lane, y, turns );
if ( clearance < -SweepTolerance || step >= MaxSweepSteps ) return new SweepHit( t, lastClear );
lastClear = t;
if ( t >= duration ) return null;
// Ease-outs only slow down and the bump bound is its peak rate, so this speed bounds every later moment too.
float speed = MathF.Sqrt( laneRate * laneRate + rowRate * rowRate ) + turnRate * MathF.PI * 0.5f * reach;
t = MathF.Min( duration, t + MathF.Max( MinSweepStep, (clearance + SweepTolerance * 0.5f) / MathF.Max( speed, 1e-3f ) ) );
void Add( ControlMotion motion )
{
if ( motion.Age >= motion.Duration ) return;
float remaining = motion.Remaining;
// The fastest the remaining fraction falls from here on: a linear glide's constant rate, or an ease-out's current one.
float rate = motion.Linear ? 1 / motion.Duration : 2 * (1 - motion.Age / motion.Duration) / motion.Duration;
lane += motion.Lane * remaining;
y += motion.Y * remaining;
turns += motion.Turns * remaining;
laneRate += MathF.Abs( motion.Lane ) * rate;
rowRate += MathF.Abs( motion.Y ) / HalfHeight * rate;
turnRate += MathF.Abs( motion.Turns ) * rate;
}
}
}
/// <summary>
/// The least clearance, in lattice units (negative when overlapping), between the collected obstacles and the drawn
/// piece <paramref name="logical"/> at <paramref name="y"/>, trailed by (<paramref name="trailLane"/>,
/// <paramref name="trailY"/>) and turned back by <paramref name="turns"/> quarter turns about its pivot, exactly as
/// DrawActivePiece draws it. Never more than the true distance.
/// </summary>
float DrawnClearance( Piece logical, float y, float trailLane, float trailY, float turns )
{
var shape = DiamondShapes.All[logical.ShapeIndex];
// DiamondShapes.Rotate's clockwise quarter turn maps (lane, row) to (-row, lane): a rotation by +90 degrees,
// matching Painter.Rotate's clockwise turn on screen.
float angle = -turns * MathF.PI * 0.5f, c = MathF.Cos( angle ), s = MathF.Sin( angle );
float pivotLane = logical.Lane + trailLane + shape.Pivot.Lane, pivotRow = (y + trailY) / HalfHeight + shape.Pivot.Row;
float least = float.MaxValue;
for ( int i = 0; i < shape.Cells.Count; i++ )
{
if ( logical.CellHealth( i ) <= 0 ) continue;
var cell = shape.RotatedCell( i, logical.Rotation );
float du = cell.Lane - shape.Pivot.Lane, dv = cell.Row - shape.Pivot.Row;
least = MathF.Min( least, SquareClearance( pivotLane + du * c - dv * s, pivotRow + du * s + dv * c, c, s, least ) );
}
return least;
}
/// <summary>Clearance of a diamond turned by (cos, sin) = (<paramref name="c"/>, <paramref name="s"/>) at lattice point
/// (<paramref name="u"/>, <paramref name="v"/>) from the walls, floor and collected obstacles; stops refining pairs
/// already farther than <paramref name="best"/>.</summary>
float SquareClearance( float u, float v, float c, float s, float best )
{
// Beyond the side walls' base lines and below the floor's is solid.
float extent = MathF.Max( MathF.Abs( c ), MathF.Abs( s ) );
best = MathF.Min( best, MathF.Min( u - extent + 1, LaneCount - u - extent ) );
best = MathF.Min( best, BoardHeight / HalfHeight - v - extent );
float reach = 1 + MathF.Abs( c ) + MathF.Abs( s );
foreach ( var (lane, row) in sweepObstacles )
{
float dx = u - lane, dy = v - row;
// A lattice (L1) gap bounds the true gap from below; skip pairs that cannot be the closest.
if ( (MathF.Abs( dx ) + MathF.Abs( dy ) - reach) * 0.70710678f >= best ) continue;
// Separating axes: the obstacle's two edge normals, then the turned diamond's. The widest gap along any of
// them is the clearance (or, when all overlap, the smallest overlap is the penetration).
float gap = MathF.Max( MathF.Max( Gap( 1, 1 ), Gap( 1, -1 ) ), MathF.Max( Gap( c - s, s + c ), Gap( c + s, s - c ) ) );
best = MathF.Min( best, gap );
float Gap( float nx, float ny )
{
float length = MathF.Sqrt( nx * nx + ny * ny );
nx /= length;
ny /= length;
float obstacle = MathF.Max( MathF.Abs( nx ), MathF.Abs( ny ) );
float turned = MathF.Max( MathF.Abs( nx * c + ny * s ), MathF.Abs( ny * c - nx * s ) );
return MathF.Abs( dx * nx + dy * ny ) - obstacle - turned;
}
}
return best;
}
/// <summary>
/// Gather every diamond obstacle (settled gems, wall teeth, filled corners and the floor's zigzag, which is a row of
/// diamonds centred on the floor line at odd lanes) the drawn piece could reach moving from <paramref name="from"/>
/// to <paramref name="to"/> and falling <paramref name="fall"/> units, so each check tests only a few.
/// </summary>
void CollectSweepObstacles( Piece from, Piece to, float fall )
{
var obstacles = sweepObstacles ??= new List<(float, float)>();
obstacles.Clear();
var shape = DiamondShapes.All[to.ShapeIndex];
float radius = 0;
for ( int i = 0; i < shape.Cells.Count; i++ )
{
var cell = shape.Cells[i];
radius = MathF.Max( radius, MathF.Sqrt( (cell.Lane - shape.Pivot.Lane) * (cell.Lane - shape.Pivot.Lane)
+ (cell.Row - shape.Pivot.Row) * (cell.Row - shape.Pivot.Row) ) );
}
// Room for the cell diamonds themselves, the obstacle diamonds, and any running trail or bump.
var trail = ControlOffset;
float margin = radius + 2 + SweepTolerance + MathF.Abs( trail.Lane ) + MathF.Abs( trail.Y ) / HalfHeight;
if ( controlMotions is not null )
foreach ( var motion in controlMotions ) margin += MathF.Abs( motion.Lane ) + MathF.Abs( motion.Y ) / HalfHeight;
float minLane = MathF.Min( from.Lane, to.Lane ) + shape.Pivot.Lane - margin, maxLane = MathF.Max( from.Lane, to.Lane ) + shape.Pivot.Lane + margin;
float minRow = MathF.Min( from.Y, to.Y ) / HalfHeight + shape.Pivot.Row - margin;
float maxRow = (MathF.Max( from.Y, to.Y ) + MathF.Max( 0, fall )) / HalfHeight + shape.Pivot.Row + margin;
foreach ( var cell in settled ) Consider( cell.Lane, cell.Y / HalfHeight );
// As SideWallTeeth and CornerFills, without allocating their iterators.
for ( float y = BoardHeight; y + HalfHeight > 0; y -= Height )
{
Consider( -1, y / HalfHeight );
Consider( LaneCount, y / HalfHeight );
}
if ( FillCornerPockets )
{
Consider( 0, (BoardHeight - HalfHeight) / HalfHeight );
Consider( LaneCount - 1, (BoardHeight - HalfHeight) / HalfHeight );
}
for ( int lane = -1; lane <= LaneCount; lane += 2 ) Consider( lane, BoardHeight / HalfHeight );
void Consider( float lane, float row )
{
if ( lane >= minLane && lane <= maxLane && row >= minRow && row <= maxRow ) obstacles.Add( (lane, row) );
}
}
/// <summary>Show a refused turn as a swing in place that stops where its drawn turn was last clear
/// (<paramref name="lastClear"/> seconds in) and returns, checked clear along its whole path.</summary>
void StartBump( float lastClear )
{
float duration = MathF.Min( RotateSmoothing * 1.3f, RotateRepeatInterval );
if ( duration <= 0 ) return;
// The drawn turn's progress then: a quadratic ease-out over RotateSmoothing.
float t = Math.Clamp( lastClear / RotateSmoothing, 0, 1 );
float peak = 1 - (1 - t) * (1 - t);
for ( int attempt = 0; attempt < 4 && peak > 0.02f; attempt++, peak *= 0.5f )
{
var candidate = new Bump( peak, duration );
if ( FirstDrawnHit( Active, null, candidate ) is null )
{
bump = candidate;
return;
}
}
}
/// <summary>Ease out held-repeat glides, unless doing so mid-turn would carry the turning piece into something.</summary>
bool CanEaseRepeatMotions()
{
if ( !TurnDrawing || controlMotions is null ) return true;
var saved = easeScratch ??= new List<ControlMotion>();
saved.Clear();
saved.AddRange( controlMotions );
EaseRepeatMotionsInPlace();
bool clear = FirstDrawnHit( Active, null, bump ) is null;
controlMotions.Clear();
controlMotions.AddRange( saved );
return clear;
}
/// <summary>The drawn turn stays clear if soft drop is <paramref name="drop"/> from this update on.</summary>
bool FallChangeClear( bool drop )
{
float held = softDropTime;
softDropTime = drop ? MathF.Max( held, 1e-6f ) : 0;
bool clear = FirstDrawnHit( Active, null, bump ) is null;
softDropTime = held;
return clear;
}
/// <summary>After the fall, snap the drawn piece to its logical pose (always clear) if an unforeseen input change
/// would have shown it overlapping something mid-turn.</summary>
void KeepDrawnTurnClear()
{
if ( !TurnDrawing || DrawnPieceClear ) return;
controlMotions?.Clear();
bump = default;
DrawnPoseSnaps++;
}
}