Utility static class that provides a small set of easing functions (OutCubic, InQuad, OutBack, Pulse), a Progress helper and a Clamp01 helper. It computes normalized easing values for UI/animation timing without engine dependencies.
namespace Coilgarden;
/// <summary>
/// The easing vocabulary, and all of it. Four curves, each with one job.
/// <para>
/// Keeping the set this small is the point rather than a limitation: a consistent easing
/// vocabulary is most of what separates "animated" from "polished". Anything that arrives
/// uses <see cref="OutCubic"/>, anything that should feel springy uses <see cref="OutBack"/>,
/// anything that leaves uses <see cref="InQuad"/>, and one-shot flashes use
/// <see cref="Pulse"/>.
/// </para>
/// <para>
/// Pure maths with no engine dependency, so the curves are covered by the headless suite -
/// which matters more than it sounds. An easing function with the wrong endpoints does not
/// look wrong, it looks like a <em>positioning</em> bug somewhere else entirely.
/// </para>
/// </summary>
public static class Ease
{
/// <summary>Decelerates into its destination. The default for anything that arrives.</summary>
public static float OutCubic( float t )
{
t = Clamp01( t );
var inverted = 1f - t;
return 1f - inverted * inverted * inverted;
}
/// <summary>Accelerates away. For anything leaving, shrinking or being consumed.</summary>
public static float InQuad( float t )
{
t = Clamp01( t );
return t * t;
}
/// <summary>
/// Overshoots past 1 and settles back. This is what makes something feel like it has
/// weight and springiness rather than being placed.
/// <para>
/// Returns values above 1 partway through, which is deliberate - callers must be scaling
/// or offsetting, not writing into something that clamps.
/// </para>
/// </summary>
public static float OutBack( float t, float overshoot = 1.70158f )
{
t = Clamp01( t );
var inverted = t - 1f;
return 1f + (overshoot + 1f) * inverted * inverted * inverted + overshoot * inverted * inverted;
}
/// <summary>
/// Rises to 1 and falls back to 0 across 0..1, peaking early.
/// <para>
/// For one-shot punches - a squash, a flash, a camera kick - where the value has to end
/// exactly where it started or the effect leaves a permanent offset behind. The early peak
/// is what makes it read as an impact rather than a swell.
/// </para>
/// </summary>
public static float Pulse( float t )
{
t = Clamp01( t );
// Rises over the first quarter, decays over the rest.
const float peak = 0.25f;
if ( t <= peak ) return OutCubic( t / peak );
return 1f - InQuad( (t - peak) / (1f - peak) );
}
/// <summary>
/// Progress through a duration, clamped, and 1 when the duration is not positive.
/// <para>
/// Every animation here is "elapsed over duration", and a zero duration from a mistyped
/// config value would otherwise divide by zero. Returning 1 means the animation reads as
/// already finished, which is the harmless outcome.
/// </para>
/// </summary>
public static float Progress( float elapsed, float duration ) =>
duration <= 0f ? 1f : Clamp01( elapsed / duration );
private static float Clamp01( float t ) => t < 0f ? 0f : t > 1f ? 1f : t;
}