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 );
}
}