Core/GameSession.cs

Main game session component for the Snake game. It coordinates run lifecycle, input handling, ticking, pause/focus/cursor rules, settings and high score persistence, and exposes state for the view layer.

File Access
namespace Coilgarden;

/// <summary>
/// Drives one run: owns the logical clock, the app state machine, and the path from input
/// intents into the simulation.
/// <para>
/// It coordinates and does not implement. The rules live in <see cref="SnakeGame"/>, the
/// look lives in the view components, and this class decides only <em>when</em> a tick
/// happens and which state the app is in. Keeping it to that is what stops it becoming the
/// class that knows about everything.
/// </para>
/// </summary>
public sealed class GameSession : Component
{
	/// <summary>
	/// The session the debug commands and views talk to.
	/// <para>
	/// Assigned from <see cref="OnEnabled"/> rather than <see cref="OnStart"/>, because
	/// <c>OnStart</c> runs once per lifetime: a component disabled and re-enabled would
	/// otherwise leave this null forever, and s&amp;box carries statics across a hot reload
	/// so a stale instance from a previous build outlives its scene.
	/// </para>
	/// </summary>
	public static GameSession Current { get; private set; }

	// ------------------------------------------------------------------ authored rules
	// Defaults come from GameConfig; these exist so the shape of a run can be tried out
	// live in the editor without a recompile. They are read once, when a run is created.

	[Property, Group( "Rules" ), Range( GameConfig.MinGridSize, GameConfig.MaxGridSize )]
	public int GridWidth { get; set; } = GameConfig.GridWidth;

	[Property, Group( "Rules" ), Range( GameConfig.MinGridSize, GameConfig.MaxGridSize )]
	public int GridHeight { get; set; } = GameConfig.GridHeight;

	[Property, Group( "Rules" ), Range( 1, 12 )]
	public int StartLength { get; set; } = GameConfig.StartLength;

	[Property, Group( "Rules" ), Range( 1, GameConfig.MaxBufferedTurnsLimit )]
	public int MaxBufferedTurns { get; set; } = GameConfig.MaxBufferedTurns;

	[Property, Group( "Rules" ), Range( 0, 100 )]
	public int ApplePoints { get; set; } = GameConfig.ApplePoints;

	// ------------------------------------------------------------------ pacing and flow

	/// <summary>Seconds per logical tick.</summary>
	[Property, Group( "Pacing" ), Range( 0.04f, 0.5f )]
	public float TickInterval { get; set; } = GameConfig.BaseTickInterval;

	/// <summary>Skip the "press a key" state and start moving as soon as the scene loads.</summary>
	[Property, Group( "Pacing" )] public bool AutoStart { get; set; }

	[Property, Group( "Pacing" )] public bool AutoPauseOnFocusLoss { get; set; } = GameConfig.AutoPauseOnFocusLoss;

	// ------------------------------------------------------------------ live state

	public GameState State { get; private set; } = GameState.MainMenu;

	/// <summary>
	/// The run. Never null once the component has started.
	/// <para>
	/// Named <c>Run</c> rather than <c>Game</c> deliberately: s&amp;box has its own static
	/// <c>Game</c>, and a property of that name on a component shadows it inside every
	/// method here - which turns <c>Game.Random</c> into a compile error a long way from
	/// its cause.
	/// </para>
	/// </summary>
	public SnakeGame Run { get; private set; }

	/// <summary>The record across runs. Loaded once, saved when a run ends.</summary>
	public HighScoreData Records { get; private set; }

	/// <summary>
	/// What the player has chosen: group volumes today, more later. Loaded once and written
	/// through immediately on every change, so a crash never loses a preference that was
	/// already set.
	/// </summary>
	public GameSettings Settings { get; private set; }

	/// <summary>True when the run that just ended beat the stored best. Cleared on restart.</summary>
	public bool IsNewBest { get; private set; }

	/// <summary>
	/// Set by the UI while a screen that owns the keyboard is up - today that is settings.
	/// <para>
	/// Without it, gameplay keys still reach the session from behind an overlay: pressing
	/// restart or a direction on the settings screen started a run underneath it, leaving the
	/// player looking at settings while the snake moved unseen. The flag lives here rather
	/// than on the panel because nothing under <c>Code/</c> may reference a Razor type - the
	/// headless build does not compile Razor and the reference would break it.
	/// </para>
	/// </summary>
	public bool ModalOpen { get; set; }

