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