DiamondDebris.Crushing.cs
using System;
using System.Collections.Generic;
using Sandbox;
using static Diamonds.DiamondBoard;

namespace Diamonds;

public sealed partial class DiamondDebris
{
	readonly record struct CrushFace( Vector2 Normal, float Distance, Vector2 Velocity, int Owner );
	readonly List<CrushFace> crushFaces = new();
	readonly record struct CrushDiamond( Vector2 Center, Vector2 Velocity );
	readonly List<CrushDiamond> crushDiamonds = new();
	bool crushDiamondsMoving;
	Vector2 crushPosition, crushVelocity;
	float crushDelta;

	void CacheCrushDiamonds()
	{
#if STANDALONE
#endif
		// Cross the native boundary once per diamond per step, not once for
		// every shard/face pair. Refresh after stepping so enclosure stays exact.
		crushDiamonds.Clear();
		crushDiamondsMoving = false;
		foreach ( var body in settledBodies ) Cache( body );
		foreach ( var body in activeBodies ) Cache( body );
		void Cache( PhysicsBody body )
		{
			var position = body.Position;
			var velocity = body.Velocity;
			crushDiamonds.Add( new( new( position.x, position.y ), new( velocity.x, velocity.y ) ) );
			crushDiamondsMoving |= velocity.LengthSquared > 1;
		}
	}

	void CrushTrappedChips( float delta )
	{
#if STANDALONE
#endif
		// Rebuilt lock/shatter colliders can already enclose a fragment before
		// their first step, with no closing velocity left for the seam test.
		CrushEmbeddedShards();
		// Stationary gems/walls cannot close a press, even in a dense shard pile.
		if ( !crushDiamondsMoving ) return;
		crushDelta = delta;
		for ( int i = particles.Count - 1; i >= 0; i-- )
		{
			var chip = particles[i];
			if ( chip.IsSpark ) continue;
			crushPosition = chip.Position;
			var velocity = chip.Body.Velocity;
			crushVelocity = new Vector2( velocity.x, velocity.y );
			crushFaces.Clear();
			int owner = 0;
			foreach ( var diamond in crushDiamonds ) AddDiamondFaces( chip, diamond, ++owner );
			for ( int edge = 0; edge < Valleys * 2; edge++ )
			{
				float x = edge * Width * 0.5f;
				AddFace( chip, new Vector2( x, BoardHeight - (edge % 2 == 0 ? Height * 0.5f : 0) ),
					new Vector2( x + Width * 0.5f, BoardHeight - (edge % 2 == 0 ? 0 : Height * 0.5f) ), Vector2.Zero, -1 );
			}
			foreach ( var tooth in SideWallTeeth() )
			{
				float x = LaneX( tooth.Lane );
				bool left = tooth.Lane < 0;
				var top = new Vector2( x, tooth.Y - Height * 0.5f );
				var tip = new Vector2( x + (left ? 1 : -1) * Width * 0.5f, tooth.Y );
				var bottom = new Vector2( x, tooth.Y + Height * 0.5f );
				AddFace( chip, left ? top : bottom, tip, Vector2.Zero, left ? -2 : -3 );
				AddFace( chip, tip, left ? bottom : top, Vector2.Zero, left ? -2 : -3 );
			}
			foreach ( var corner in CornerFills( FillCornerPockets ) )
				AddDiamondFaces( chip, new( new Vector2( LaneX( corner.Lane ), corner.Y ), Vector2.Zero ), corner.Lane == 0 ? -2 : -3 );
			AddFace( chip, new Vector2( BoardWidth, 0 ), new Vector2( 0, 0 ), Vector2.Zero, -4 );
			if ( !IsPressedFlat( chip, delta ) ) continue;
			AwardCrush( chip );
			EmitSmoke( chip, true );
			chip.Body.Remove();
			particles.RemoveAt( i );
		}
	}

	void CrushEmbeddedShards()
	{
#if STANDALONE
#endif
		Span<Vector2> polygon = stackalloc Vector2[8];
		for ( int i = particles.Count - 1; i >= 0; i-- )
		{
			var shard = particles[i];
			if ( shard.IsSpark ) continue;
			var position = shard.Position;
			var vertices = polygon[..shard.Vertices.Length];
			bool transformed = false;
			float coveredArea = 0;
			bool embedded = false;
			foreach ( var diamond in crushDiamonds )
			{
				if ( !NearDiamond( position, shard.Radius, diamond.Center ) ) continue;
				if ( EnclosesShard( diamond.Center, position, shard.Radius ) ) { embedded = true; break; }
				if ( !transformed ) { TransformShard( shard, vertices ); transformed = true; }
				coveredArea += CoveredArea( vertices, diamond.Center );
			}
			// Board diamonds have disjoint interiors: adding their clipped areas
			// detects a fragment buried across shared faces or corner junctions.
			// A centroid-only check misses precisely those internal seams.
			if ( transformed && coveredArea > PolygonArea( vertices ) * 0.55f ) embedded = true;
			if ( !embedded )
			{
				if ( !transformed || ClearOfDiamonds( vertices, position, shard.Radius ) )
				{
					shard.LastClearTransform = shard.Body.Transform;
					shard.HasClearTransform = true;
				}
				continue;
			}
			// Other shards can force a solver overlap without a diamond closing
			// the pocket. Recover the last clear pose if it is still available.
			// A newly placed/moving gem covering that pose still causes a crush.
			if ( RestoreClearShardPose( shard, vertices ) ) continue;
			AwardCrush( shard );
			EmitSmoke( shard, true );
			shard.Body.Remove();
			particles.RemoveAt( i );
		}
	}

