Core/TickClock.cs

A pure fixed-step clock utility used to decide when logical ticks should occur. It tracks sub-tick remainder, can be reset with an optional grace delay, and advances by a frame delta returning how many whole ticks are due up to a max.

File Access
namespace Coilgarden;

/// <summary>
/// The fixed-step clock that decides when a logical tick happens. Pure: no engine types, no
/// ambient time, so the whole of its behaviour is testable headlessly.
/// <para>
/// It was extracted from <see cref="GameSession"/> after a defect that no test could have
/// caught while it lived inside a component. The session cleared its accumulator outright once
/// the per-frame tick budget was spent - which, with a budget of one, is every single tick - so
/// the fraction of an interval that had legitimately elapsed was thrown away each time. A tick
/// could then only land on a frame boundary, making the real interval the configured one
/// rounded up to the next whole frame: measured 7.5% slow at 60Hz, and 68% slow when a frame
/// took longer than a tick. In a game played for a high score, speed must not depend on the
/// player's hardware.
/// </para>
/// <para>
/// The rule that fixes it is the distinction this class exists to hold: <b>whole ticks past the
/// budget are dropped, the sub-tick remainder is always kept.</b> Dropping backlog is
/// deliberate - a frame that overran must not be paid back as a burst of steps the player never
/// saw - but the remainder is not backlog, it is simply where the clock has got to.
/// </para>
/// </summary>
public sealed class TickClock
{
	private float timer;

	/// <summary>How far through the current tick the clock is, 0 to 1.</summary>
	public float Fraction( float interval ) =>
		interval <= 0f ? 0f : Math.Clamp( timer / interval, 0f, 1f );

	/// <summary>
	/// Puts the clock back to the start of a tick, optionally with a grace period before the
	/// first one can land.
	/// <para>
	/// The grace is stored as a negative timer, so <see cref="Fraction"/> clamps to zero and
	/// anything interpolating on it simply sits still rather than easing into motion.
	/// </para>
	/// </summary>
	public void Reset( float grace = 0f )
	{
		// Written as a positive test so a zero grace stores positive zero. Negating instead gave
		// negative zero, which survives the clamp in Fraction and surfaces as "-0.00" in the
		// state readout - harmless arithmetically and exactly the kind of thing that sends
		// somebody hunting a bug that is not there.
		timer = grace > 0f ? -grace : 0f;
	}

	/// <summary>
	/// Advances by one frame and reports how many logical ticks are now due, never more than
	/// <paramref name="maxTicks"/>.
	/// </summary>
	public int Advance( float delta, float interval, int maxTicks )
	{
		// A non-positive interval would mean infinite ticks per frame. Treated as "no clock"
		// rather than throwing, because it can only arrive from a config value or a slider.
		if ( interval <= 0f ) return 0;
		if ( maxTicks < 1 ) return 0;

		timer += delta;

		if ( timer < interval ) return 0;

		var due = (int)(timer / interval);

		if ( due <= maxTicks )
		{
			timer -= due * interval;
			return due;
		}

		// Past the budget: the extra whole ticks are dropped, and the remainder is what is left
		// over after them - not zero.
		timer %= interval;

		return maxTicks;
	}
}