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

namespace Diamonds;

/// <summary>Cosmetic score delivery, independent of shard physics and gameplay randomness.</summary>
public sealed class DiamondScoreEffects
{
	// Board-relative coordinates; the HUD and pickups share one unshaken canvas.
	public Vector2 Target { get; set; } = new( -160, 208 );
	readonly DiamondScoreLayout layout = new();
	public DiamondScoreLayout Layout { get { layout.Update( DisplayedScore ); return layout; } }
	public const string ScoreFont = "Enixe";
	// Enixe's capitals occupy about 57% of its em; 40px preserves the old 23px glyph height.
	public const float PickupFontSize = 40;
	const float NormalArrivalSpacing = 0.07f;
	// Preserve the readable rhythm for small bursts, but drain crowded screens faster.
	static float ArrivalSpacing( int count ) => MathF.Max( 0.025f, NormalArrivalSpacing / MathF.Sqrt( MathF.Max( 1, count / 10f ) ) );
	public static float ValueScale( long points ) => 1 + (Math.Clamp( points, 1, 100 ) - 1) / 99f;
	public static Vector2 MeasureNumber( string value ) =>
		new TextRendering.Scope( value, Color.White, PickupFontSize, ScoreFont ).Measure();
	public sealed class Pickup
	{
		public Vector2 Position, Velocity, PullStart, PullVelocity;
		public Vector2 TargetSample = new( 0.5f );
		public long Points;
		public int ColorIndex;
		public float Age, FloatDuration, PullDuration, Gravity, HorizontalRadius;
		public float ArrivalInterval = NormalArrivalSpacing;
		public float PullProgress
		{
			get
			{
				if ( Age <= FloatDuration ) return 0;
				if ( Age >= FloatDuration + PullDuration ) return 1;
				float elapsed = Age - FloatDuration;
				// Exponentially ramp travel speed in real seconds. A queued flight
				// still finishes with a fast snap instead of scaling all its speed down.
				// Negative exponents keep long arrival queues numerically bounded.
				const float acceleration = 5;
				return MathF.Exp( -acceleration * (PullDuration - elapsed) )
					* (1 - MathF.Exp( -acceleration * elapsed )) / (1 - MathF.Exp( -acceleration * PullDuration ));
			}
		}
	}
	public sealed class Spark
	{
		public Vector2 Position, Velocity;
		public int ColorIndex;
		public float Age, Lifetime;
	}
	readonly Random random;
	readonly List<Pickup> pickups = new();
	readonly List<Spark> sparks = new();
	DiamondBoard currentBoard;
	int resetVersion;
	float bounceAge = 1, colorHeat, effectTime, nextArrivalTime;
	float bounceSpeed = 1;
	public long DisplayedScore { get; private set; }
	public float ColorPhase { get; private set; }
	public float ColorSpeed { get; private set; } = 0.28f;
	public float Scale
	{
		get
		{
			// Straight ramps and hard corners: compress immediately on every hit,
			// punch out, hold briefly, then cut back with a tiny undershoot.
			if ( bounceAge < 0.035f ) return 0.92f + 0.38f * (bounceAge / 0.035f);
			if ( bounceAge < 0.05f ) return 1.30f;
			if ( bounceAge < 0.12f ) return 1.30f - 0.34f * ((bounceAge - 0.05f) / 0.07f);
			if ( bounceAge < 0.155f ) return 0.96f + 0.04f * ((bounceAge - 0.12f) / 0.035f);
			return 1;
		}
	}
	// Not readonly: a hotloaded instance may predate this field.
	List<long> arrivals = new();
	/// <summary>Point values delivered into the score during the latest update.</summary>
	public IReadOnlyList<long> Arrivals => arrivals ??= new();
	public IReadOnlyList<Pickup> Pickups => pickups;
	public IReadOnlyList<Spark> Sparks => sparks;
	public DiamondScoreEffects( int? seed = null ) => random = seed.HasValue ? new Random( seed.Value ) : new Random();

	public void Clear()
	{
		pickups.Clear();
		sparks.Clear();
		arrivals?.Clear();
		currentBoard = null;
		DisplayedScore = 0;
		bounceAge = 1;
		bounceSpeed = 1;
		colorHeat = ColorPhase = effectTime = nextArrivalTime = 0;
		ColorSpeed = 0.28f;
	}

	public void Update( DiamondBoard board, IReadOnlyList<DiamondDebris.CrushAward> awards, float delta )
	{
		(arrivals ??= new()).Clear();
		if ( board != currentBoard || board.ResetVersion != resetVersion )
		{
			Clear();
			currentBoard = board;
			resetVersion = board.ResetVersion;
			DisplayedScore = board.Score;
			foreach ( var award in awards ) DisplayedScore -= award.Points;
		}
		if ( board.Paused ) return;
		// Finish earned deliveries even if the final placement ended the game.
		float remaining = Math.Clamp( delta, 0, 0.1f );
		// Advance once per rendered update. An arrival below holds its compressed
		// scale for that frame rather than spending the punch inside substeps.
		bounceAge = MathF.Min( 1, bounceAge + remaining * bounceSpeed );
		while ( remaining > 0 )
		{
			float step = MathF.Min( remaining, 1 / 120f );
			Advance( step );
			remaining -= step;
		}
		foreach ( var award in awards )
		{
			// Match the font's actual width, including the plus, outline and capped launch pop.
			float maxScale = MathF.Min( 2, ValueScale( award.Points ) * 1.28f );
			float radius = (MeasureNumber( $"+{award.Points}" ).x * 0.5f + 2) * maxScale;
			pickups.Add( new Pickup { Position = award.Position.WithX( Math.Clamp( award.Position.x, radius, DiamondBoard.BoardWidth - radius ) ),
				Points = award.Points, ColorIndex = award.ColorIndex, HorizontalRadius = radius,
				Velocity = new Vector2( Range( -240, 240 ), Range( -470, -330 ) ), Gravity = Range( 240, 460 ),
				FloatDuration = Range( 0.8f, 1.2f ), PullDuration = Range( 0.32f, 0.46f ) } );
		}
	}

