Scoring/ComboDrain.cs
using System;

namespace SquishyTerry.Scoring;

/// <summary>
/// The timeline of a combo running out, shared by the <see cref="ScoreKeeper"/> (which lets a hit
/// during it rescue the combo) and the combo HUD (which animates it), so the number you see counting
/// down is exactly the number a rescue keeps.
/// <list type="bullet">
/// <item>Freeze: the count holds for a moment.</item>
/// <item>Phase A (combos bigger than the bar): the count eases down to a full bar.</item>
/// <item>Swap gap: a short hold at a full bar (the HUD swaps its title card back to the bar here).</item>
/// <item>Phase B: the bar drains one segment per step, the count following it to zero.</item>
/// </list>
/// </summary>
public static class ComboDrain
{
	/// <summary>Segments in the HUD's bar (a combo this big fills it).</summary>
	public const int Segments = 20;

	public const float FreezeTime = 0.3f;
	public const float CountdownA = 0.7f;
	public const float SwapGap = 0.3f;
	public const float DropStep = 0.035f;
	public const float DropFade = 0.06f;

	public static bool HasPhaseA( int peak ) => peak > Segments;
	public static int Lit( int peak ) => Math.Clamp( peak, 0, Segments );
	public static float PhaseBStart( int peak ) => FreezeTime + (HasPhaseA( peak ) ? CountdownA + SwapGap : 0f);

	/// <summary>Total length of the drain for a combo of this size.</summary>
	public static float Duration( int peak ) => PhaseBStart( peak ) + Lit( peak ) * DropStep + DropFade;

	/// <summary>0..1 how far bar segment k (0 = bottom) has dropped out at time t; the top lit one goes first.</summary>
	public static float Dropped( int peak, int k, float t )
	{
		float at = PhaseBStart( peak ) + (Lit( peak ) - 1 - k) * DropStep;
		return Math.Clamp( (t - at) / DropFade, 0f, 1f );
	}

	/// <summary>The combo value shown (and kept by a rescue) at time t into the drain.</summary>
	public static int ValueAt( int peak, float t )
	{
		if ( peak <= 0 ) return 0;

		// Phase A: ease the count down from the combo to a full bar.
		if ( HasPhaseA( peak ) && t < FreezeTime + CountdownA )
		{
			float p = Math.Clamp( (t - FreezeTime) / CountdownA, 0f, 1f );
			float eased = 1f - (1f - p) * (1f - p);
			return (int)MathF.Ceiling( peak + (Segments - peak) * eased - 0.001f );
		}

		// Phase B: exactly the segments still standing.
		float remaining = 0f;
		for ( int k = 0; k < Lit( peak ); k++ ) remaining += 1f - Dropped( peak, k, t );
		return (int)MathF.Ceiling( remaining - 0.001f );
	}
}