	/// <summary>How far through the current tick we are, 0 to 1. The view interpolates on it.</summary>
	public float TickFraction => clock.Fraction( TickInterval );

	/// <summary>
	/// Seconds since the state last changed, in real time. The UI uses it to hold a screen back
	/// until whatever the world is doing has had time to read.
	/// </summary>
	public float StateAge { get; private set; }

	/// <summary>
	/// The frame delta the <em>presentation</em> layer should advance by: zero while paused.
	/// <para>
	/// A pause has to stop the tray, not merely the snake. Without this the apple carried on
	/// bobbing and spinning and sparkles carried on flying and falling behind a paused screen,
	/// which reads as the game having ignored the pause rather than honoured it.
	/// </para>
	/// </summary>
	public float FeelDelta => State == GameState.Paused ? 0f : Time.Delta;

	/// <summary>
	/// A clock for the feel layer's oscillators - breathing, bobbing, spinning. It stops while
	/// paused, which <see cref="Time.Now"/> cannot do.
	/// </summary>
	public float FeelTime { get; private set; }

	/// <summary>The most recent tick's outcome, for anything that reacts to events.</summary>
	public StepResult LastStep { get; private set; }

	private readonly Direction[] intents = new Direction[GameInput.MaxIntentsPerFrame];

	private readonly TickClock clock = new();

	// ------------------------------------------------------------------ lifecycle

	protected override void OnEnabled()
	{
		Current = this;

		Records ??= HighScoreStore.Load();
		Settings ??= SettingsStore.Load();

		// Created here rather than in OnStart so that a re-enabled component is always in a
		// playable state; OnStart would not run a second time.
		Run ??= CreateRun();
	}

	protected override void OnStart()
	{
		if ( AutoStart ) StartRun();
	}

	protected override void OnDisabled()
	{
		if ( Current == this ) Current = null;
	}

	protected override void OnUpdate()
	{
		if ( Run is null ) return;

		StateAge += Time.Delta;
		FeelTime += FeelDelta;

		ApplyCursorRules();
		ApplyFocusRules();
		ReadInput();

		if ( State != GameState.Playing ) return;

		AdvanceClock();
	}

	/// <summary>
	/// The one place <see cref="State"/> is written, so <see cref="StateAge"/> cannot be left
	/// stale by a transition that forgot to reset it.
	/// </summary>
	private void EnterState( GameState state )
	{
		if ( State == state ) return;

		State = state;
		StateAge = 0f;
	}

	// ------------------------------------------------------------------ state changes

	/// <summary>The rules a new run will be created with, as authored plus clamped.</summary>
	public GameRules AuthoredRules => new GameRules(
		GridWidth, GridHeight, StartLength, GameConfig.StartDirection, MaxBufferedTurns, ApplePoints ).Clamped();

	/// <summary>Begins a run from <see cref="GameState.MainMenu"/>.</summary>
	public void StartRun()
	{
		if ( State != GameState.MainMenu ) return;

		// The still beat between asking to play and the snake actually moving.
		clock.Reset( GameConfig.StartGrace );

		EnterState( GameState.Playing );
	}

	/// <summary>
	/// Throws the current run away and begins a new one immediately. Reachable from
	/// anywhere, including mid-run and while paused, because the one thing a player wants
	/// after a bad death is to already be playing again.
	/// </summary>
	public void Restart() => ResetRun( GameState.Playing );

	/// <summary>
	/// Throws the current run away and returns to the title screen. This is what the "menu"
	/// button on the pause and game-over screens does - reachable mid-run for the same
	/// reason restarting is: nobody wants to wait for a state transition they already asked
	/// for.
	/// </summary>
	public void ReturnToMenu() => ResetRun( GameState.MainMenu );

