Game/GameplayTimeScale.cs

GameplayTimeScale manages slow-motion time effects for gameplay. It tracks multiple overlapping effects with ease-in, hold, and ease-out phases, computes per-effect current scale from unscaled delta time, and exposes the most restrictive (minimum) scale. It returns Handle objects to release or cancel effects.

using System.Collections.Generic;

namespace BlockParty;

public enum GameplayTimeEasing
{
	Linear,
	SmoothStep,
	SineInOut,
}

/// <summary>
/// Presentation clock for gameplay slow motion. Effects advance only from unscaled rendered-frame
/// time; the resulting scale controls how quickly fixed simulation steps are scheduled, never the
/// fixed <see cref="Arena.STEP"/> passed into gameplay.
/// </summary>
public sealed class GameplayTimeScale
{
	public sealed class Handle
	{
		internal Effect Effect { get; set; }
		public bool IsActive => Effect is not null;
	}

	internal enum Phase
	{
		EaseIn,
		Hold,
		EaseOut,
	}

	internal sealed class Effect
	{
		public Handle Handle;
		public float TargetScale;
		public float EaseIn;
		public float Hold;
		public float EaseOut;
		public GameplayTimeEasing Easing;
		public Phase Phase;
		public float Elapsed;
		public float ReleaseStartScale = 1f;
		public float CurrentScale = 1f;
	}

	private readonly List<Effect> _effects = new();

	/// <summary>The most restrictive active effect, or normal speed when none are active.</summary>
	public float Scale { get; private set; } = 1f;

	/// <summary>Create an effect that remains held until <see cref="Release"/> is called.</summary>
	public Handle Acquire( float targetScale, float easeIn = 0f, float easeOut = 0f,
		GameplayTimeEasing easing = GameplayTimeEasing.SmoothStep )
		=> AddEffect( targetScale, easeIn, float.PositiveInfinity, easeOut, easing );

	/// <summary>Create an effect that automatically eases in, holds, and eases back to normal speed.</summary>
	/// <param name="targetScale">Gameplay rate during the hold, clamped to 0.01–1. A value of 0.2 runs
	/// gameplay at one fifth speed.</param>
	/// <param name="hold">Time spent at <paramref name="targetScale"/>, in unscaled real seconds.</param>
	/// <param name="easeIn">Unscaled seconds used to move from normal speed to
	/// <paramref name="targetScale"/>.</param>
	/// <param name="easeOut">Unscaled seconds used to return from <paramref name="targetScale"/> to
	/// normal speed after the hold.</param>
	/// <param name="easing">Curve applied independently to the ease-in and ease-out transitions.</param>
	/// <returns>A handle that can be released early or cancelled immediately.</returns>
	public Handle OneShot( float targetScale, float hold, float easeIn = 0f, float easeOut = 0f,
		GameplayTimeEasing easing = GameplayTimeEasing.SmoothStep )
		=> AddEffect( targetScale, easeIn, Math.Max( 0f, hold ), easeOut, easing );

	/// <summary>Ease a held or one-shot effect back to normal speed.</summary>
	public void Release( Handle handle )
	{
		Effect effect = handle?.Effect;
		if ( effect is null || effect.Phase == Phase.EaseOut ) return;

		effect.ReleaseStartScale = effect.CurrentScale;
		effect.Elapsed = 0f;
		effect.Phase = Phase.EaseOut;
		if ( effect.EaseOut <= 0f )
			Remove( effect );
		RecalculateScale();
	}

	/// <summary>Remove an effect immediately without running its ease-out.</summary>
	public void Cancel( Handle handle )
	{
		Effect effect = handle?.Effect;
		if ( effect is null ) return;

		Remove( effect );
		RecalculateScale();
	}