	void AwardCrush( Particle shard )
	{
		if ( currentBoard.GameOver ) return;
		EmitCrushSunburst( shard );
		crushAwards.Add( new( shard.Position, shard.ColorIndex, currentBoard.NextShardPoints ) );
		currentBoard.AwardShardCrush( 1 );
		CrushedThisFrame++;
	}

	static bool EnclosesShard( Vector2 center, Vector2 position, float radius )
	{
		var offset = position - center;
		// Distance from the centroid to the nearest diamond face. Tolerate
		// shallow solver overlap, but never let a gem contain a shard's core.
		float penetration = (1 - MathF.Abs( offset.x ) / (Width / 2) - MathF.Abs( offset.y ) / (Height / 2))
			/ MathF.Sqrt( 4 / (Width * Width) + 4 / (Height * Height) );
		return penetration > MathF.Max( 1.5f, radius * 0.2f );
	}

	void AddDiamondFaces( Particle chip, CrushDiamond diamond, int owner )
	{
		var center = diamond.Center;
		var difference = crushPosition - center;
		float reach = chip.Radius * 2.5f + (diamond.Velocity - crushVelocity).Length * crushDelta;
		if ( MathF.Abs( difference.x ) > Width * 0.5f + reach ||
			MathF.Abs( difference.y ) > Height * 0.5f + reach ) return;
		var velocity = diamond.Velocity;
		var top = center + new Vector2( 0, -Height * 0.5f );
		var right = center + new Vector2( Width * 0.5f, 0 );
		var bottom = center + new Vector2( 0, Height * 0.5f );
		var left = center + new Vector2( -Width * 0.5f, 0 );
		AddFace( chip, top, right, velocity, owner );
		AddFace( chip, right, bottom, velocity, owner );
		AddFace( chip, bottom, left, velocity, owner );
		AddFace( chip, left, top, velocity, owner );
	}

	void AddFace( Particle chip, Vector2 a, Vector2 b, Vector2 velocity, int owner )
	{
		var edge = b - a;
		float length = edge.Length;
		var tangent = edge / length;
		var offset = crushPosition - a;
		float along = Vector2.Dot( offset, tangent );
		// Stay on the actual segment; extrapolated planes at tips aren't presses.
		if ( along < 0 || along > length ) return;
		var normal = new Vector2( tangent.y, -tangent.x );
		float distance = Vector2.Dot( offset, normal );
		float reach = chip.Radius * 2.5f + (velocity - crushVelocity).Length * crushDelta;
		if ( distance < -chip.Radius || distance > reach ) return;
		crushFaces.Add( new( normal, distance, velocity, owner ) );
	}

	bool IsPressedFlat( Particle chip, float delta )
	{
		for ( int a = 0; a < crushFaces.Count; a++ )
		for ( int b = a + 1; b < crushFaces.Count; b++ )
		{
			var first = crushFaces[a];
			var second = crushFaces[b];
			// Adjacent faces of one gem and zigzag valley supports cannot crush.
			if ( first.Owner == second.Owner || Vector2.Dot( first.Normal, second.Normal ) > -0.95f ) continue;
			float min = float.PositiveInfinity, max = float.NegativeInfinity;
			var rotation = chip.Body.Rotation;
			foreach ( var vertex in chip.Vertices )
			{
				var rotated = rotation * new Vector3( vertex.x, vertex.y, 0 );
				float projection = Vector2.Dot( new Vector2( rotated.x, rotated.y ), first.Normal );
				min = MathF.Min( min, projection ); max = MathF.Max( max, projection );
			}
			float thickness = max - min;
			float gap = first.Distance + second.Distance;
			float closing = Vector2.Dot( first.Velocity - second.Velocity, first.Normal );
			// Test the substep's approaching surfaces before Box2D ejects a trapped
			// chip. Small solver overlaps are harmless; require >55% compression.
			if ( closing > 1 && gap - closing * delta < thickness * 0.45f ) return true;
		}
		return false;
	}
}