	/// <summary>
	/// The shared body of <see cref="Restart"/> and <see cref="ReturnToMenu"/> - both throw
	/// the run away and rebuild, and differ only in which state they land in. One authority
	/// for that means the two can never drift into resetting a different set of fields.
	/// <para>
	/// The rules are re-read here, so a value changed in the editor takes effect on the next
	/// reset rather than needing play mode stopped and started.
	/// </para>
	/// </summary>
	private void ResetRun( GameState target )
	{
		var wanted = AuthoredRules;

		// Only rebuild when the shape of the run actually changed. Reusing the existing
		// SnakeGame keeps a reset allocation-free, which is what lets it be instant.
		if ( Run is null || Run.Rules != wanted )
		{
			Run = CreateRun();
		}
		else
		{
			Run.Restart();
		}

		// A restart gets the same still beat as a first start, so the snake never begins moving
		// before the player's eyes have got back to the board.
		clock.Reset( target == GameState.Playing ? GameConfig.StartGrace : 0f );

		LastStep = default;
		IsNewBest = false;

		EnterState( target );

		// Reset explicitly as well: a reset back into the state it was already in - restarting
		// from a run in progress - is not a state *change*, but it is emphatically a fresh start
		// as far as anything timing off StateAge is concerned.
		StateAge = 0f;
	}

	/// <summary>
	/// Nudges a volume group by <paramref name="delta"/> and writes the result through
	/// immediately. Public so the settings panel can call it straight off a slider or a
	/// stepper button; the actual clamp lives on <see cref="GameSettings"/> so a value
	/// pushed past its ceiling here still comes out legal.
	/// </summary>
	public void AdjustMasterVolume( float delta ) => UpdateSettings( s => s.MasterVolume += delta );

	public void AdjustSfxVolume( float delta ) => UpdateSettings( s => s.SfxVolume += delta );

	public void AdjustMusicVolume( float delta ) => UpdateSettings( s => s.MusicVolume += delta );

	public void AdjustAmbienceVolume( float delta ) => UpdateSettings( s => s.AmbienceVolume += delta );

	/// <summary>
	/// Puts every setting back to the designed balance.
	/// <para>
	/// Worth a control of its own rather than expecting the player to step four sliders back by
	/// hand. The designed mix is a deliberate thing - the beds sit well under the effects - and
	/// somebody who has pushed the music up to hear it should be able to get that back in one
	/// action rather than guessing at the numbers.
	/// </para>
	/// </summary>
	public void ResetSettings()
	{
		Settings = new GameSettings();
		SettingsStore.Save( Settings );
	}

	private void UpdateSettings( Action<GameSettings> mutate )
	{
		Settings ??= SettingsStore.Load();

		mutate( Settings );
		Settings.Clamp();

		SettingsStore.Save( Settings );
	}

	public void SetPaused( bool paused )
	{
		if ( paused && State == GameState.Playing )
		{
			EnterState( GameState.Paused );
			return;
		}

		if ( !paused && State == GameState.Paused )
		{
			EnterState( GameState.Playing );
		}
	}

	// ------------------------------------------------------------------ internals

	private SnakeGame CreateRun()
	{
		// A fixed seed would make every run open identically, which is the one thing a score
		// chase must not do. The seed comes from s&box's own RNG rather than from
		// System.Environment.TickCount: the engine sandboxes game code against an API
		// whitelist that plain Roslyn knows nothing about, and TickCount is not on it.
		return new SnakeGame( AuthoredRules, Game.Random.Int( 0, SeedRange ) );
	}

	private const int SeedRange = 1_000_000;

	/// <summary>
	/// Shows the cursor whenever there is something to click, and hides it during play.
	/// <para>
	/// <b>This is what made the buttons dead.</b> s&amp;box's default cursor mode is
	/// <see cref="MouseVisibility.Auto"/>, which reveals the cursor only when it finds panels
	/// declaring <c>pointer-events: auto</c> - and this project's stylesheet said
	/// <c>pointer-events: all</c>, which is a different keyword and matches nothing. The result
	/// was a menu that rendered perfectly and could not be clicked at all, because there was no
	/// cursor to click it with. The stylesheet now uses the keyword the engine looks for, and
	/// the mode is set outright here as well so the interface never depends on that heuristic
	/// again.
	/// </para>
	/// <para>
	/// Hidden during play on purpose: nothing in a run is clickable, and a cursor parked over
	/// the tray is one more thing on a board whose whole job is to stay readable.
	/// </para>
	/// </summary>
	private void ApplyCursorRules()
	{
		var wantsCursor = ModalOpen || State != GameState.Playing;

		Mouse.Visibility = wantsCursor ? MouseVisibility.Visible : MouseVisibility.Auto;
	}

	/// <summary>Whether the window was focused last frame. The auto-pause triggers on the change.</summary>
	private bool seenFocus;

