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