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

namespace Diamonds;

/// <summary>Box2D debris in Painter coordinates (+Y down). Only crushed shards award points.</summary>
public sealed partial class DiamondDebris
{
	public sealed class Particle
	{
		internal PhysicsBody Body;
		internal float Age, BlinkTime = -1;
		internal float MaxAge;
		internal float ShineDelay, ShinePeriod = 3.5f;
		public int ShineDirection { get; internal set; } = 1;
		// The debris age stops on pause, including while the physics body sleeps.
		public float ShineProgress => IsSpark || Age < ShineDelay ? -1 : (Age - ShineDelay) % ShinePeriod / 0.32f;
		internal Transform LastClearTransform;
		internal bool HasClearTransform;
		public bool IsSpark { get; internal set; }
		public float Opacity => IsSpark ? MathF.Pow( Math.Clamp( 1 - Age / MaxAge, 0, 1 ), 1.4f ) : 1;
		public float Radius { get; internal set; }
		public int ColorIndex { get; internal set; }
		public float Brightness { get; internal set; }
		public Vector2[] Vertices { get; internal set; }
		public Vector2 Position
		{
			get
			{
				// Read the native transform once for both coordinates.
				var position = Body.Position;
				return new( position.x, position.y );
			}
		}
		public float Angle => Body.Rotation.Yaw();
		public bool Visible => IsSpark ? Age < MaxAge : BlinkTime < 0 || (int)(BlinkTime / (BlinkTime < 0.45f ? 0.12f : 0.06f)) % 2 == 0;
	}

	const int MaxShards = 320;
	const int MaxSparks = 960;
	public const float MaxSparkSpeed = 8000;
	const float BlinkDuration = 0.9f;
	const int PhysicsRevision = 10;
	int physicsRevision;
	int settledVersion, activeSpawnVersion;
	readonly Random random;
	public DiamondDebris( int? seed = null ) => random = seed.HasValue ? new Random( seed.Value ) : new Random();
	readonly List<Particle> particles = new();
	readonly List<PhysicsBody> settledBodies = new();
	readonly List<PhysicsBody> activeBodies = new();
	readonly List<Diamond> cells = new();
	readonly Surface surface = new() { Friction = 0.65f, Elasticity = 0.24f, Density = 1000, RollingResistance = 0.2f };
	readonly Surface sparkSurface = new() { Friction = 0.65f, Elasticity = 0.96f, Density = 1000, RollingResistance = 0.2f };
	public float SparkForceMultiplier { get; set; } = 4;
	public float SparkLifetimeMultiplier { get; set; } = 4;
	public float SparkGravityMin { get; set; } = 0.75f;
	public float SparkGravityMax { get; set; } = 1.6f;
	public float ShardLifetimeMin { get; set; } = 38;
	public float ShardLifetimeMax { get; set; } = 42;
	public float SparkBounciness
	{
		get => sparkSurface.Elasticity;
		set
		{
			float elasticity = Math.Clamp( value, 0, 1 );
			if ( sparkSurface.Elasticity == elasticity ) return;
			sparkSurface.Elasticity = elasticity;
			// Box2D copies surface values into each fixture when assigned.
			foreach ( var particle in particles )
			{
				if ( !particle.IsSpark ) continue;
				foreach ( var shape in particle.Body.Shapes ) shape.Surface = sparkSurface;
			}
		}
	}
	Scene physicsScene;
	DiamondBoard currentBoard;
	bool FillCornerPockets => currentBoard?.FillCornerPockets ?? true;
	int resetVersion;
	long lastFlash;
	long lastFaceFlash;
	long lastShatter;
	long lastCornerSpark;
	public int CrushedThisFrame { get; private set; }
	public readonly record struct CrushAward( Vector2 Position, int ColorIndex, long Points );
	readonly List<CrushAward> crushAwards = new();
	public IReadOnlyList<CrushAward> CrushAwards => crushAwards;
	public IReadOnlyList<Particle> Particles => particles;
#if STANDALONE
#endif
	PhysicsWorld World => physicsScene.PhysicsWorld;

