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.
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&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&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&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 );
}
}