	void Advance( float delta )
	{
		effectTime += delta;
		colorHeat *= MathF.Exp( -delta * 1.7f );
		ColorSpeed += (0.28f + colorHeat - ColorSpeed) * (1 - MathF.Exp( -delta * 5 ));
		ColorPhase = (ColorPhase + ColorSpeed * delta) % DiamondBoard.ColorCount;
		for ( int i = sparks.Count - 1; i >= 0; i-- )
		{
			var spark = sparks[i];
			spark.Age += delta;
			spark.Velocity += new Vector2( 0, 150 * delta );
			spark.Position += spark.Velocity * delta;
			if ( spark.Age >= spark.Lifetime ) sparks.RemoveAt( i );
		}
		for ( int i = pickups.Count - 1; i >= 0; i-- )
		{
			var pickup = pickups[i];
			float floatStep = Math.Clamp( pickup.FloatDuration - pickup.Age, 0, delta );
			if ( floatStep > 0 )
			{
				pickup.Position += pickup.Velocity * floatStep + new Vector2( 0, pickup.Gravity * floatStep * floatStep * 0.5f );
				pickup.Velocity += new Vector2( 0, pickup.Gravity * floatStep );
				// Reflect overshoot as well as velocity, only during the free flight.
				// Once pulling, numbers can cross the wall into the score's letterbox.
				float left = pickup.HorizontalRadius, right = DiamondBoard.BoardWidth - left;
				if ( pickup.Position.x < left )
				{
					pickup.Position = pickup.Position.WithX( left + (left - pickup.Position.x) );
					pickup.Velocity = pickup.Velocity.WithX( MathF.Abs( pickup.Velocity.x ) * 0.82f );
				}
				else if ( pickup.Position.x > right )
				{
					pickup.Position = pickup.Position.WithX( right - (pickup.Position.x - right) );
					pickup.Velocity = pickup.Velocity.WithX( -MathF.Abs( pickup.Velocity.x ) * 0.82f );
				}
				// Keep the number readable even when a shard is crushed near the ceiling.
				if ( pickup.Position.y < 22 ) { pickup.Position = pickup.Position.WithY( 22 ); pickup.Velocity = pickup.Velocity.WithY( MathF.Max( 0, pickup.Velocity.y ) ); }
				pickup.PullStart = pickup.Position;
				pickup.PullVelocity = pickup.Velocity;
				if ( pickup.Age + delta >= pickup.FloatDuration )
				{
					pickup.TargetSample = new Vector2( Range( 0, 1 ), Range( 0, 1 ) );
					// Give closely packed deliveries separate visible hits. Stretch the
					// pull, never the ballistic phase, so waiting numbers stay on screen.
					float pullStartTime = effectTime - delta + floatStep;
					float arrivalTime = MathF.Max( pullStartTime + pickup.PullDuration, nextArrivalTime );
					pickup.PullDuration = arrivalTime - pullStartTime;
					pickup.ArrivalInterval = ArrivalSpacing( pickups.Count );
					nextArrivalTime = arrivalTime + pickup.ArrivalInterval;
				}
			}
			pickup.Age += delta;
			if ( pickup.Age <= pickup.FloatDuration ) continue;
			float t = pickup.PullProgress;
			var previous = pickup.Position;
			// A short curved pull accelerates into the score; preserve the launch tangent.
			var control = pickup.PullStart + pickup.PullVelocity * MathF.Min( pickup.PullDuration, 0.46f ) * 0.5f;
			var destination = Target + Layout.ArrivalOffset( pickup.TargetSample ) * Scale;
			pickup.Position = pickup.PullStart * ((1 - t) * (1 - t)) + control * (2 * (1 - t) * t) + destination * (t * t);
			pickup.Velocity = (pickup.Position - previous) / delta;
			if ( t < 1 ) continue;
			DisplayedScore += pickup.Points;
			arrivals.Add( pickup.Points );
			bounceAge = 0;
			bounceSpeed = NormalArrivalSpacing / MathF.Max( 0.025f, pickup.ArrivalInterval );
			colorHeat = MathF.Min( 5, colorHeat + 0.42f );
			for ( int s = 0; s < 9; s++ )
			{
				float angle = Range( 0, MathF.PI * 2 );
				sparks.Add( new Spark { Position = pickup.Position, ColorIndex = pickup.ColorIndex, Lifetime = Range( 0.22f, 0.42f ),
					Velocity = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * Range( 65, 210 ) } );
			}
			pickups.RemoveAt( i );
		}
		if ( sparks.Count > 360 ) sparks.RemoveRange( 0, sparks.Count - 360 );
	}

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