Code/Animation/AgePhase.cs
using Sandbox;

namespace Goo.Animation;

/// <summary>Projects an entry's age into normalized slide-in / hold / fade-out values (0..1); pure value-type sibling of <see cref="Tween"/>.</summary>
public readonly record struct AgePhase
{
    public float SlideInDuration { get; init; }
    public float HoldTime        { get; init; }
    public float FadeOutDuration { get; init; }
    public Sandbox.Utility.Easing.Function SlideEasing { get; init; }
    public Sandbox.Utility.Easing.Function FadeEasing  { get; init; }

    public AgePhase(
        float slideInDuration, float holdTime, float fadeOutDuration,
        Sandbox.Utility.Easing.Function? slideEasing = null,
        Sandbox.Utility.Easing.Function? fadeEasing  = null )
    {
        SlideInDuration = slideInDuration;
        HoldTime        = holdTime;
        FadeOutDuration = fadeOutDuration;
        SlideEasing     = slideEasing ?? Easing.EaseOut;
        FadeEasing      = fadeEasing  ?? Easing.EaseIn;
    }

    /// <param name="age">Seconds since spawn (drives slide-in).</param>
    /// <param name="idleAge">Seconds since last input (drives hold + fade-out).</param>
    public AgeProjection Project( float age, float idleAge )
    {
        float slideT = SlideInDuration > 0f ? MathX.Clamp( age / SlideInDuration, 0f, 1f ) : 1f;
        float slide  = (SlideEasing ?? Easing.EaseOut)( slideT );

        float fadeT = FadeOutDuration > 0f
            ? MathX.Clamp( (idleAge - HoldTime) / FadeOutDuration, 0f, 1f )
            : (idleAge >= HoldTime ? 1f : 0f);
        float fade = (FadeEasing ?? Easing.EaseIn)( fadeT );

        return new AgeProjection( slide, fade, (1f - fade) * slide );
    }
}

/// <summary>Normalized 0..1 projection from <see cref="AgePhase.Project"/>; <c>Opacity</c> is the composite <c>(1 - Fade) * Slide</c>.</summary>
public readonly record struct AgeProjection( float Slide, float Fade, float Opacity );