	/// <summary>Advance all envelopes from unscaled rendered-frame time.</summary>
	public void Tick( float unscaledDelta )
	{
		if ( _effects.Count == 0 ) return;
		float delta = Math.Max( 0f, unscaledDelta );

		for ( int i = _effects.Count - 1; i >= 0; i-- )
		{
			Effect effect = _effects[i];
			float remaining = delta;
			bool removed = false;
			while ( remaining > 0f && !removed )
			{
				switch ( effect.Phase )
				{
					case Phase.EaseIn:
						remaining = AdvancePhase( effect, remaining, effect.EaseIn );
						if ( effect.Elapsed < effect.EaseIn )
						{
							float amount = Ease( effect.Elapsed / effect.EaseIn, effect.Easing );
							effect.CurrentScale = 1f + (effect.TargetScale - 1f) * amount;
							remaining = 0f;
						}
						else
						{
							effect.CurrentScale = effect.TargetScale;
							effect.Elapsed = 0f;
							effect.Phase = Phase.Hold;
						}
						break;

					case Phase.Hold:
						effect.CurrentScale = effect.TargetScale;
						if ( float.IsPositiveInfinity( effect.Hold ) )
						{
							remaining = 0f;
							break;
						}

						remaining = AdvancePhase( effect, remaining, effect.Hold );
						if ( effect.Elapsed < effect.Hold )
						{
							remaining = 0f;
						}
						else
						{
							effect.ReleaseStartScale = effect.CurrentScale;
							effect.Elapsed = 0f;
							effect.Phase = Phase.EaseOut;
						}
						break;

					case Phase.EaseOut:
						remaining = AdvancePhase( effect, remaining, effect.EaseOut );
						if ( effect.Elapsed >= effect.EaseOut )
						{
							RemoveAt( i );
							removed = true;
						}
						else
						{
							float releaseAmount = Ease( effect.Elapsed / effect.EaseOut, effect.Easing );
							effect.CurrentScale = effect.ReleaseStartScale + (1f - effect.ReleaseStartScale) * releaseAmount;
							remaining = 0f;
						}
						break;
				}
			}
		}

		RecalculateScale();
	}

	private static float AdvancePhase( Effect effect, float delta, float duration )
	{
		if ( duration <= 0f )
		{
			effect.Elapsed = duration;
			return delta;
		}

		float available = duration - effect.Elapsed;
		float consumed = Math.Min( delta, available );
		effect.Elapsed += consumed;
		return delta - consumed;
	}

	/// <summary>Immediately clear every effect, used when changing or rebuilding stages.</summary>
	public void Reset()
	{
		foreach ( Effect effect in _effects )
		{
			effect.Handle.Effect = null;
		}
		_effects.Clear();
		Scale = 1f;
	}

	private Handle AddEffect( float targetScale, float easeIn, float hold, float easeOut, GameplayTimeEasing easing )
	{
		var handle = new Handle();
		var effect = new Effect
		{
			Handle = handle,
			TargetScale = Math.Clamp( targetScale, 0.01f, 1f ),
			EaseIn = Math.Max( 0f, easeIn ),
			Hold = hold,
			EaseOut = Math.Max( 0f, easeOut ),
			Easing = easing,
		};
		handle.Effect = effect;
		_effects.Add( effect );
		if ( effect.EaseIn <= 0f )
		{
			effect.CurrentScale = effect.TargetScale;
			effect.Phase = Phase.Hold;
		}
		RecalculateScale();
		return handle;
	}

	private void Remove( Effect effect )
	{
		int index = _effects.IndexOf( effect );
		if ( index >= 0 ) RemoveAt( index );
	}

	private void RemoveAt( int index )
	{
		Effect effect = _effects[index];
		effect.Handle.Effect = null;
		_effects.RemoveAt( index );
	}

	private void RecalculateScale()
	{
		float scale = 1f;
		foreach ( Effect effect in _effects )
			scale = Math.Min( scale, effect.CurrentScale );
		Scale = scale;
	}

	private static float Ease( float amount, GameplayTimeEasing easing )
	{
		amount = Math.Clamp( amount, 0f, 1f );
		return easing switch
		{
			GameplayTimeEasing.Linear => amount,
			GameplayTimeEasing.SineInOut => 0.5f - MathF.Cos( amount * MathF.PI ) * 0.5f,
			_ => amount * amount * (3f - 2f * amount),
		};
	}
}