Game/GameTime.cs
namespace Monolith;
/// <summary>
/// A clock that stops when the game is paused, and the pause state itself.
///
/// **Why this exists rather than a bare bool.** Gating every `OnUpdate` behind a flag stops
/// things MOVING, but it does not stop time PASSING, and most of this game is written against
/// absolute deadlines: `shieldEndsAt = Time.Now + 40f`, `runStartedAt = Time.Now`,
/// `clearedBannerUntil = Time.Now + 3.5f`. Read the tutorial for two minutes with a naive pause
/// and every one of those deadlines is in the past when you resume. The shield expires
/// instantly, the banner never shows, and the run timer that feeds the leaderboard has two
/// minutes of reading time baked into it.
///
/// So the pause has to move the clock, not just the components. <see cref="Now"/> is real time
/// minus every second ever spent paused, which means a deadline written against it keeps
/// exactly the remaining time it had.
///
/// **What deliberately still uses the real clock.** Three things must keep running while
/// paused, and they are the reason this is opt-in per call site rather than a global override:
/// the frame-hitch watchdog and the mesher backlog report (both diagnostics measuring wall time,
/// which is the whole point of them), and the controller navigation repeat in the HUD, which has
/// to work while the pause menu is open. Cosmetic `MathF.Sin( Time.Now )` pulses also stay on
/// the real clock; they are inside gated updates anyway, so they freeze with everything else.
///
/// **Multiplayer.** Pausing is refused outright when anyone else is connected. The monolith is
/// host-authoritative and shared, so one player opening a menu cannot be allowed to stop the
/// world for everyone, and stopping it only locally would desync them from a world that kept
/// going. Solo, which is how this is played, gets a real pause. Otherwise the menu opens over a
/// live game, the way it does in any online game.
/// </summary>
public static class GameTime
{
/// <summary>Total real seconds spent paused since launch. The offset between the clocks.</summary>
private static float pausedTotal;
/// <summary>Set for the frames on which the world is frozen.</summary>
public static bool Paused { get; private set; }
/// <summary>
/// True when the pause menu is open but the world is NOT frozen, which is the multiplayer
/// case. The menu still needs to know it is showing so it can draw and take input.
/// </summary>
public static bool MenuOpenLive { get; private set; }
/// <summary>
/// Game time, in seconds. Advances with real time, except while paused. Use this for anything
/// that measures gameplay: run timers, shield deadlines, cooldowns.
/// </summary>
public static float Now => Time.Now - pausedTotal;
/// <summary>
/// Frame delta, forced to zero while paused. Mostly a safety net: gated updates never read
/// this while paused, but anything that slips through integrates nothing rather than jumping.
/// </summary>
public static float Delta => Paused ? 0f : Time.Delta;
/// <summary>
/// Requests a pause. Returns whether the world actually froze, which is false in multiplayer.
/// </summary>
public static bool SetPaused( bool wanted )
{
if ( !wanted )
{
Paused = false;
MenuOpenLive = false;
return false;
}
if ( CanFreeze() )
{
Paused = true;
MenuOpenLive = false;
return true;
}
// Someone else is playing. Show the menu, leave the world running.
Paused = false;
MenuOpenLive = true;
return false;
}
/// <summary>
/// Solo means "nobody else is connected". Wrapped because <c>Connection.All</c> throws if the
/// networking system is not up yet, which is true for the first frames of a scene, and the
/// honest answer in that window is that we are alone.
/// </summary>
private static bool CanFreeze()
{
try { return Connection.All.Count <= 1; }
catch ( Exception ) { return true; }
}
/// <summary>
/// Accumulates paused time. Must be called once per frame from something that is NOT itself
/// gated by the pause, or the clock would never catch up and the offset would never grow.
/// </summary>
public static void Advance()
{
if ( Paused )
pausedTotal += Time.Delta;
}
/// <summary>
/// Drops the pause without crediting the time. Used when the scene is torn down and restarted,
/// where carrying an offset forward would be meaningless.
/// </summary>
public static void Reset()
{
Paused = false;
MenuOpenLive = false;
}
}
/// <summary>
/// <see cref="TimeSince"/>, but on the pausable clock.
///
/// The engine struct reads <c>Time.Now</c> internally and there is no way to redirect it, so
/// pausing means having our own. Deliberately minimal: this codebase only ever assigns a float
/// to these and reads them back as a float, so an implicit conversion each way is the entire
/// surface that is needed. `Relative`, `Absolute`, `Fraction` and `Passed` are not reproduced
/// because nothing uses them, and guessing at semantics we do not need is how bugs get in.
/// </summary>
/// <remarks>
/// **There are deliberately no comparison operators here, and that is load bearing.** Every use
/// in this codebase is of the form <c>timeSinceFired > 0.5f</c>. With both implicit
/// conversions present, defining <c>operator >(GameTimeSince, GameTimeSince)</c> as well would
/// give that expression two equally valid readings: convert the left side down to float, or
/// convert the literal up to a struct. That is an ambiguity error at every call site. Leaving the
/// operators out means the float conversion is the only path and the comparison just works. The
/// engine's own <see cref="TimeSince"/> resolves it the same way.
/// </remarks>
public struct GameTimeSince
{
private float absolute;
/// <summary>Seconds elapsed on the game clock since this was last assigned.</summary>
public float Relative => GameTime.Now - absolute;
public static implicit operator float( GameTimeSince ts ) => ts.Relative;
public static implicit operator GameTimeSince( float seconds )
=> new() { absolute = GameTime.Now - seconds };
public override string ToString() => Relative.ToString();
}