Feel/Ease.cs

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.

Native Interop
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;
}