DiamondTitleEffects.cs
using System;
using System.Collections.Generic;
using Sandbox;

namespace Diamonds;

/// <summary>Menu-only sparks with swept button collisions, independent of gameplay physics.</summary>
public sealed class DiamondTitleEffects
{
	public sealed class Spark
	{
		public Vector2 Position, Velocity;
		public Color Color;
		public float Radius, Age, Lifetime;
	}

	public sealed class Burst
	{
		public Vector2 Position;
		public Vector2? FaceEnd;
		public Color Color;
		public float Age, Rotation;
	}

	readonly Random random = new();
	readonly List<Spark> particles = new();
	readonly List<Burst> bursts = new();
	public IReadOnlyList<Spark> Particles => particles;
	public IReadOnlyList<Burst> Bursts => bursts;
	public float Range( float min, float max ) => min + (max - min) * (float)random.NextDouble();

	public void Clear() { particles.Clear(); bursts.Clear(); }

	public void EmitSpark( Vector2 position, Vector2 velocity, Color color, float radius = 1.5f )
	{
		if ( particles.Count >= 500 ) particles.RemoveAt( 0 );
		particles.Add( new Spark { Position = position, Velocity = velocity, Color = color,
			Radius = radius, Lifetime = Range( 1.7f, 2.7f ) } );
	}

	public void EmitSlide( Vector2 position, Color color, float speed )
	{
		// Mostly escape backward along the underside, with a smaller leading spray.
		var tangent = new Vector2( 0.62932f, -0.777146f );
		var normal = new Vector2( 0.777146f, 0.62932f );
		float along = random.NextDouble() < 0.8 ? 1 : -1;
		var velocity = (tangent * (along * Range( 140, 470 )) + normal * Range( 40, 170 )) * (0.15f + speed * 1.5f);
		EmitSpark( position, velocity, color, Range( 1, 2.3f ) );
	}

	public void EmitImpact( Vector2 point, Color color )
		=> EmitImpact( point, color, null );

	public void EmitFaceImpact( Vector2 start, Vector2 end, Color color )
		=> EmitImpact( start, color, end );

	void EmitImpact( Vector2 point, Color color, Vector2? end )
	{
		bursts.Add( new Burst { Position = point, FaceEnd = end, Color = color, Rotation = Range( 0, MathF.PI * 2 ) } );
		for ( int i = 0; i < 64; i++ )
		{
			float angle = Range( 0, MathF.PI * 2 );
			var velocity = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * Range( 180, 730 );
			EmitSpark( end.HasValue ? point + (end.Value - point) * Range( 0, 1 ) : point, velocity, color, Range( 1.1f, 2.7f ) );
		}
	}

	public void Update( float delta, Rect viewport, ReadOnlySpan<Rect> buttons )
	{
		foreach ( var burst in bursts ) burst.Age += delta;
		bursts.RemoveAll( b => b.Age >= 0.25f );
		for ( int i = particles.Count - 1; i >= 0; i-- )
		{
			var spark = particles[i];
			spark.Age += delta;
			if ( spark.Age >= spark.Lifetime || !viewport.IsInside( spark.Position ) )
			{
				particles.RemoveAt( i );
				continue;
			}
			spark.Velocity.y += 920 * delta;
			float remaining = delta;
			for ( int bounce = 0; bounce < 4 && remaining > 0; bounce++ )
			{
				var motion = spark.Velocity * remaining;
				float first = 1;
				var normal = Vector2.Zero;
				foreach ( var button in buttons ) Sweep( spark.Position, motion, button.Grow( spark.Radius ), ref first, ref normal );
				spark.Position += motion * first;
				if ( normal == Vector2.Zero ) break;
				spark.Position += normal * 0.05f;
				float incoming = Vector2.Dot( spark.Velocity, normal );
				var tangent = spark.Velocity - normal * incoming;
				spark.Velocity = tangent * 0.88f - normal * (incoming * 0.62f);
				remaining *= 1 - first;
			}
			if ( !viewport.IsInside( spark.Position ) ) particles.RemoveAt( i );
		}
	}

	// Segment/slab intersection finds the first entering face, even at high speed.
	static void Sweep( Vector2 position, Vector2 motion, Rect rect, ref float first, ref Vector2 normal )
	{
		float enter = float.NegativeInfinity, leave = float.PositiveInfinity;
		var face = Vector2.Zero;
		for ( int axis = 0; axis < 2; axis++ )
		{
			float origin = axis == 0 ? position.x : position.y;
			float travel = axis == 0 ? motion.x : motion.y;
			float min = axis == 0 ? rect.Left : rect.Top, max = axis == 0 ? rect.Right : rect.Bottom;
			if ( MathF.Abs( travel ) < 0.00001f )
			{
				if ( origin < min || origin > max ) return;
				continue;
			}
			float near = (min - origin) / travel, far = (max - origin) / travel;
			if ( near > far ) (near, far) = (far, near);
			if ( near > enter )
			{
				enter = near;
				face = axis == 0 ? new Vector2( -MathF.Sign( travel ), 0 ) : new Vector2( 0, -MathF.Sign( travel ) );
			}
			leave = MathF.Min( leave, far );
			if ( enter > leave ) return;
		}
		if ( enter < 0 || enter > first || leave < 0 ) return;
		first = enter;
		normal = face;
	}
}