	/// <summary>
	/// Pauses a running game when the player alt-tabs away from it.
	/// <para>
	/// <b>Edge-triggered, and that is the whole point.</b> This used to pause on every frame the
	/// window was not focused, which meant an explicit resume was undone by the very next frame
	/// and the game could not be played at all: press resume, and it is back on the pause screen
	/// before a single tick has run. It only takes one environment where
	/// <see cref="Application.IsFocused"/> reads false while the player is plainly playing - the
	/// editor's play mode is one - for a level-triggered rule to make the game unusable.
	/// </para>
	/// <para>
	/// Reacting to the <em>transition</em> instead is both correct and fails safe. A genuine
	/// alt-tab still pauses the run, an explicit resume always sticks, and if focus is never
	/// reported at all the feature quietly does nothing rather than taking the game down with it.
	/// </para>
	/// <para>
	/// Resuming is <em>not</em> automatic. Coming back to a window and finding the snake already
	/// moving is how a run gets lost to a stray alt-tab; the player asks for the game to resume
	/// when they are ready.
	/// </para>
	/// </summary>
	private void ApplyFocusRules()
	{
		// The engine's own menu counts as looking away: the game is behind it either way.
		var focused = Application.IsFocused && !Game.IsMainMenuVisible;

		var lostFocus = seenFocus && !focused;
		seenFocus = focused;

		if ( !AutoPauseOnFocusLoss ) return;
		if ( !lostFocus ) return;
		if ( State != GameState.Playing ) return;

		// A run that has only just started is not one the player has alt-tabbed away from; it is
		// far likelier to be a focus flicker on the same frame they asked to play.
		if ( StateAge < GameConfig.StartGrace ) return;

		SetPaused( true );
	}

	private void ReadInput()
	{
		// A screen that owns the keyboard swallows everything, restart included. The clock is
		// already stopped in both states settings can be opened from, so nothing is lost.
		if ( ModalOpen ) return;

		// Restart is checked first and from every state, so it always means the same thing.
		if ( GameInput.RestartPressed() )
		{
			Restart();
			return;
		}

		switch ( State )
		{
			case GameState.MainMenu:
				if ( GameInput.ConfirmPressed() )
				{
					StartRun();
					return;
				}

				// Any direction key starts the run *and* is taken as the first turn, so the
				// player's first press is never swallowed by a prompt they did not read.
				if ( GameInput.AnyDirectionPressed() )
				{
					StartRun();
					ApplyTurns();
				}

				return;

			case GameState.Playing:
				if ( GameInput.PausePressed() )
				{
					SetPaused( true );
					return;
				}

				ApplyTurns();
				return;

			case GameState.Paused:
				// Confirm resumes as well as Pause does, because a paused player reaches for
				// whichever of the two they remember.
				if ( GameInput.PausePressed() || GameInput.ConfirmPressed() ) SetPaused( false );
				return;

			case GameState.GameOver:
				if ( GameInput.ConfirmPressed() || GameInput.AnyDirectionPressed() ) Restart();
				return;
		}
	}

	private void ApplyTurns()
	{
		var count = GameInput.ReadTurns( intents );

		for ( var i = 0; i < count; i++ )
		{
			Run.TryTurn( intents[i] );
		}
	}

	/// <summary>
	/// Runs whatever ticks the clock says are due. The accumulator rule - drop whole backlog
	/// ticks, always keep the remainder - lives in <see cref="TickClock"/>, where it is covered
	/// by the headless suite.
	/// </summary>
	private void AdvanceClock()
	{
		var due = clock.Advance( Time.Delta, TickInterval, GameConfig.MaxTicksPerFrame );

		for ( var i = 0; i < due; i++ )
		{
			Tick();

			// A run that ended mid-frame must not have its remaining ticks played out.
			if ( State != GameState.Playing ) return;
		}
	}

	private void Tick()
	{
		LastStep = Run.Step();

		if ( !LastStep.IsTerminal ) return;

		EnterState( GameState.GameOver );

		// Settled at the start of a tick, so the death frame is drawn at the cell the snake
		// actually died on rather than part-way into a step it never took.
		clock.Reset();

		EndRun();
	}

	/// <summary>
	/// Files the finished run against the stored record. Called from exactly one place, so a
	/// run cannot be counted twice or missed.
	/// </summary>
	private void EndRun()
	{
		IsNewBest = Records.Submit( Run.Score, Run.Snake.Length, Run.ApplesEaten );

		HighScoreStore.Save( Records );
	}
}