	static bool prepared;
	/// <summary>Compile and initialize particle/physics paths before play, in an isolated disposable world.</summary>
	public static void Prepare()
	{
		if ( prepared ) return;
		var probe = new DiamondDebris( 0 );
		try
		{
			probe.CreateWorld();
			// Exercise both collision tags, native fixtures and the filter callback.
			// The probe owns its randomness and never publishes particles or awards.
			probe.EmitShatter( new Diamond( 16, FloorY( 16 ), 0 ) );
			probe.AddSpark( new Vector2( LaneX( 16 ), FloorY( 16 ) ), Vector2.Zero, 1, 0 );
			probe.AddSpark( new Vector2( LaneX( 16 ), FloorY( 16 ) ), Vector2.Zero, 1, 0 );
			probe.World.Step( 1 / 480f, 1 );
			prepared = true;
		}
		finally { probe.Clear(); }
	}

	public void Clear()
	{
		physicsScene?.Destroy();
		physicsScene = null;
		particles.Clear();
		smoke.Clear();
		shatterSunbursts.Clear();
		crushSunbursts.Clear();
		settledBodies.Clear();
		activeBodies.Clear();
		cells.Clear();
		crushDiamonds.Clear();
		crushFaces.Clear();
		currentBoard = null;
		lastFlash = 0;
		lastFaceFlash = 0;
		lastShatter = 0;
		lastCornerSpark = 0;
		CrushedThisFrame = 0;
		crushAwards.Clear();
	}

