Rounds/GamePause.cs

A GameObjectSystem that pauses single-player gameplay when the s&box overlay (pause menu or settings) is open by setting Scene.TimeScale to 0 and holding power-up timers, and restores time when the menu closes or someone joins.

Networking
using Sandbox;

namespace NZombies;

/// <summary>
/// A SINGLE-PLAYER GAME STANDS STILL WHILE THE s&amp;box MENU IS UP (2026-10-05). The user: *"i want to be able to pause the game by
/// pressing ESC / sbox already does this but i need the zombies to freeze too / only in singleplayer"*.
///
/// ⛔ ESC OPENS THE ENGINE'S PAUSE MENU, AND THE GAME WENT ON BEHIND IT. `Game.IsPaused`, the one flag the scene's tick asks
/// (`Scene.GameTick`), is set only by the editor's Pause button and its Stop, never by the pause menu (read out of the engine's
/// own assemblies). So zombies walked and bit, the round spawned, and a bleedout ran out while the menu was up.
///
/// ⚠️ `Scene.TimeScale = 0`, NOT `Game.IsPaused`. With `IsPaused` the scene skips its update, this system's included, so nothing
/// would be left awake to notice the menu close. At time scale 0 every update still runs, with `Time.Delta` 0 and scene time
/// standing still: zombies, nav agents, physics, animation, the spawner, and every `TimeUntil`/`TimeSince` clock (rounds,
/// bleedout, cooldowns) stop, and this goes on watching the menu. Nothing else in the project writes `TimeScale`.
///
/// ⚠️ POWER-UPS RUN ON REAL TIME (`ActivePowerups`, `RealTimeUntil`), which a time scale does not touch, so their clocks are
/// held for the pause (`ActivePowerups.Hold` / `Release`): a 30 s Insta-Kill is not spent behind the menu.
///
/// ⚠️ SINGLE PLAYER ONLY: no network, or a lobby with nobody else in it. Someone joining ends the pause on the spot.
///
/// ⚠️ ANY s&amp;box OVERLAY, not just the pause menu (`MenuOpen`): Settings, opened from the pause menu, replaces it,
/// and the game must not start again underneath.
///
/// ⚠️ A `GameObjectSystem`, the engine menu's own pattern (`GameJamSystem`): it runs every frame of every scene with nothing to
/// keep alive, so a pause cannot be left on by a component being switched off. Its tick is a named method (INSTRUCTIONS: no
/// lambdas held across a hotload).
/// </summary>
public sealed class GamePause : GameObjectSystem<GamePause>
{
	// ⚠️ NULLABLE-BACKED, INSTRUCTIONS §1: a static's value survives a hotload, its initialiser does not re-run.
	static bool? _enabled;

	/// <summary>Pause a single-player game behind the s&amp;box menu. `nz_pause_solo 0` turns it off.</summary>
	public static bool Enabled { get => _enabled ?? true; set => _enabled = value; }

	/// <summary>Is a pause holding the game still right now?</summary>
	public static bool IsPaused { get; private set; }

	bool _paused;
	float _scaleBefore = 1f;
	RealTimeSince _since;

	public GamePause( Scene scene ) : base( scene )
	{
		Listen( Stage.StartUpdate, -1000, Tick, nameof( GamePause ) );
	}

	/// <summary>Nobody else in the game: no network, or a lobby of one.</summary>
	static bool Solo => !Networking.IsActive || Connection.All.Count <= 1;

	/// <summary>
	/// Is an s&amp;box overlay up: the pause menu, or Settings opened from it?
	///
	/// ⛔ THROUGH AN INSTANCE (2026-10-05). In this engine build the overlay’s `IsOpen` is an INSTANCE property (its getter reads
	/// the internal `IModalSystem.Current`), while `ShowPauseMenu` beside it is static, so reading it off the type does not compile.
	/// The object holds nothing, so one is kept. If a later engine makes it static, this is the line to change.
	/// </summary>
	static bool MenuOpen => (_overlay ??= new Game.Overlay()).IsOpen;

	static Game.Overlay _overlay;

	void Tick()
	{
		// ⚠️ NOT IN THE EDITOR'S OWN SCENE, only in a game being played (the editor's play mode included: Shift+Esc there)
		if ( Scene is null || Scene.IsEditor ) return;

		var want = Enabled && MenuOpen && Solo;

		if ( want == _paused )
		{
			// ⚠️ A ZERO TIME SCALE WITH NO PAUSE IS A PAUSE THAT LOST ITS OWNER, a hotload in the middle of one, say. Nothing else
			// in the project sets it, so it is put back rather than leaving the game frozen with the menu closed.
			if ( !_paused && Scene.TimeScale == 0f )
			{
				Scene.TimeScale = 1f;
				ActivePowerups.Release();
				Log.Warning( "[nz-pause] the game was frozen with no pause holding it: time runs again" );
			}
			return;
		}

		if ( want ) Pause();
		else Resume();
	}

	void Pause()
	{
		_paused = true;
		IsPaused = true;
		_since = 0f;

		// ⚠️ WHAT IT WAS, WHEN IT WAS SOMETHING: a zero here would be put back at the end and freeze the game for good
		_scaleBefore = Scene.TimeScale > 0f ? Scene.TimeScale : 1f;
		Scene.TimeScale = 0f;
		ActivePowerups.Hold();

		Log.Info( "[nz-pause] paused: the menu is open in single player, so zombies, clocks and power-ups stand still" );
	}

	void Resume()
	{
		_paused = false;
		IsPaused = false;

		Scene.TimeScale = _scaleBefore > 0f ? _scaleBefore : 1f;
		ActivePowerups.Release();

		Log.Info( $"[nz-pause] resumed after {(float)_since:0.0}s{(Solo ? "" : ", because someone joined")}" );
	}

	/// <summary>⚠️ A SCENE CLOSED MID-PAUSE (quitting from the menu) STILL HANDS THE POWER-UP CLOCKS BACK: they are statics, and
	/// they would answer from the held copy in the next game.</summary>
	public override void Dispose()
	{
		if ( _paused )
		{
			_paused = false;
			IsPaused = false;
			ActivePowerups.Release();
		}

		base.Dispose();
	}

	/// <summary>`nz_pause_solo [0|1]` — pause a single-player game behind the s&amp;box menu, or not. With no argument, reports.</summary>
	[ConCmd( "nz_pause_solo" )]
	public static void Cmd( int on = -1 )
	{
		if ( on >= 0 ) Enabled = on != 0;

		Log.Info( $"[nz-pause] pausing behind the menu in single player: {(Enabled ? "ON" : "off")}"
			+ $" · paused now: {(IsPaused ? "yes" : "no")} · single player: {(Solo ? "yes" : "no")}"
			+ $" · an s&box menu open: {(MenuOpen ? "yes" : "no")}" );
	}
}