	void CreateWorld()
	{
		// A private scene selects the engine's Box2D backend. It isn't added to the
		// game scene hierarchy or ticked; we step only its physics world below.
		physicsScene = new Scene { PhysicsMode = ScenePhysicsMode.Physics2D };
		physicsRevision = PhysicsRevision;
		World.Gravity = new Vector3( 0, 780, 0 );
		World.SleepingEnabled = true;
		var rules = new Sandbox.Physics.CollisionRules();
		rules.Pairs[("spark", "spark")] = Sandbox.Physics.CollisionRules.Result.Ignore;
		rules.Pairs[("shard", "spark")] = Sandbox.Physics.CollisionRules.Result.Ignore;
		rules.Serialize(); // Validate and rebuild the Box2D runtime tag filter.
		World.CollisionRules = rules;
		var floor = World.CreateBody();
		floor.BodyType = PhysicsBodyType.Static;
		for ( int i = 0; i < Valleys * 2; i++ )
		{
			float x = i * Width * 0.5f, nextX = x + Width * 0.5f;
			float y = BoardHeight - (i % 2 == 0 ? Height * 0.5f : 0);
			float nextY = BoardHeight - (i % 2 == 0 ? 0 : Height * 0.5f);
			floor.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>
			{
				new( x, y, 0 ), new( nextX, nextY, 0 ), new( nextX, BoardHeight + 32, 0 ), new( x, BoardHeight + 32, 0 )
			} ).Surface = surface;
		}
		floor.AddBoxShape( new Vector3( -32, BoardHeight * 0.5f, 0 ), Rotation.Identity, new Vector3( 32, BoardHeight * 0.5f + 64, 1 ) ).Surface = surface;
		floor.AddBoxShape( new Vector3( BoardWidth + 32, BoardHeight * 0.5f, 0 ), Rotation.Identity, new Vector3( 32, BoardHeight * 0.5f + 64, 1 ) ).Surface = surface;
		floor.AddBoxShape( new Vector3( BoardWidth * 0.5f, -32, 0 ), Rotation.Identity, new Vector3( BoardWidth * 0.5f + 64, 32, 1 ) ).Surface = surface;
		foreach ( var tooth in SideWallTeeth() )
		{
			float x = LaneX( tooth.Lane ), inward = tooth.Lane < 0 ? 1 : -1;
			floor.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>
			{
				new( x, tooth.Y - Height * 0.5f, 0 ), new( x + inward * Width * 0.5f, tooth.Y, 0 ),
				new( x, tooth.Y + Height * 0.5f, 0 )
			} ).Surface = surface;
		}
		foreach ( var corner in CornerFills( FillCornerPockets ) )
		{
			float x = LaneX( corner.Lane ), y = corner.Y;
			floor.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>
			{
				new( x, y - Height * 0.5f, 0 ), new( x + Width * 0.5f, y, 0 ),
				new( x, y + Height * 0.5f, 0 ), new( x - Width * 0.5f, y, 0 )
			} ).Surface = surface;
		}
		floor.EnableTouch = false;
	}

	PhysicsBody CreateDiamond( Diamond cell )
	{
		var body = World.CreateBody();
		body.BodyType = PhysicsBodyType.Keyframed;
		body.Position = new Vector3( LaneX( cell.Lane ), cell.Y, 0 );
		// Convex fixtures tile the complete piece, preserving concave notches and
		// holes rather than filling them with a convex hull of the whole assembly.
		body.AddHullShape( Vector3.Zero, Rotation.Identity, new List<Vector3>
		{
			new( 0, -Height * 0.5f, 0 ), new( Width * 0.5f, 0, 0 ),
			new( 0, Height * 0.5f, 0 ), new( -Width * 0.5f, 0, 0 )
		} ).Surface = surface;
		body.EnableTouch = false;
		return body;
	}

	void SyncBodies( List<PhysicsBody> bodies, IReadOnlyList<Diamond> diamonds, float delta, bool rebuild = false )
	{
#if STANDALONE
#endif
		// A lethal landing can add exactly as many cells as it removes. Indices
		// then refer to different gems despite an unchanged count; never animate
		// those replacement colliders across the board from their former owners.
		if ( rebuild || bodies.Count != diamonds.Count )
		{
			foreach ( var body in bodies ) body.Remove();
			bodies.Clear();
			foreach ( var cell in diamonds ) bodies.Add( CreateDiamond( cell ) );
		}
		for ( int i = 0; i < diamonds.Count; i++ )
		{
			var cell = diamonds[i];
			var target = new Vector3( LaneX( cell.Lane ), cell.Y, 0 );
			// Keyframed velocities carry/push debris along with animated supports.
			var body = bodies[i];
			if ( (body.Position - target).LengthSquared > 0.0001f ) body.Move( new Transform( target ), delta );
			else if ( body.Velocity != Vector3.Zero ) body.Velocity = Vector3.Zero;
		}
	}

	public void Update( DiamondBoard board, float delta )
	{
#if STANDALONE
#endif
		CrushedThisFrame = 0;
		crushAwards.Clear();
		if ( board != currentBoard || board.ResetVersion != resetVersion || physicsRevision != PhysicsRevision )
		{
			Clear();
			currentBoard = board;
			resetVersion = board.ResetVersion;
		}
		if ( board.Paused || delta <= 0 ) return;
		delta = Math.Clamp( delta, 0, 0.1f );
		if ( physicsScene is null ) CreateWorld();
		SyncBodies( settledBodies, board.Settled, delta, settledVersion != board.SettledVersion );
		settledVersion = board.SettledVersion;
		cells.Clear();
		if ( !board.GameOver && !board.IsResolving ) CopyCellsOf( board.Active, cells );
		SyncBodies( activeBodies, cells, delta, activeSpawnVersion != board.SpawnVersion );
		activeSpawnVersion = board.SpawnVersion;
		cells.AddRange( board.Settled );
		UpdateShatterSunbursts( delta );
		EmitSlidingSparks( board, delta );
		foreach ( var flash in board.DamageFlashes )
		{
			if ( flash.Id <= lastFlash ) continue;
			Emit( flash, board.GameOver );
			lastFlash = flash.Id;
		}
		foreach ( var contact in board.CornerSparks )
		{
			if ( contact.Id <= lastCornerSpark ) continue;
			bool gentle = contact.Speed <= DamageSpeedThreshold;
			EmitCorner( contact.Contact, gentle ? random.Next( 3, 6 ) : SparkCount( 1 ),
				gentle ? Range( 0.18f, 0.28f ) : SparkStrength( 1 ) );
			lastCornerSpark = contact.Id;
		}
		foreach ( var burst in board.ShatterBursts )
		{
			if ( burst.Id <= lastShatter ) continue;
			if ( !board.GameOver ) EmitShatter( burst.Cell );
			EmitShatterEffects( burst.Cell, board.GameOver );
			lastShatter = burst.Id;
		}
		foreach ( var flash in board.FaceFlashes )
		{
			if ( flash.Id <= lastFaceFlash ) continue;
			EmitFaceSparks( flash );
			lastFaceFlash = flash.Id;
		}
		// Keep solver substeps at most 1/480s: softened contacts at 1/240s can
		// sleep with visibly compressed shards in a stack pocket. Outer steps
		// still batch geometry checks at 60 Hz, or 120 Hz for fast closing gems.
		bool fastGems = settledBodies.Any( b => b.Velocity.LengthSquared > 480 * 480 ) ||
			activeBodies.Any( b => b.Velocity.LengthSquared > 480 * 480 );
		int steps = Math.Max( 1, (int)MathF.Ceiling( delta * (fastGems ? 120 : 60) ) );
		int solverSubsteps = Math.Max( 4, (int)MathF.Ceiling( delta / steps * 480 ) );
#if STANDALONE
#endif
		UpdateSmoke( delta );
		for ( int step = 0; step < steps; step++ )
		{
			CacheCrushDiamonds();
			CrushTrappedChips( delta / steps );
			ConstrainParticles();
			{
#if STANDALONE
#endif
				World.Step( delta / steps, solverSubsteps );
			}
			ConstrainParticles();
			CacheCrushDiamonds();
			CrushEmbeddedShards();
			ResolveShardOverlaps();
		}
#if STANDALONE
#endif
		for ( int i = particles.Count - 1; i >= 0; i-- )
		{
			var chip = particles[i];
			chip.Age += delta;
			// Shards blink out once their own sampled lifetime passes; sparks fade out by age.
			if ( !chip.IsSpark && chip.BlinkTime < 0 && chip.Age > chip.MaxAge ) chip.BlinkTime = 0;
			if ( chip.BlinkTime >= 0 ) chip.BlinkTime += delta;
			if ( (chip.IsSpark && chip.Age >= chip.MaxAge) || chip.BlinkTime >= BlinkDuration || chip.Position.y > BoardHeight + 64 )
			{
				if ( chip.BlinkTime >= BlinkDuration && chip.Position.y <= BoardHeight ) EmitSmoke( chip, false );
				chip.Body.Remove();
				particles.RemoveAt( i );
			}
		}
	}

	float Range( float min, float max ) => min + (max - min) * (float)random.NextDouble();

	bool HasRoom( Vector2 position, float radius )
	{
		if ( position.x < radius || position.x > BoardWidth - radius || position.y < radius ) return false;
		float wall = SideWallInset( position.y, FillCornerPockets ) + radius * MathF.Sqrt( 1 + Width * Width / (Height * Height) );
		if ( position.x < wall || position.x > BoardWidth - wall ) return false;
		float floor = BoardHeight - Height * 0.5f * MathF.Abs( 1 - position.x / (Width * 0.5f) % 2 );
		if ( position.y > floor - radius * MathF.Sqrt( 1 + Height * Height / (Width * Width) ) ) return false;
		foreach ( var cell in cells )
		{
			float dx = MathF.Abs( position.x - LaneX( cell.Lane ) );
			float dy = MathF.Abs( position.y - cell.Y );
			// Expanded face half-planes, conservative at the tips for a round chip.
			if ( dx / (Width * 0.5f) + dy / (Height * 0.5f) <
				1 + radius * MathF.Sqrt( 4 / (Width * Width) + 4 / (Height * Height) ) ) return false;
		}
		return true;
	}

	void Emit( DamageFlash flash, bool gameOver )
	{
		EmitCorner( new( flash.Lane, flash.Y, flash.ColorIndex ),
			gameOver ? random.Next( 3, 6 ) : SparkCount( flash.DamageCount ), SparkStrength( flash.DamageCount ) );
	}

	void EmitCorner( DiamondDamage.Contact contact, int count, float strength )
	{
#if STANDALONE
#endif
		var origin = new Vector2( LaneX( contact.Lane ), contact.Y );
		for ( int i = 0; i < count; i++ )
		{
			float radius = Range( 0.7f, 1.8f );
			Vector2 direction = default, position = origin;
			bool found = false;
			// Separate just outside the struck tips, so Box2D doesn't eject a chip
			// from inside the receiving or incoming diamond with an arbitrary impulse.
			for ( int attempt = 0; attempt < 24 && !found; attempt++ )
			{
				float angle = Range( -MathF.PI, MathF.PI );
				direction = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );
				for ( float distance = radius + 1; distance <= 12; distance += 2 )
				{
					position = origin + direction * distance;
					if ( HasRoom( position, radius + 0.5f ) ) { found = true; break; }
				}
			}
			if ( !found ) continue;
			var velocity = new Vector2( direction.x * Range( 100, 260 ), direction.y * Range( 90, 220 ) - 130 ) * strength;
			AddSpark( position, velocity, radius, contact.ColorIndex );
		}
	}

	int SparkCount( int damageCount )
	{
		// Quiet single hits; broad bonded damage gets a much denser shower.
		float count = 10 + 6 * MathF.Pow( Math.Clamp( damageCount, 1, 32 ) - 1, 1.5f );
		return Math.Clamp( (int)MathF.Round( count * Range( 0.9f, 1.1f ) ), 8, 240 );
	}

	float SparkStrength( int damageCount ) =>
		(1 + 0.22f * Math.Min( 12, Math.Max( 0, damageCount - 1 ) )) * Range( 0.95f, 1.05f );

	void EmitShatter( Diamond cell )
	{
		var center = new Vector2( LaneX( cell.Lane ), cell.Y );
		// Each horizontal half is already almost equilateral (56 x 52 x 52).
		// Quarter it through its edge midpoints, then inset each triangle about
		// its centroid so the eight fragments start with breathing room.
		foreach ( int side in new[] { -1, 1 } )
		{
			var tip = new Vector2( 0, side * Height / 2 );
			var left = new Vector2( -Width / 2, 0 );
			var right = new Vector2( Width / 2, 0 );
			var leftMid = (tip + left) / 2;
			var rightMid = (tip + right) / 2;
			AddShard( tip, leftMid, rightMid );
			AddShard( left, Vector2.Zero, leftMid );
			AddShard( right, rightMid, Vector2.Zero );
			AddShard( Vector2.Zero, rightMid, leftMid );
		}

		void AddShard( Vector2 a, Vector2 b, Vector2 c )
		{
			var offset = (a + b + c) / 3;
			float scale = Range( 0.66f, 0.78f );
			Vector2[] vertices = [(a - offset) * scale, (b - offset) * scale, (c - offset) * scale];
			var outward = offset.Normal;
			float angle = Range( -0.25f, 0.25f );
			var direction = outward * MathF.Cos( angle ) + new Vector2( -outward.y, outward.x ) * MathF.Sin( angle );
			AddParticle( center + offset, direction * Range( 600, 900 ), vertices, cell.ColorIndex, false );
		}
	}

	void EmitFaceSparks( FaceFlash flash )
	{
		var face = flash.Contact;
		var start = new Vector2( LaneX( face.StartLane ), face.StartY );
		var end = new Vector2( LaneX( face.EndLane ), face.EndY );
		var tangent = (end - start).Normal;
		// A full face seam is sealed by two gems. Escape around its endpoints,
		// searching outside both bodies instead of spawning inside either one.
		int count = SparkCount( flash.DamageCount );
		float strength = SparkStrength( flash.DamageCount );
		for ( int i = 0; i < count; i++ )
		{
			float radius = Range( 0.7f, 1.8f );
			bool atStart = i % 2 == 0;
			var origin = atStart ? start : end;
			var outward = tangent * (atStart ? -1 : 1);
			for ( int attempt = 0; attempt < 24; attempt++ )
			{
				float angle = Range( -1.2f, 1.2f );
				var direction = outward * MathF.Cos( angle ) + new Vector2( -outward.y, outward.x ) * MathF.Sin( angle );
				var position = origin + direction * Range( 3, 14 );
				if ( !HasRoom( position, radius + 0.5f ) ) continue;
				AddSpark( position, direction * Range( 130, 310 ) * strength, radius, face.ColorIndex );
				break;
			}
		}
	}

	void AddSpark( Vector2 position, Vector2 velocity, float radius, int colorIndex )
	{
		// Three quarters triangular splinters; the remaining chips are squares.
		bool triangle = random.NextDouble() < 0.76;
		float w = radius * Range( 0.65f, 1 ), h = radius;
		Vector2[] vertices = triangle
			? [new( -w, h * 0.65f ), new( w, h * 0.65f ), new( Range( -0.35f, 0.35f ) * w, -h )]
			: [new( -w, -w ), new( w, -w ), new( w, w ), new( -w, w )];
		AddParticle( position, velocity * MathF.Max( 0, SparkForceMultiplier ), vertices, colorIndex, true );
	}

	void AddParticle( Vector2 position, Vector2 velocity, Vector2[] vertices, int colorIndex, bool spark )
	{
		// Sparks cannot evict the long-lived, score-bearing shards.
		int count = 0, oldest = -1;
		for ( int i = 0; i < particles.Count; i++ )
		{
			if ( particles[i].IsSpark != spark ) continue;
			if ( oldest < 0 ) oldest = i;
			count++;
		}
		if ( count >= (spark ? MaxSparks : MaxShards) )
		{
			particles[oldest].Body.Remove();
			particles.RemoveAt( oldest );
		}
		var body = World.CreateBody();
		body.BodyType = PhysicsBodyType.Dynamic;
		body.Position = new Vector3( position.x, position.y, 0 );
		float radius = 0;
		for ( int i = 0; i < vertices.Length; i++ ) radius = MathF.Max( radius, vertices[i].Length );
		// Sub-unit spark polygons fall below Box2D's hull-welding tolerance.
		// Use a tiny round fixture; full-size shards retain their exact triangles.
		PhysicsShape shape;
		if ( spark ) shape = body.AddSphereShape( Vector3.Zero, radius );
		else
		{
			var hull = new List<Vector3>( vertices.Length );
			for ( int i = 0; i < vertices.Length; i++ ) hull.Add( new( vertices[i].x, vertices[i].y, 0 ) );
			shape = body.AddHullShape( Vector3.Zero, Rotation.Identity, hull );
		}
		shape.Surface = spark ? sparkSurface : surface;
		// Debris uses geometric crush checks, not engine contact callbacks.
		shape.EnableTouch = false;
		shape.Tags.Add( spark ? "spark" : "shard" );
		body.Rotation = spark ? Rotation.FromYaw( Range( 0, 360 ) ) : Rotation.Identity;
		if ( spark && velocity.LengthSquared > MaxSparkSpeed * MaxSparkSpeed ) velocity = velocity.Normal * MaxSparkSpeed;
		body.Velocity = new Vector3( velocity.x, velocity.y, 0 );
		body.AngularVelocity = new Vector3( 0, 0, spark ? Range( -620, 620 ) : Range( -160, 160 ) );
		// Each spark samples its own weight so a burst fans into varied arcs.
		if ( spark ) body.GravityScale = Range( MathF.Max( 0, SparkGravityMin ), MathF.Max( SparkGravityMin, SparkGravityMax ) );
		body.LinearDamping = 0.35f;
		body.AngularDamping = 1.8f;
		body.EnhancedCcd = true;
		body.AutoSleep = true;
		body.SleepThreshold = 8;
		particles.Add( new Particle { Body = body, Radius = radius, Vertices = vertices, ColorIndex = colorIndex,
			IsSpark = spark, Brightness = spark ? Range( 0.5f, 0.8f ) : Range( 0.15f, 0.4f ),
			MaxAge = spark ? Range( 0.5f, 0.9f ) * MathF.Max( 0.1f, SparkLifetimeMultiplier ) : Range( MathF.Max( 0, ShardLifetimeMin ), MathF.Max( ShardLifetimeMin, ShardLifetimeMax ) ),
			ShineDelay = spark ? 0 : Range( 0.5f, 3 ), ShinePeriod = spark ? 3.5f : Range( 2.5f, 4.5f ),
			ShineDirection = spark || random.Next( 2 ) == 0 ? 1 : -1 } );
	}
}