🔍 s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (75 total matches found)
fpkreastudios.coilgarden / styles/form/_switch.scss
Game game
$primary: red !default;
$primary-alt: white !default;

$switch-padding: 6px !default;

.checkbox.switch
{
	cursor: pointer;

	> .checkmark
	{
		font-size: 22px;
		border: 0px solid $primary;
		border-radius: 100px;
		text-align: center;
		justify-content: center;
		align-items: center;
		color: $primary-alt;
		padding: $switch-padding;
		padding-right: 32px;
		padding-left: $switch-padding;
		transition: all 0.3s ease;
		background-color: rgba( $primary, 0.1 );

		> .handle
		{
			background-color: $primary-alt;
			width: 20px;
			height: 20px;
			border-radius: 100px;
			box-shadow: 2px 2px 12px black;
		}
	}

	&.checked
	{
		> .checkmark
		{
			background-color: $primary;
			padding-left: 32px;
			padding-right: $switch-padding;
		}
	}

	&:active
	{
		transform: scale( 0.9 );
		transform-origin: 20px 50%;
	}
}
fpkreastudios.coilgarden / ui/_theme.scss
Game game
// The shared visual language for Coilgarden's UI.
//
// The UI is not styled independently of the game. Every colour below is the same colour as
// something in the world (see Code/Core/Palette.cs) - the panels are the tray's terracotta,
// the text is the sand, the accent is the apple. That is what makes the interface feel like
// it belongs to the game rather than sitting on top of it.
//
// Three rules drive every value here.
//
// 1. Rank by consequence. Score is the biggest thing on screen because score is the point.
//    Best is a third of its size. Labels are tiny - a player reads the word "SCORE" once and
//    never again, so it exists only to explain the number the first time.
//
// 2. Nothing over the playfield during play. The arena has to stay readable, so the HUD sits
//    above the tray and only a finished or paused run is allowed to cover it.
//
// 3. One rhythm. Spacing is multiples of $u, radii come from one pair of values, and there is
//    a single easing curve. Consistency is most of what "polished" means.

// ---------------------------------------------------------------- palette
// Mirrors Code/Core/Palette.cs. Change one, change the other.

$backdrop:      #1b171d;
$table:         #2a242b;
$tray:          #965f47;
$tray-light:    #b47b5c;
$tray-dark:     #69402e;

$sand:          #c7c4a9;
$sand-dim:      #bdb99d;

$snake:         #2e6f43;
$snake-light:   #4a9252;
$apple:         #e03d31;
$leaf:          #6fa145;

// Text sits on dark panels, so it is the sand tone rather than pure white - white on warm
// brown reads as clinical, and nothing in this game should.
$text:          #f2ede2;
$text-soft:     rgba(242, 237, 226, 0.66);
$text-faint:    rgba(242, 237, 226, 0.34);

// Panels are the backdrop hue, near-opaque so a card reads as the room dimming without the
// playfield showing through what sits on it. 0.90 was not enough: the sand is the brightest
// thing in the game, and the checker was visible straight through the buttons.
$panel:         rgba(27, 23, 29, 0.985);
$hairline:      rgba(242, 237, 226, 0.10);

// Controls sit *on* a card, so they tint it rather than being transparent themselves - the
// card behind them is already opaque, which is what keeps the world out of them.
$plate:         rgba(56, 50, 58, 1);
$plate-hover:   rgba(74, 66, 76, 1);

// ---------------------------------------------------------------- rhythm

// The spacing unit, and the one rule about it: **never write `$u * n`**. s&box's SCSS does not
// evaluate arithmetic in lengths and drops the whole declaration - with a warning for `gap` and
// in complete silence for margins and padding. The entire spacing scale of this interface was
// resolving to nothing for exactly that reason, which is why every space below is written as a
// literal multiple of 4. The unit is kept as documentation of the rhythm, not as a calculator.
$u: 4px;

$radius: 14px;
$radius-lg: 26px;

// One curve for everything. A settle with a touch of overshoot reads as soft rather than
// mechanical, which is the whole target for this game's feel.
$ease: cubic-bezier(0.22, 1, 0.36, 1);

// ---------------------------------------------------------------- type

// Poppins is a geometric, round-shouldered face that ships with s&box. It is doing real work
// here: the roundness is the same idea as the sphere-based snake.
@mixin face {
	font-family: "Poppins", "Roboto", sans-serif;
	font-weight: 500;
}

@mixin label {
	@include face;
	font-size: 13px;
	font-weight: 600;
	letter-spacing: 3px;
	text-transform: uppercase;
	color: $text-faint;
}

@mixin numeral {
	@include face;
	font-weight: 700;

	// Numbers are tabular so a score counting up does not shift its own layout. A number
	// that jitters while it changes is the most avoidable kind of unpolished.
	font-family: "Poppins", "Roboto", sans-serif;
}

@mixin card {
	background-color: $panel;
	border-radius: $radius-lg;
	border: 1px solid $hairline;
	flex-direction: column;
	align-items: center;

	// Each side written out rather than a `padding: A B` shorthand. s&box's SCSS applies the
	// shorthand only partly - the card came out with a correct top and no bottom, so the last
	// button on every screen sat flush against the rounded corner.
	padding-top: 48px;
	padding-bottom: 48px;
	padding-left: 56px;
	padding-right: 56px;

	// Without this the centring scrim compresses the card to less than its content.
	flex-shrink: 0;
}

// ---------------------------------------------------------------- screen chrome
// Shared by every full-screen state - the main menu, pause, game over and settings all sit
// in a scrim over the arena and rise in on the same card. One treatment for "a screen is up"
// is what makes the four of them read as one system rather than four separate designs.

@keyframes scrim-in {
	from { opacity: 0; }
	to { opacity: 1; }
}

@keyframes card-in {
	0% {
		opacity: 0;
		transform: translateY(18px) scale(0.94);
	}

	100% {
		opacity: 1;
		transform: translateY(0px) scale(1);
	}
}

.scrim {
	position: absolute;
	width: 100%;
	height: 100%;
	align-items: center;
	justify-content: center;
	pointer-events: all;

	// Warm rather than neutral black, so a menu or a pause reads as the lights dimming in
	// the same room rather than a grey sheet dropped over the game.
	background-color: rgba(27, 23, 29, 0.62);

	// Fades up rather than cutting in. A hard cut to a dimmed screen reads as a glitch;
	// over a sixth of a second it reads as the room dimming.
	animation: scrim-in 0.18s $ease both;
}

.card {
	@include card;
	min-width: 460px;
	animation: card-in 0.26s $ease both;

	// A record-beating run gets an apple-red edge. Restating the good news in a second
	// channel, so it lands even if the words are skimmed.
	&.record {
		border: 2px solid $apple;
	}

	.wordmark {
		@include face;
		font-size: 62px;
		font-weight: 700;
		letter-spacing: -1px;
		color: $snake-light;
	}

	.tagline {
		font-size: 19px;
		color: $text-soft;
		margin-top: 8px;
	}

	.heading {
		@include face;
		font-size: 46px;
		font-weight: 700;
		letter-spacing: -0.5px;
		color: $text;
	}

	.cause {
		font-size: 19px;
		color: $text-soft;
		margin-top: 8px;
		text-align: center;
	}

	.tally {
		flex-direction: row;
		margin-top: 28px;
		gap: 40px;

		.tally-item {
			flex-direction: column;
			align-items: center;

			.tally-label { @include label; }

			.tally-value {
				@include numeral;
				font-size: 32px;
				color: $text;
			}
		}
	}

	.prompt {
		@include label;
		font-size: 14px;
		letter-spacing: 2px;
		color: $text-faint;
		margin-top: 24px;
	}
}

// ---------------------------------------------------------------- buttons
// One control, three intents. Ghost is the default - a low-key rectangle that only asserts
// itself on hover - primary is the one action on a screen the player most likely wants, and
// danger is reserved for anything that throws a run away.

.menu {
	flex-direction: column;
	align-items: stretch;
	width: 320px;
	margin-top: 40px;
	gap: 12px;
}

.menu-row {
	flex-direction: row;
	align-items: stretch;
	width: 320px;

	// Same standoff as a stacked menu, so a screen ending in a row of buttons has the same
	// rhythm as one ending in a column of them.
	margin-top: 40px;
	gap: 12px;

	.btn { flex-grow: 1; }
}

.btn {
	@include face;

	// Sides written out - see the note on the card mixin about the padding shorthand.
	padding-top: 14px;
	padding-bottom: 14px;
	padding-left: 24px;
	padding-right: 24px;

	border-radius: $radius;
	border: 1px solid $hairline;
	background-color: $plate;
	color: $text;
	font-size: 17px;
	font-weight: 600;
	text-align: center;
	justify-content: center;
	align-items: center;
	pointer-events: all;
	cursor: pointer;

	transition: background-color 0.12s $ease, border-color 0.12s $ease,
		color 0.12s $ease, transform 0.12s $ease;
	transform: scale(1);

	&:hover {
		background-color: $plate-hover;
		border-color: rgba(242, 237, 226, 0.24);
		transform: scale(1.03);
	}

	// Click feedback: a quick dip below rest, so a press always reads as a press even on a
	// hover state that looks similar at a glance.
	&:active {
		transform: scale(0.97);
		transition-duration: 0.05s;
	}

	&.primary {
		background-color: $apple;
		border-color: $apple;
		color: $text;

		&:hover { background-color: #ff5647; transform: scale(1.03); }
	}

	&.danger {
		&:hover { border-color: $apple; color: $apple; }
	}

	&.small {
		padding-top: 8px;
		padding-bottom: 8px;
		padding-left: 16px;
		padding-right: 16px;
		font-size: 14px;
	}
}

// ---------------------------------------------------------------- settings controls

.setting-row {
	flex-direction: row;
	align-items: center;
	justify-content: space-between;
	width: 100%;
	padding-top: 14px;
	padding-bottom: 14px;
	padding-left: 20px;
	padding-right: 20px;
	border-radius: $radius;
	background-color: $plate;
	border: 1px solid transparent;
	margin-bottom: 8px;
	transition: border-color 0.12s $ease, background-color 0.12s $ease;

	.setting-name {
		@include face;
		font-size: 16px;
		color: $text;

		// Fixed and non-shrinking, so the longest label sets the column and no row wraps its
		// name onto a second line while its neighbours stay on one.
		width: 120px;
		flex-shrink: 0;
	}
}

.stepper {
	flex-direction: row;
	align-items: center;
	gap: 12px;

	.step {
		width: 30px;
		height: 30px;
		border-radius: 999px;
		background-color: rgba(242, 237, 226, 0.08);
		border: 1px solid $hairline;
		color: $text;
		font-size: 17px;
		font-weight: 700;
		text-align: center;
		justify-content: center;
		align-items: center;
		pointer-events: all;
		cursor: pointer;
		transition: background-color 0.1s $ease, border-color 0.1s $ease, transform 0.1s $ease;

		&:hover { background-color: rgba(242, 237, 226, 0.16); border-color: $apple; }
		&:active { transform: scale(0.9); }
	}

	// Ten pips rather than a continuous bar. A single filled element sized from an inline
	// `style` never drew - the attribute did not reach the panel at all - and pips are the
	// better control anyway: one pip is exactly one tap of the stepper beside it, so the meter
	// shows what a click will do rather than only where the value currently sits.
	.meter {
		flex-direction: row;
		flex-shrink: 0;
		align-items: center;
		gap: 3px;

		.pip {
			width: 8px;
			height: 10px;
			flex-shrink: 0;
			border-radius: 3px;
			background-color: rgba(242, 237, 226, 0.14);
			transition: background-color 0.12s $ease;

			&.on { background-color: $apple; }
		}
	}

	.setting-value {
		@include numeral;
		font-size: 14px;
		color: $text-soft;
		min-width: 38px;
		text-align: center;
		justify-content: center;
	}
}

// ---------------------------------------------------------------- records readout

.records {
	flex-direction: column;
	align-items: stretch;
	width: 320px;
	margin-top: 28px;
	padding-top: 18px;
	padding-bottom: 18px;
	padding-left: 22px;
	padding-right: 22px;
	background-color: $plate;
	border-radius: $radius;

	.records-title { @include label; margin-bottom: 12px; }

	.record-row {
		flex-direction: row;
		justify-content: space-between;
		padding-top: 4px;
		padding-bottom: 4px;
		font-size: 15px;
		color: $text-soft;

		span:last-child { @include numeral; color: $text; }
	}
}
fpkreastudios.coilgarden / Audio/GameSounds.cs
Game game
namespace Coilgarden;

/// <summary>
/// The names of every sound event, in one place, so a typo is a compile error rather than a
/// silent nothing.
/// <para>
/// A misspelled sound path is one of the worst kinds of bug to find: the game runs, nothing
/// throws, and the only symptom is an event that is quieter than you remembered. Routing every
/// play through a constant removes the entire category.
/// </para>
/// <para>
/// The resources live in <c>Assets/sounds</c> and wrap <c>.wav</c> files synthesised by
/// <c>Tools/generate_audio.py</c>. Nothing here is sampled or third-party.
/// </para>
/// </summary>
public static class GameSounds
{
	// ------------------------------------------------------------------ gameplay

	/// <summary>Three variants plus a pitch range, because this one plays more than any other.</summary>
	public const string AppleEat = "sounds/coilgarden.apple.eat.sound";

	public const string AppleSpawn = "sounds/coilgarden.apple.spawn.sound";

	/// <summary>
	/// Played on a direction change only, never per step. Turning is the player's one input;
	/// stepping happens whether they act or not, and at six steps a second a footstep is a
	/// machine gun.
	/// </summary>
	public const string Turn = "sounds/coilgarden.turn.sound";

	public const string GameStart = "sounds/coilgarden.game.start.sound";

	public const string GameOver = "sounds/coilgarden.game.over.sound";

	public const string Restart = "sounds/coilgarden.restart.sound";

	/// <summary>Beating the stored best. Rare enough to stay special.</summary>
	public const string HighScore = "sounds/coilgarden.high.score.sound";

	/// <summary>Filling the whole tray - the rarest event in the game, and the only win state.</summary>
	public const string ArenaFilled = "sounds/coilgarden.arena.filled.sound";

	// ------------------------------------------------------------------ ui

	public const string UiHover = "sounds/coilgarden.ui.hover.sound";

	public const string UiClick = "sounds/coilgarden.ui.click.sound";

	// ------------------------------------------------------------------ beds

	/// <summary>Menu and pre-run bed. Almost nothing happens in it, deliberately.</summary>
	public const string MusicCalm = "sounds/coilgarden.music.calm.sound";

	/// <summary>In-run bed. Same harmony as the calm one, so a crossfade never changes key.</summary>
	public const string MusicPlay = "sounds/coilgarden.music.play.sound";

	/// <summary>Always running, very quiet. Stops silence sounding like a bug.</summary>
	public const string Ambience = "sounds/coilgarden.ambience.garden.sound";
}
fpkreastudios.coilgarden / Core/DebugCommands.cs
Game game
namespace Coilgarden;

/// <summary>
/// Console commands used to verify behaviour in the running engine, where the headless test
/// suite cannot reach. All prefixed <c>cg_</c>.
/// <para>
/// These are a development tool and are expected to be removed by the final presentation
/// phase. They deliberately avoid touching any Razor component, because the headless build
/// does not compile Razor and a reference from here would break it.
/// </para>
/// <para>
/// Every one of them tolerates there being no live run. Components cache themselves in the
/// component lifecycle, which the editor also runs for the edit-mode scene, so a command can
/// be invoked while there is no session at all - and a null dereference in a console command
/// reads like a bug in the game.
/// </para>
/// </summary>
public static class DebugCommands
{
	/// <summary>The live session, or null with a reason already logged.</summary>
	private static GameSession Session( string command )
	{
		var session = GameSession.Current;

		if ( session?.Run is not null ) return session;

		Log.Info( $"{command}: no running session. Is play mode started?" );
		return null;
	}

	/// <summary>Dumps the live session's state - the quickest way to see what the game thinks is happening.</summary>
	[ConCmd( "cg_state" )]
	public static void State()
	{
		var session = Session( "cg_state" );
		if ( session is null ) return;

		var run = session.Run;
		var apple = run.Apple.HasValue ? run.Apple.Value.ToString() : "none";

		Log.Info( $"cg_state: state={session.State} arena={run.Arena.Width}x{run.Arena.Height} " +
			$"head={run.Snake.Head} dir={run.Snake.Direction} next={run.Snake.NextDirection} " +
			$"length={run.Snake.Length} buffered={run.Snake.BufferedTurns} apple={apple} score={run.Score} " +
			$"apples={run.ApplesEaten} ticks={run.Ticks} endedBy={run.EndedBy?.ToString() ?? "-"} " +
			$"lastStep={run.EndedBy?.ToString() ?? session.LastStep.Outcome.ToString()} " +
			$"tickFraction={session.TickFraction:F2} best={session.Records.BestScore} newBest={session.IsNewBest} " +
			$"modal={session.ModalOpen} stateAge={session.StateAge:F2} feelTime={session.FeelTime:F2} " +
			$"autoPause={session.AutoPauseOnFocusLoss} focused={Application.IsFocused}" );
	}

	/// <summary>Dumps the persistent record, for checking that it survives a restart.</summary>
	[ConCmd( "cg_records" )]
	public static void Records()
	{
		var session = Session( "cg_records" );
		if ( session is null ) return;

		var records = session.Records;

		Log.Info( $"cg_records: bestScore={records.BestScore} bestLength={records.BestLength} " +
			$"runs={records.RunsPlayed} applesTotal={records.ApplesEatenTotal} " +
			$"version={records.Version} file={HighScoreStore.FileName}" );
	}

	/// <summary>Wipes the stored record. Needed to test first-launch behaviour more than once.</summary>
	[ConCmd( "cg_clearrecords" )]
	public static void ClearRecords()
	{
		HighScoreStore.Save( new HighScoreData() );
		Log.Info( "cg_clearrecords: record cleared on disk. Restart play mode to reload it." );
	}

	/// <summary>Restarts the live run, for checking that a restart really does reset everything.</summary>
	[ConCmd( "cg_restart" )]
	public static void RestartRun()
	{
		var session = Session( "cg_restart" );
		if ( session is null ) return;

		session.Restart();
		Log.Info( $"cg_restart: restarted, state={session.State}." );
	}

	/// <summary>Starts play from the waiting state, so a run can be driven without a keyboard.</summary>
	[ConCmd( "cg_start" )]
	public static void StartRun()
	{
		var session = Session( "cg_start" );
		if ( session is null ) return;

		session.StartRun();
		Log.Info( $"cg_start: state is now {session.State}." );
	}

	/// <summary>Returns to the title screen, abandoning any run in progress.</summary>
	[ConCmd( "cg_menu" )]
	public static void ReturnToMenu()
	{
		var session = Session( "cg_menu" );
		if ( session is null ) return;

		session.ReturnToMenu();
		Log.Info( $"cg_menu: state is now {session.State}." );
	}

	/// <summary>Pauses or resumes: <c>cg_pause 1</c> or <c>cg_pause 0</c>.</summary>
	[ConCmd( "cg_pause" )]
	public static void Pause( int paused )
	{
		var session = Session( "cg_pause" );
		if ( session is null ) return;

		session.SetPaused( paused != 0 );
		Log.Info( $"cg_pause: state is now {session.State}." );
	}

	/// <summary>
	/// Turns focus auto-pause off or on: <c>cg_autopause 0</c>.
	/// <para>
	/// Needed because the feature works. Driving the game from outside the engine means the
	/// editor window is not the foreground window, so a run pauses the instant it starts and
	/// no automated verification can get past it.
	/// </para>
	/// </summary>
	[ConCmd( "cg_autopause" )]
	public static void AutoPause( int enabled )
	{
		var session = Session( "cg_autopause" );
		if ( session is null ) return;

		session.AutoPauseOnFocusLoss = enabled != 0;
		Log.Info( $"cg_autopause: focus auto-pause is {(session.AutoPauseOnFocusLoss ? "on" : "off")}." );
	}

	/// <summary>
	/// Sets the live tick interval in seconds: <c>cg_speed 1.0</c>.
	/// <para>
	/// Its real purpose is verification rather than tuning. A console command cannot advance
	/// a frame, so at the real tick rate there is no way to observe a specific tick from
	/// outside the game - slowing the clock to a second a step makes the whole loop
	/// steerable and inspectable one tick at a time.
	/// </para>
	/// </summary>
	[ConCmd( "cg_speed" )]
	public static void Speed( float seconds )
	{
		var session = Session( "cg_speed" );
		if ( session is null ) return;

		session.TickInterval = seconds.Clamp( 0.02f, 5f );
		Log.Info( $"cg_speed: tick interval is now {session.TickInterval}s." );
	}

	/// <summary>
	/// Queues a turn on the live run: <c>cg_turn up|down|left|right</c>. This is how input
	/// handling can be exercised from outside without a human at the keyboard.
	/// </summary>
	[ConCmd( "cg_turn" )]
	public static void Turn( string direction )
	{
		var session = Session( "cg_turn" );
		if ( session is null ) return;

		var parsed = Parse( direction );

		if ( parsed is null )
		{
			Log.Info( "cg_turn: expected up, down, left or right." );
			return;
		}

		var accepted = session.Run.TryTurn( parsed.Value );

		Log.Info( $"cg_turn {parsed.Value}: {(accepted ? "accepted" : "refused")}, " +
			$"buffered={session.Run.Snake.BufferedTurns}." );
	}

	/// <summary>Reports which headings survive the next tick, from the game's own rule.</summary>
	[ConCmd( "cg_safe" )]
	public static void Safe()
	{
		var session = Session( "cg_safe" );
		if ( session is null ) return;

		var run = session.Run;
		var safe = new List<string>();

		foreach ( Direction direction in Enum.GetValues<Direction>() )
		{
			if ( run.WouldSurvive( direction ) ) safe.Add( direction.ToString() );
		}

		Log.Info( $"cg_safe: head={run.Snake.Head} survivable=[{string.Join( ", ", safe )}]" );
	}

	private static Direction? Parse( string direction ) => direction?.ToLowerInvariant() switch
	{
		"up" => Direction.Up,
		"down" => Direction.Down,
		"left" => Direction.Left,
		"right" => Direction.Right,
		_ => null
	};

	/// <summary>
	/// Reports the live feel state: where the tick is, and what the camera is doing.
	/// <para>
	/// A console command cannot advance a frame, so run it twice in quick succession to watch
	/// something decay - that is the only way to observe a frame-dependent animation from
	/// outside the game.
	/// </para>
	/// </summary>
	[ConCmd( "cg_feel" )]
	public static void Feel()
	{
		var session = Session( "cg_feel" );
		if ( session is null ) return;

		var camera = Sandbox.Game.ActiveScene?.GetAllComponents<ArenaCamera>().FirstOrDefault();
		var height = camera?.Camera?.OrthographicHeight ?? 0f;

		Log.Info( $"cg_feel: state={session.State} tickFraction={session.TickFraction:F3} " +
			$"interval={session.TickInterval:F3} head={session.Run.Snake.Head} " +
			$"orthoHeight={height:F1} camPos={camera?.WorldPosition}" );
	}

	/// <summary>
	/// Reports the audio state: the music mix, the group levels, and how many effects have played
	/// or failed to.
	/// <para>
	/// <c>failed</c> is the one to watch. A null handle is what a missing or uncompiled
	/// <c>.vsnd</c> looks like from code, and it is otherwise completely silent - in both senses.
	/// </para>
	/// </summary>
	[ConCmd( "cg_audio" )]
	public static void Audio()
	{
		var music = Sandbox.Game.ActiveScene?.GetAllComponents<MusicDirector>().FirstOrDefault();
		var audio = GameAudio.Current;

		Log.Info( music is null
			? "cg_audio: no MusicDirector in the scene."
			: $"cg_audio: blend={music.Blend:F2} calm={music.CalmPlaying} " +
			  $"play={music.PlayPlaying} ambience={music.AmbiencePlaying} " +
			  $"musicVol={music.MusicVolume:F2} ambienceVol={music.AmbienceVolume:F2}" );

		Log.Info( audio is null
			? "cg_audio: no GameAudio in the scene."
			: $"cg_audio: sfxVol={audio.SfxVolume:F2} played={audio.PlayCount} " +
			  $"failed={audio.FailedCount} last={audio.LastPlayed}" );
	}

	/// <summary>
	/// Reports whether the mouse can actually reach the interface. A UI whose buttons are
	/// perfect and whose cursor is hidden is indistinguishable, from the player's side, from a
	/// UI that is broken.
	/// </summary>
	[ConCmd( "cg_cursor" )]
	public static void Cursor()
	{
		Log.Info( $"cg_cursor: visibility={Mouse.Visibility} active={Mouse.Active} " +
			$"position={Mouse.Position} focused={Application.IsFocused} " +
			$"engineMenu={Game.IsMainMenuVisible}" );
	}

	/// <summary>Dumps the live settings - the group volumes the settings panel edits.</summary>
	[ConCmd( "cg_settings" )]
	public static void Settings()
	{
		var session = Session( "cg_settings" );
		if ( session is null ) return;

		var s = session.Settings;

		Log.Info( $"cg_settings: master={s.MasterVolume:F2} sfx={s.SfxVolume:F2} " +
			$"music={s.MusicVolume:F2} ambience={s.AmbienceVolume:F2} file={SettingsStore.FileName}" );
	}

	/// <summary>
	/// Sets a volume group to an absolute value, for checking the settings screen without a
	/// mouse: <c>cg_setvolume sfx 0.5</c>. Channel is one of master, sfx, music, ambience.
	/// </summary>
	[ConCmd( "cg_setvolume" )]
	public static void SetVolume( string channel, float value )
	{
		var session = Session( "cg_setvolume" );
		if ( session is null ) return;

		var s = session.Settings;

		switch ( channel?.ToLowerInvariant() )
		{
			case "master": session.AdjustMasterVolume( value - s.MasterVolume ); break;
			case "sfx": session.AdjustSfxVolume( value - s.SfxVolume ); break;
			case "music": session.AdjustMusicVolume( value - s.MusicVolume ); break;
			case "ambience": session.AdjustAmbienceVolume( value - s.AmbienceVolume ); break;
			default:
				Log.Info( "cg_setvolume: expected master, sfx, music or ambience." );
				return;
		}

		Log.Info( $"cg_setvolume: {channel} is now {value:F2}." );
	}

	/// <summary>
	/// Puts every setting back to the designed balance - the same thing the settings screen's
	/// Restore Defaults button does, reachable without a mouse.
	/// </summary>
	[ConCmd( "cg_resetsettings" )]
	public static void ResetSettings()
	{
		var session = Session( "cg_resetsettings" );
		if ( session is null ) return;

		session.ResetSettings();

		var s = session.Settings;

		Log.Info( $"cg_resetsettings: master={s.MasterVolume:F2} sfx={s.SfxVolume:F2} " +
			$"music={s.MusicVolume:F2} ambience={s.AmbienceVolume:F2}" );
	}

	/// <summary>
	/// Plays one sound by its short name, so each can be checked in isolation:
	/// <c>cg_sound apple.eat</c>.
	/// </summary>
	[ConCmd( "cg_sound" )]
	public static void PlaySound( string which )
	{
		if ( string.IsNullOrWhiteSpace( which ) )
		{
			Log.Info( "cg_sound: expected a sound name, e.g. apple.eat. Use cg_audio for state." );
			return;
		}

		var path = $"sounds/coilgarden.{which}.sound";

		// Routed through GameAudio so it is heard at the same level the game plays it at -
		// checking balance against a raw full-volume play would be checking nothing.
		if ( GameAudio.Current is not null )
		{
			GameAudio.Current.Play( path );
			Log.Info( $"cg_sound: played {path} through GameAudio." );
			return;
		}

		var handle = Sound.Play( path );

		Log.Info( handle is null
			? $"cg_sound: '{path}' did not play - is the wav compiled?"
			: $"cg_sound: played {path} directly (no GameAudio in the scene)." );
	}

	/// <summary>
	/// Fires the camera's punch and shake without needing to eat or die.
	/// <para>
	/// Both last about a fifth of a second, which is shorter than a round trip - so pair this
	/// with <c>cg_feel</c> in one batched request to catch the camera actually displaced.
	/// </para>
	/// </summary>
	[ConCmd( "cg_kick" )]
	public static void Kick()
	{
		var camera = Sandbox.Game.ActiveScene?.GetAllComponents<ArenaCamera>().FirstOrDefault();

		if ( camera is null )
		{
			Log.Info( "cg_kick: no ArenaCamera in the scene." );
			return;
		}

		camera.Punch( GameConfig.CameraPunchAmount );
		camera.Shake( GameConfig.CameraShakeAmount );

		Log.Info( "cg_kick: punch and shake requested." );
	}

	/// <summary>
	/// Fires a sparkle burst at the apple's cell without eating it.
	/// <para>
	/// Exists because the eat burst lasts under half a second, which is far shorter than a
	/// screenshot round trip - there is otherwise no way to look at the particles at all.
	/// </para>
	/// </summary>
	[ConCmd( "cg_burst" )]
	public static void Burst()
	{
		var session = Session( "cg_burst" );
		if ( session is null ) return;

		var effects = Sandbox.Game.ActiveScene?.GetAllComponents<Effects>().FirstOrDefault();

		if ( effects is null )
		{
			Log.Info( "cg_burst: no Effects component in the scene." );
			return;
		}

		var cell = session.Run.Apple ?? session.Run.Snake.Head;

		effects.Burst( cell );

		// Reports the pool state, because a silent early return inside Burst is indistinguishable
		// from particles that spawn and are then invisible for some other reason.
		Log.Info( $"cg_burst: cell={cell} origin={effects.LastBurstOrigin} alive={effects.AliveCount} " +
			$"enable={effects.Enable} timeScale={effects.TimeScale:F2}" );
	}

	/// <summary>
	/// Exercises the rules against a throwaway simulation and reports pass/fail per check.
	/// <para>
	/// This duplicates part of the headless suite on purpose. Running the same rules inside
	/// the engine is what proves they behave the same there - the headless build knows
	/// nothing about s&amp;box's API whitelist, so a call that is legal to Roslyn and rejected
	/// by the engine would otherwise only be found by playing.
	/// </para>
	/// </summary>
	[ConCmd( "cg_selftest" )]
	public static void SelfTest()
	{
		var passed = 0;
		var failed = 0;

		void Check( string what, bool condition )
		{
			if ( condition )
			{
				passed++;
				return;
			}

			failed++;
			Log.Warning( $"cg_selftest FAILED: {what}" );
		}

		var rules = GameRules.Default with { Width = 11, Height = 11 };
		var run = new SnakeGame( rules, 1234 );

		// A fresh run is set up correctly.
		Check( "starts at the configured length", run.Snake.Length == rules.StartLength );
		Check( "head starts at the arena centre", run.Snake.Head == run.Arena.Centre );
		Check( "the rules were kept", run.Rules.Width == 11 && run.Rules.Height == 11 );
		Check( "an apple exists", run.Apple.HasValue );
		Check( "the apple is not under the snake", run.Apple.HasValue && !run.Snake.Occupies( run.Apple.Value ) );
		Check( "score starts at zero", run.Score == 0 );
		Check( "the run is not over", !run.IsOver );

		// Turning.
		Check( "a reversal is refused", !run.TryTurn( run.Snake.Direction.Opposite() ) );
		Check( "the current heading is refused as a turn", !run.TryTurn( run.Snake.Direction ) );
		Check( "a legal turn is accepted", run.TryTurn( Direction.Up ) );

		// Moving.
		var before = run.Snake.Head;
		var step = run.Step();

		Check( "the buffered turn was taken", run.Snake.Direction == Direction.Up );
		Check( "the head moved one cell", step.To == before + Direction.Up.Delta() );
		Check( "length is unchanged by a plain move", run.Snake.Length == rules.StartLength );

		// The survivability query has to agree with what a step actually does, or the two
		// copies of the collision rule have drifted apart.
		var predictedSurvival = run.WouldSurvive( run.Snake.Direction );
		var actuallySurvived = !run.Step().IsTerminal;

		Check( "WouldSurvive agrees with the step it predicted", predictedSurvival == actuallySurvived );

		// Walls end the run. Drive straight up until something happens.
		var guard = 0;
		while ( !run.IsOver && guard++ < 500 )
		{
			run.Step();
		}

		Check( "driving into a wall ends the run", run.IsOver );
		Check( "the run ended for a stated reason", run.EndedBy.HasValue );
		Check( "a finished run survives nothing", !run.WouldSurvive( Direction.Up ) );

		// Restart clears everything.
		run.Restart();

		Check( "restart clears the end state", !run.IsOver && run.EndedBy is null );
		Check( "restart resets the score", run.Score == 0 );
		Check( "restart resets the length", run.Snake.Length == rules.StartLength );
		Check( "restart resets the tick count", run.Ticks == 0 );
		Check( "restart puts the head back at the centre", run.Snake.Head == run.Arena.Centre );
		Check( "restart leaves an apple on the board", run.Apple.HasValue );

		// Eating grows and scores.
		var eaten = false;
		var lengthBefore = run.Snake.Length;
		guard = 0;

		while ( !run.IsOver && !eaten && guard++ < 5000 )
		{
			SteerTowardsApple( run );
			eaten = run.Step().Grew;
		}

		Check( "the snake can reach an apple", eaten );

		if ( eaten )
		{
			Check( "eating grew the snake", run.Snake.Length == lengthBefore + 1 );
			Check( "eating scored the rules' value", run.Score == rules.ApplePoints );
			Check( "eating counted an apple", run.ApplesEaten == 1 );
			Check( "a new apple was spawned", run.Apple.HasValue );
			Check( "the new apple is not under the snake", run.Apple.HasValue && !run.Snake.Occupies( run.Apple.Value ) );
		}

		// Records, which need no filesystem to be checked.
		var records = new HighScoreData();

		Check( "a first score is a new best", records.Submit( 50, 9, 5 ) );
		Check( "the best was stored", records.BestScore == 50 );
		Check( "a worse score is not a new best", !records.Submit( 10, 4, 1 ) );
		Check( "a worse score does not lower the best", records.BestScore == 50 );
		Check( "matching the best is not beating it", !records.Submit( 50, 9, 5 ) );
		Check( "every run is counted", records.RunsPlayed == 3 );

		Log.Info( $"cg_selftest: {passed}/{passed + failed} checks passed." );
	}

	/// <summary>
	/// Apple seeking, steering only through the public API. Survivability comes from
	/// <see cref="SnakeGame.WouldSurvive"/> rather than a second copy of the collision rule -
	/// which is exactly what this used to be, in two places.
	/// </summary>
	private static void SteerTowardsApple( SnakeGame run )
	{
		if ( !run.Apple.HasValue ) return;

		var snake = run.Snake;
		var delta = run.Apple.Value - snake.Head;

		var towardsX = delta.X > 0 ? Direction.Right : Direction.Left;
		var towardsY = delta.Y > 0 ? Direction.Up : Direction.Down;

		var preferences = Math.Abs( delta.X ) >= Math.Abs( delta.Y )
			? new[] { towardsX, towardsY, snake.Direction, towardsY.Opposite(), towardsX.Opposite() }
			: new[] { towardsY, towardsX, snake.Direction, towardsX.Opposite(), towardsY.Opposite() };

		foreach ( var candidate in preferences )
		{
			if ( snake.Direction.IsOpposite( candidate ) ) continue;
			if ( !run.WouldSurvive( candidate ) ) continue;

			if ( candidate != snake.Direction ) run.TryTurn( candidate );
			return;
		}
	}
}
fpkreastudios.coilgarden / Core/TickClock.cs
Game game
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;
	}
}
fpkreastudios.coilgarden / Feel/Effects.cs
Game game
namespace Coilgarden;

/// <summary>
/// A small pool of sparkles, thrown when an apple is eaten.
/// <para>
/// Hand-rolled rather than driven by the engine's particle system, for two reasons. The first
/// is control: at ten particles an event, every one of them is visible, and being able to tune
/// the exact arc, life and shrink of each is worth more here than any feature a general
/// particle system offers. The second is that the whole visual identity is "two primitives and
/// code", and a sparkle is a small sphere.
/// </para>
/// <para>
/// <b>Budgeted, not unbounded.</b> The pool is fixed and allocated once; a burst that would
/// exceed it simply throws fewer. There is no path by which a fast player fills the screen -
/// which matters, because the one thing an effect must never do here is hide the board.
/// </para>
/// </summary>
public sealed class Effects : Component
{
	[Property] public GameSession Session { get; set; }

	[Property] public float CellSize { get; set; } = GameConfig.CellSize;

	/// <summary>Turn the sparkles off, to check the game still reads without them.</summary>
	[Property] public bool Enable { get; set; } = true;

	/// <summary>
	/// Slows the sparkles down, for looking at them.
	/// <para>
	/// A burst lasts under half a second, which is shorter than a screenshot round trip - so
	/// without this there is no way to inspect the arc, the spread or the shrink at all. Left at
	/// 1 in play; drop it to about 0.08 to study a burst frame by frame.
	/// </para>
	/// </summary>
	[Property, Range( 0.02f, 1f )] public float TimeScale { get; set; } = 1f;

	private struct Sparkle
	{
		public ModelRenderer Renderer;
		public Vector3 Position;
		public Vector3 Velocity;
		public float Age;
		public float Life;
		public bool Alive;
	}

	private GameObject visualRoot;
	private Primitives primitives;
	private ArenaSpace space;
	private Sparkle[] pool;
	private int nextIndex;

	private int builtWidth;
	private int builtHeight;

	/// <summary>Deterministic per-session, so a burst is not a different shape every run.</summary>
	private readonly System.Random random = new( 0x5EED );

	protected override void OnEnabled()
	{
		Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();
	}

	protected override void OnDisabled() => TearDown();

	/// <summary>How many sparkles are currently in flight. For the debug readout.</summary>
	public int AliveCount
	{
		get
		{
			if ( pool is null ) return -1;

			var alive = 0;

			for ( var i = 0; i < pool.Length; i++ )
			{
				if ( pool[i].Alive ) alive++;
			}

			return alive;
		}
	}

	/// <summary>Where the last burst was asked to happen, and whether the pool was ready for it.</summary>
	public Vector3 LastBurstOrigin { get; private set; }

	/// <summary>Throws a burst of sparkles out of a cell.</summary>
	public void Burst( GridPos cell )
	{
		if ( !Enable || pool is null ) return;

		var origin = space.Above( space.Cell( cell ), GameConfig.AppleDiameter * 0.5f );

		LastBurstOrigin = origin;

		for ( var i = 0; i < GameConfig.SparklesPerApple; i++ )
		{
			Launch( origin );
		}
	}

	private void Launch( Vector3 origin )
	{
		// Round-robin through the pool. The oldest sparkle is the one recycled, so a burst
		// during a burst degrades by dropping the stalest particle rather than by refusing.
		var index = nextIndex;
		nextIndex = (nextIndex + 1) % pool.Length;

		// Outwards in the tray plane, biased towards the viewer so the burst arcs up off the
		// sand rather than sliding along it.
		var angle = (float)(random.NextDouble() * MathF.PI * 2f);
		var speed = GameConfig.SparkleSpeed * CellSize * (0.55f + (float)random.NextDouble() * 0.7f);

		var velocity = new Vector3(
			-speed * (0.5f + (float)random.NextDouble() * 0.5f),
			MathF.Cos( angle ) * speed * 0.55f,
			MathF.Sin( angle ) * speed * 0.55f );

		pool[index].Position = origin;
		pool[index].Velocity = velocity;
		pool[index].Age = 0f;
		pool[index].Life = GameConfig.SparkleLife * (0.7f + (float)random.NextDouble() * 0.6f);
		pool[index].Alive = true;
	}

	protected override void OnUpdate()
	{
		var arena = Session?.Run?.Arena;
		if ( arena is null ) return;

		EnsureBuilt( arena );

		// Sparkles in flight stop where they are while paused, rather than continuing to arc and
		// fall behind the pause card.
		var delta = Session.FeelDelta * TimeScale;

		for ( var i = 0; i < pool.Length; i++ )
		{
			if ( !pool[i].Alive )
			{
				continue;
			}

			pool[i].Age += delta;

			var t = Ease.Progress( pool[i].Age, pool[i].Life );

			if ( t >= 1f )
			{
				pool[i].Alive = false;
				pool[i].Renderer.Enabled = false;
				continue;
			}

			// "Up" out of the tray is -X, so gravity pulls back towards +X.
			pool[i].Velocity += new Vector3( GameConfig.SparkleGravity * CellSize * delta, 0f, 0f );
			pool[i].Position += pool[i].Velocity * delta;

			// A sparkle that has fallen back to the sand is done. Without this they keep going
			// and end up *behind* the sand bed, still alive and invisible - which looks exactly
			// like the particles never having worked.
			if ( pool[i].Position.x >= 0f )
			{
				pool[i].Alive = false;
				pool[i].Renderer.Enabled = false;
				continue;
			}

			var renderer = pool[i].Renderer;

			renderer.GameObject.LocalPosition = pool[i].Position;

			// Shrinking to nothing is what makes them read as sparks rather than as debris that
			// vanishes. InQuad keeps them full-size for most of their life and then goes quickly.
			primitives.Resize( renderer, GameConfig.SparkleSize * CellSize * (1f - Ease.InQuad( t )) );
			renderer.Enabled = true;
		}
	}

	private void EnsureBuilt( Arena arena )
	{
		var built = visualRoot.IsValid()
			&& builtWidth == arena.Width
			&& builtHeight == arena.Height
			&& pool is not null;

		if ( built ) return;

		TearDown();

		builtWidth = arena.Width;
		builtHeight = arena.Height;

		visualRoot = new GameObject( GameObject, true, "Sparkles" );
		visualRoot.Flags |= GameObjectFlags.NotSaved;

		primitives = new Primitives( visualRoot );
		space = new ArenaSpace( arena.Width, arena.Height, CellSize );

		pool = new Sparkle[GameConfig.SparkleBudget];

		for ( var i = 0; i < pool.Length; i++ )
		{
			pool[i].Renderer = primitives.Sphere( $"Sparkle {i}", Vector3.Zero,
				GameConfig.SparkleSize * CellSize, Palette.Sparkle );

			pool[i].Renderer.Enabled = false;
		}
	}

	private void TearDown()
	{
		visualRoot?.Destroy();
		visualRoot = null;
		primitives = null;
		pool = null;
		nextIndex = 0;
		builtWidth = 0;
		builtHeight = 0;
	}
}
fpkreastudios.coilgarden / Snake/SnakeGame.cs
Game game
namespace Coilgarden;

/// <summary>
/// One run, as pure rules: the arena, the snake, the apple and the score, with a single
/// <see cref="Step"/> that advances all of them by one logical tick.
/// <para>
/// This is the whole game as far as correctness is concerned. It has no engine dependency
/// at all, which is what lets the rules be exercised thousands of times in a headless test
/// run, and it is the seam that stops the feel layer from ever becoming load-bearing:
/// <see cref="GameSession"/> decides <em>when</em> to step, and the views decide how it
/// looks, but neither can change what happens.
/// </para>
/// </summary>
public sealed class SnakeGame
{
	private readonly AppleSpawner spawner;

	public SnakeGame( GameRules rules, int seed )
	{
		// Clamped here rather than trusted, so there is no way to construct a run whose
		// rules cannot be played - whatever the caller read them from.
		Rules = rules.Clamped();

		Arena = new Arena( Rules.Width, Rules.Height );
		Snake = new Snake( Arena, Rules.StartLength, Rules.StartDirection, Rules.MaxBufferedTurns );
		spawner = new AppleSpawner( Arena, seed );

		Restart();
	}

	/// <summary>Convenience for the common case and for tests that do not vary the rules.</summary>
	public SnakeGame( int seed ) : this( GameRules.Default, seed )
	{
	}

	/// <summary>The rules this run is being played under. Fixed for its lifetime.</summary>
	public GameRules Rules { get; }

	public Arena Arena { get; }

	public Snake Snake { get; }

	/// <summary>Where the apple is, or null when the arena had no room for one.</summary>
	public GridPos? Apple { get; private set; }

	public int Score { get; private set; }

	public int ApplesEaten { get; private set; }

	/// <summary>Ticks survived this run. The difficulty curve and the HUD both read it.</summary>
	public int Ticks { get; private set; }

	/// <summary>Set once the run has ended, and the reason why.</summary>
	public StepOutcome? EndedBy { get; private set; }

	public bool IsOver => EndedBy.HasValue;

	/// <summary>
	/// Returns every field to its starting value. Written as a single method that touches
	/// all of them so a field added later has one obvious place to be reset, rather than
	/// being forgotten in one of several branches.
	/// <para>
	/// The apple RNG is deliberately <em>not</em> reseeded: consecutive runs in one sitting
	/// should not open with the same apple in the same place.
	/// </para>
	/// </summary>
	public void Restart()
	{
		Snake.Reset( Rules.StartLength, Rules.StartDirection );

		Score = 0;
		ApplesEaten = 0;
		Ticks = 0;
		EndedBy = null;
		Apple = spawner.Pick( Snake );
	}

	/// <summary>Records a turn for an upcoming tick. Ignored once the run is over.</summary>
	public bool TryTurn( Direction direction ) => !IsOver && Snake.TryTurn( direction );

	/// <summary>
	/// Would heading this way on the next tick keep the run alive?
	/// <para>
	/// Answers from the same rule <see cref="Step"/> resolves with rather than a second copy
	/// of it, so the two cannot drift apart. Used by the test and self-test bots to play the
	/// game through the public API, and it is the query a hint or accessibility cue would
	/// be built on.
	/// </para>
	/// <para>
	/// Note this asks about a <em>heading</em>, not about whether the turn is legal -
	/// <see cref="TryTurn"/> answers that.
	/// </para>
	/// </summary>
	public bool WouldSurvive( Direction direction )
	{
		if ( IsOver ) return false;

		var target = Snake.Head + direction.Delta();

		if ( !Arena.Contains( target ) ) return false;

		var growing = Apple.HasValue && Apple.Value == target;

		return !Snake.CollidesWithBody( target, growing );
	}

	/// <summary>
	/// Advances one logical tick. Does nothing once the run has ended, so a session that
	/// keeps ticking through a death animation cannot accidentally step past it.
	/// </summary>
	public StepResult Step()
	{
		if ( IsOver )
		{
			return new StepResult( EndedBy.Value, Snake.Head, Snake.Head, Snake.Direction, false, 0 );
		}

		var result = Snake.Step( Apple );

		Ticks++;

		if ( result.Grew )
		{
			ApplesEaten++;

			var points = ScoreRules.ApplePoints( ApplesEaten, Rules );
			Score += points;

			result = result with { ScoreGained = points };

			// A null here means the snake now covers every cell, which the step already
			// reported as FilledArena. Leaving Apple null is correct: there is nowhere to
			// put one, and the run is over anyway.
			Apple = spawner.Pick( Snake );
		}

		if ( result.IsTerminal )
		{
			EndedBy = result.Outcome;
		}

		return result;
	}
}
fpkreastudios.coilgarden / UI/HudPanel.razor
Game game
@using Sandbox;
@using Sandbox.UI;
@namespace Coilgarden
@inherits Panel
@attribute [StyleSheet( "/UI/GameUi.razor.scss" )]

@*
	The live readout: score and best, and nothing else. Deliberately small - everything else
	the player needs during a run is in the arena itself, and length lives in the game-over
	summary instead of here because it is interesting after a run and noise during one.

	Refreshes itself off its own Tick(), independently of whichever overlay GameUi has on top
	of it, so a paused or finished run still shows a live-looking score without dragging the
	rest of the interface through a rebuild to do it.
*@

<root class="readout">
	<div class="@ScoreClass">@ScoreText</div>
	<div class="best">
		<span class="best-label">BEST</span>
		<span class="best-value">@BestText</span>
	</div>
</root>

@code
{
	[Parameter] public GameSession Session { get; set; }

	private string ScoreText { get; set; } = "0";
	private string BestText { get; set; } = "0";

	/// <summary>Carries the pop class while the score is counting, built here rather than in
	/// markup so the attribute is never a mix of literal text and an @@expression.</summary>
	private string ScoreClass { get; set; } = "score";

	/// <summary>
	/// The score as it is being shown, which chases the real one rather than jumping to it.
	/// A number that counts up turns a state change into an event the eye can follow, and it
	/// is the difference between the score being a readout and the score being the reward.
	/// Kept as a float so the chase is smooth, and always floored so the player never sees a
	/// number higher than what they have actually earned.
	/// </summary>
	private float shownScore;

	private int lastRealScore;

	/// <summary>Seconds since the score last changed, driving the pop.</summary>
	private float scoreAge = 99f;

	public override void Tick()
	{
		base.Tick();

		var run = Session?.Run;
		if ( run is null ) return;

		TickScore( run.Score );

		ScoreText = ((int)shownScore).ToString();
		BestText = (Session.Records?.BestScore ?? 0).ToString();
	}

	/// <summary>
	/// Advances the displayed score towards the real one and drives the pop.
	/// <para>
	/// A restart drops the target to zero, and that is the one case where counting is wrong -
	/// watching your score tick down to nothing is a strange thing to do to somebody who just
	/// asked to play again, so it snaps instead.
	/// </para>
	/// </summary>
	private void TickScore( int real )
	{
		if ( real != lastRealScore )
		{
			if ( real < lastRealScore ) shownScore = real;

			lastRealScore = real;
			scoreAge = 0f;
		}

		scoreAge += Time.Delta;

		// Rate comes from the size of the gap, so an award of any size lands in about the same
		// time rather than a big one taking noticeably longer.
		var gap = MathF.Abs( real - shownScore );

		if ( gap > 0.01f )
		{
			var rate = MathF.Max( gap, 1f ) / GameConfig.ScoreCountDuration;

			shownScore = shownScore < real
				? MathF.Min( shownScore + rate * Time.Delta, real )
				: MathF.Max( shownScore - rate * Time.Delta, real );
		}
		else
		{
			shownScore = real;
		}

		ScoreClass = scoreAge < GameConfig.ScoreCountDuration ? "score popped" : "score";
	}

	/// <summary>
	/// The counting score is folded in as its whole number, so the panel redraws once per
	/// digit change rather than every frame.
	/// </summary>
	protected override int BuildHash() => System.HashCode.Combine( (int)shownScore, ScoreClass, BestText );
}
fpkreastudios.coilgarden / UI/MainMenuPanel.razor
Game game
@using Sandbox;
@using Sandbox.UI;
@namespace Coilgarden
@inherits Panel
@attribute [StyleSheet( "/UI/GameUi.razor.scss" )]

@*
	The title screen. A keyboard player can skip it entirely by just pressing a direction -
	GameSession takes that as both "start" and the first turn - so the buttons here exist for
	the mouse player and for reaching Settings, not because the game waits on them.
*@

<root class="scrim">
	<div class="card">
		<div class="wordmark">Coilgarden</div>
		<div class="tagline">A small garden. One apple at a time.</div>

		<div class="menu">
			<div onmouseover=@UiSound.Hover class="btn primary" onclick=@Play>Play</div>
			<div onmouseover=@UiSound.Hover class="btn" onclick=@OpenSettingsClicked>Settings</div>
		</div>

		<div class="prompt">or press a direction to begin</div>

		@if ( HasRecord )
		{
			<div class="records">
				<div class="records-title">Best run</div>
				<div class="record-row"><span>Score</span><span>@BestScoreText</span></div>
				<div class="record-row"><span>Length</span><span>@BestLengthText</span></div>
				<div class="record-row"><span>Runs played</span><span>@RunsText</span></div>
			</div>
		}
	</div>
</root>

@code
{
	[Parameter] public GameSession Session { get; set; }

	[Parameter] public Action SettingsRequested { get; set; }

	private HighScoreData Records => Session?.Records;

	private bool HasRecord => Records is { RunsPlayed: > 0 };

	private string BestScoreText => (Records?.BestScore ?? 0).ToString();

	private string BestLengthText => (Records?.BestLength ?? 0).ToString();

	private string RunsText => (Records?.RunsPlayed ?? 0).ToString();

	private void Play()
	{
		UiSound.Click();
		Session?.StartRun();
	}

	private void OpenSettingsClicked()
	{
		UiSound.Click();
		SettingsRequested?.Invoke();
	}

	protected override int BuildHash() => System.HashCode.Combine(
		Records?.BestScore, Records?.BestLength, Records?.RunsPlayed );
}
fpkreastudios.coilgarden / styles/form/_colorproperty.scss
Game game

.colorproperty
{
	align-items: center;
	position: relative;

	.colorsquare
	{
		width: 20px;
		height: 20px;
		border-radius: 4px;
		margin-right: 8px;
		position: absolute;
		left: 7px;
		z-index: 1;
		cursor: pointer;
	}

	> .textentry:not( .a.b.c )
	{
		padding-left: 36px;
	}
}
fpkreastudios.coilgarden / ui/controls/color/coloralphacontrol.cs.scss
Game game
ColorAlphaControl
{
	gap: 0.5rem;
	flex-grow: 1;
	pointer-events: all;
	background: linear-gradient( to right, black, white );
	border-radius: 4px;
	padding: 2px;
	height: 12px;
	position: relative;
	cursor: pointer;
	border: 1px solid #333;

	&:hover
	{
		border: 1px solid #08f;
	}

	&:active
	{
		border: 1px solid #fff;
	}

	.handle
	{
		top: -5px;
		bottom: -5px;
		aspect-ratio: 1;
		border-radius: 100px;
		border: 2px solid #444;
		position: absolute;
		background-color: white;
		box-shadow: 2px 2px 16px #000a;
		transform: translateX( -50% );
		pointer-events: none;
	}
}
fpkreastudios.coilgarden / Core/GameState.cs
Game game
namespace Coilgarden;

/// <summary>Where the app is.</summary>
public enum GameState
{
	/// <summary>
	/// The title screen. A fresh run is already set up underneath it and waiting for the
	/// player to ask for it - by clicking Play, or by pressing a direction key directly,
	/// which is taken as both "start" and the first turn so a keyboard player's first press
	/// is never swallowed by a menu they did not read.
	/// </summary>
	MainMenu,

	Playing,

	Paused,

	/// <summary>The run ended. Restarting from here is the only way out.</summary>
	GameOver
}
fpkreastudios.coilgarden / Feel/Ease.cs
Game game
namespace Coilgarden;

/// <summary>
/// The easing vocabulary, and all of it. Four curves, each with one job.
/// <para>
/// Keeping the set this small is the point rather than a limitation: a consistent easing
/// vocabulary is most of what separates "animated" from "polished". Anything that arrives
/// uses <see cref="OutCubic"/>, anything that should feel springy uses <see cref="OutBack"/>,
/// anything that leaves uses <see cref="InQuad"/>, and one-shot flashes use
/// <see cref="Pulse"/>.
/// </para>
/// <para>
/// Pure maths with no engine dependency, so the curves are covered by the headless suite -
/// which matters more than it sounds. An easing function with the wrong endpoints does not
/// look wrong, it looks like a <em>positioning</em> bug somewhere else entirely.
/// </para>
/// </summary>
public static class Ease
{
	/// <summary>Decelerates into its destination. The default for anything that arrives.</summary>
	public static float OutCubic( float t )
	{
		t = Clamp01( t );

		var inverted = 1f - t;

		return 1f - inverted * inverted * inverted;
	}

	/// <summary>Accelerates away. For anything leaving, shrinking or being consumed.</summary>
	public static float InQuad( float t )
	{
		t = Clamp01( t );

		return t * t;
	}

	/// <summary>
	/// Overshoots past 1 and settles back. This is what makes something feel like it has
	/// weight and springiness rather than being placed.
	/// <para>
	/// Returns values above 1 partway through, which is deliberate - callers must be scaling
	/// or offsetting, not writing into something that clamps.
	/// </para>
	/// </summary>
	public static float OutBack( float t, float overshoot = 1.70158f )
	{
		t = Clamp01( t );

		var inverted = t - 1f;

		return 1f + (overshoot + 1f) * inverted * inverted * inverted + overshoot * inverted * inverted;
	}

	/// <summary>
	/// Rises to 1 and falls back to 0 across 0..1, peaking early.
	/// <para>
	/// For one-shot punches - a squash, a flash, a camera kick - where the value has to end
	/// exactly where it started or the effect leaves a permanent offset behind. The early peak
	/// is what makes it read as an impact rather than a swell.
	/// </para>
	/// </summary>
	public static float Pulse( float t )
	{
		t = Clamp01( t );

		// Rises over the first quarter, decays over the rest.
		const float peak = 0.25f;

		if ( t <= peak ) return OutCubic( t / peak );

		return 1f - InQuad( (t - peak) / (1f - peak) );
	}

	/// <summary>
	/// Progress through a duration, clamped, and 1 when the duration is not positive.
	/// <para>
	/// Every animation here is "elapsed over duration", and a zero duration from a mistyped
	/// config value would otherwise divide by zero. Returning 1 means the animation reads as
	/// already finished, which is the harmless outcome.
	/// </para>
	/// </summary>
	public static float Progress( float elapsed, float duration ) =>
		duration <= 0f ? 1f : Clamp01( elapsed / duration );

	private static float Clamp01( float t ) => t < 0f ? 0f : t > 1f ? 1f : t;
}
fpkreastudios.coilgarden / Rendering/SceneLook.cs
Game game
namespace Coilgarden;

/// <summary>
/// Drives the scene's lighting and post-processing from <see cref="Palette"/> in code.
/// <para>
/// The look used to be authored on the scene's components. That was wrong for two reasons.
/// The first is architectural: the project's rule is that tuning values live in one file, and
/// having the entire visual identity spread across component properties in a JSON scene made
/// it the one part of the game that could not be read, reviewed or diffed. The second is
/// practical: the scene is only editable through the editor, play mode runs a <em>clone</em> of
/// it, and the views only build in <c>OnUpdate</c> - so every colour change cost a save, a play
/// restart and a round trip, and half of them silently edited the wrong copy.
/// </para>
/// <para>
/// Applied <b>once</b>, on enable, and never per frame. An earlier version wrote everything in
/// <c>OnPreRender</c> to get instant hot-reload feedback, and that rendered the entire frame
/// black - re-writing exposure and light state every frame fights whatever the renderer is
/// doing between frames. Play mode is restarted for every visual change anyway, so applying on
/// enable loses nothing.
/// </para>
/// <para>
/// It also deliberately does <b>not</b> touch <see cref="Tonemapping"/>. Exposure is left to
/// the values authored on the scene; driving it from here was part of what caused the black
/// frame, and it is the one setting where the engine's own adaptation needs to be left alone.
/// </para>
/// </summary>
public sealed class SceneLook : Component
{
	/// <summary>
	/// Shadow bias. Emphatically not zero: at zero every surface shadows itself and the whole
	/// scene renders black, which cost a full debugging cycle to find. It looks exactly like
	/// "the lights are not working".
	/// </summary>
	public const float ShadowBias = 0.08f;

	public const float ShadowHardness = 0.85f;

	/// <summary>Key light heading, as pitch/yaw. Comes from above and to the left of the viewer.</summary>
	public static readonly Angles KeyAngles = new( 22f, -20f, 0f );

	/// <summary>Fill light heading. Opposite side, shallower, so it fills without flattening.</summary>
	public static readonly Angles FillAngles = new( -24f, 30f, 0f );

	/// <summary>
	/// The vignette is off.
	/// <para>
	/// It banded visibly into concentric rings against the near-black backdrop at every
	/// intensity that was strong enough to be worth having, and at that point it was adding an
	/// artefact rather than atmosphere. The dark backdrop already frames the tray, which is all
	/// the vignette was there to do. Worth revisiting in the polish pass with a lighter
	/// backdrop to dither against.
	/// </para>
	/// </summary>
	public const bool EnableVignette = false;

	/// <summary>
	/// Lit surfaces come back noticeably duller than their albedo, so a little saturation is
	/// added back at the end rather than by pushing the palette - channels above 1 wrap.
	/// </summary>
	public const float Saturation = 1.24f;

	public const float Contrast = 1.04f;

	/// <summary>Lifts the whole image slightly; tonemapping lands the palette darker than authored.</summary>
	public const float Brightness = 1.56f;

	public const float SharpenScale = 0.18f;

	// ---------------------------------------------------------------- live tunables
	// Static rather than const so `cg_light` can drive them without a play restart. Lighting is
	// the one part of this game that cannot be judged by reasoning, and a restart per experiment
	// made iterating on it prohibitively slow.

	/// <summary>
	/// Multiplier on the key light. Above 1 the sand comes back closer to the pale cream it is
	/// authored as - a lit surface returns well below its albedo, and the floor was reading as a
	/// dull greige rather than as sand.
	/// </summary>
	public static float KeyScale = 1f;

	public static float FillScale = 1f;

	/// <summary>
	/// Multiplier on the ambient wash. Below 1 deepens shadows; the original value lit every
	/// surface from all sides hard enough that nothing cast a shadow worth seeing.
	/// </summary>
	public static float AmbientScale = 1f;

	/// <summary>
	/// Off, having been tried and made no visible difference.
	/// <para>
	/// Screen-space contact shadows are exactly the feature the missing shadow under each piece
	/// calls for, and enabling them changed nothing that could be seen at any lighting balance -
	/// so it is GPU cost bought for nothing. Left here as a switch because it is the first thing
	/// worth re-testing if the lighting is ever reworked.
	/// </para>
	/// </summary>
	public static bool UseContactShadows = false;

	/// <summary>
	/// Fixed exposure for the tonemapper. This is the only global brightness control that
	/// actually moves the image: the sand's albedo is already at the legal ceiling and a lit
	/// surface still comes back well under it, because the filmic curve compresses everything
	/// below white. Light intensity cannot reach past that; exposure can.
	/// </summary>
	public static float Exposure = 1f;

	/// <summary>
	/// A light colour at a given intensity.
	/// <para>
	/// Channels are allowed past 1 here, unlike anywhere else in this project. The wrap-above-1
	/// hazard recorded in the conventions is a property of the <em>tint</em> path on a
	/// <see cref="ModelRenderer"/>, which truncates through 8 bits per channel; a light's colour
	/// is consumed as a float and scaling it is the only way to actually add intensity rather
	/// than merely desaturate towards white.
	/// </para>
	/// </summary>
	private static Color Scaled( Color colour, float scale ) =>
		new( colour.r * scale, colour.g * scale, colour.b * scale, colour.a );

	[Property] public DirectionalLight KeyLight { get; set; }

	[Property] public DirectionalLight FillLight { get; set; }

	[Property] public AmbientLight Ambient { get; set; }

	[Property] public CameraComponent Camera { get; set; }

	/// <summary>Turn the whole grade off, to check the game still reads without it.</summary>
	[Property] public bool EnablePost { get; set; } = true;

	private Vignette vignette;
	private ColorAdjustments grade;
	private Sharpen sharpen;
	private Tonemapping tonemapping;

	/// <summary>
	/// The live look, so the lighting can be re-applied from a console command. Lighting is the
	/// one thing in this project that cannot be judged without looking at it, and a play restart
	/// per experiment makes iterating on it prohibitively slow.
	/// </summary>
	public static SceneLook Current { get; private set; }

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

	/// <summary>Re-applies lighting and grade. Safe to call at any time; never per frame.</summary>
	public void Apply()
	{
		ApplyLighting();
		ApplyPost();
	}

	protected override void OnEnabled()
	{
		Current = this;

		Camera ??= Components.Get<CameraComponent>();

		var lights = Scene.GetAllComponents<DirectionalLight>().ToList();

		// Identified by object name, not by which one currently casts shadows. Picking the key
		// by `l.Shadows` was circular: this component is what turns shadows on, so before it had
		// ever run the test could match nothing and silently hand the key's warm colour and
		// angle to the fill light - swapping the two whenever the scene was in the wrong state.
		KeyLight ??= Find( lights, "Key" ) ?? lights.FirstOrDefault();
		FillLight ??= Find( lights, "Fill" ) ?? lights.FirstOrDefault( l => l != KeyLight );
		Ambient ??= Scene.GetAllComponents<AmbientLight>().FirstOrDefault();

		vignette = Components.Get<Vignette>();
		grade = Components.Get<ColorAdjustments>();
		sharpen = Components.Get<Sharpen>();
		tonemapping = Components.Get<Tonemapping>();

		ApplyLighting();
		ApplyPost();
	}

	/// <summary>
	/// Retunes the lighting live: <c>cg_light &lt;key&gt; &lt;ambient&gt; &lt;contactShadows&gt;</c>.
	/// <para>
	/// Development only. It exists because lighting cannot be judged without looking at it, and
	/// applying on enable alone means a play restart for every experiment.
	/// </para>
	/// </summary>
	[ConCmd( "cg_light" )]
	public static void Light( float key, float ambient, float exposure )
	{
		if ( Current is null )
		{
			Log.Info( "cg_light: no SceneLook in the scene. Is play mode started?" );
			return;
		}

		KeyScale = key.Clamp( 0.1f, 6f );
		AmbientScale = ambient.Clamp( 0f, 3f );
		Exposure = exposure.Clamp( 0.1f, 6f );

		Current.Apply();

		Log.Info( $"cg_light: key={KeyScale:F2} ambient={AmbientScale:F2} exposure={Exposure:F2} " +
			$"contactShadows={UseContactShadows}" );
	}

	private static DirectionalLight Find( List<DirectionalLight> lights, string nameContains ) =>
		lights.FirstOrDefault( l =>
			l.GameObject.IsValid() &&
			l.GameObject.Name.Contains( nameContains, StringComparison.OrdinalIgnoreCase ) );

	private void ApplyLighting()
	{
		if ( Camera.IsValid() ) Camera.BackgroundColor = Palette.Backdrop;

		if ( KeyLight.IsValid() )
		{
			KeyLight.WorldRotation = KeyAngles;
			KeyLight.LightColor = Scaled( Palette.KeyLight, KeyScale );

			// This is a *second* ambient term on top of the AmbientLight component. Running both
			// was suspected of being why nothing casts a visible shadow, but removing it was
			// tried and made the image plainly worse - the tray went dull and the whole frame
			// shifted red, with the shadows no more visible than before. It stays.
			KeyLight.SkyColor = Scaled( Palette.Ambient, AmbientScale );

			KeyLight.Shadows = true;
			KeyLight.ShadowBias = ShadowBias;
			KeyLight.ShadowHardness = ShadowHardness;

			// The cascaded shadow map is far too coarse for a sphere resting on a flat tray;
			// contact shadows are the screen-space pass that catches exactly that detail, and
			// it is what puts the pieces *on* the sand rather than floating above it.
			KeyLight.ContactShadows = UseContactShadows;
		}

		if ( FillLight.IsValid() )
		{
			FillLight.WorldRotation = FillAngles;
			FillLight.LightColor = Scaled( Palette.FillLight, FillScale );

			// Black, so the fill adds direction without also adding another ambient term.
			FillLight.SkyColor = Color.Black;

			// One shadow-casting light. A second set of shadows on a top-down board reads as
			// dirt rather than as depth. Contact shadows off here for the same reason.
			FillLight.Shadows = false;
			FillLight.ContactShadows = false;
		}

		if ( Ambient.IsValid() ) Ambient.Color = Scaled( Palette.Ambient, AmbientScale );

		// Framing is driven from here too, so GameConfig is the single source of truth for it.
		// A value authored on the scene component otherwise wins over the code default, which
		// is how a padding change can appear to do nothing at all.
		var framing = Components.Get<ArenaCamera>();

		if ( framing.IsValid() )
		{
			framing.Padding = GameConfig.CameraPadding;
			framing.CellSize = GameConfig.CellSize;
			framing.Distance = GameConfig.CameraDistance;
		}
	}

	private void ApplyPost()
	{
		if ( vignette.IsValid() ) vignette.Enabled = EnablePost && EnableVignette;

		// Pinned rather than adapting. Auto exposure on a board whose contents change brightness
		// as the snake grows would make the tray subtly breathe, and a fixed value is the one
		// control that can actually lift the sand to the cream it is authored as. Written once
		// on enable, never per frame - driving exposure every frame renders the whole scene
		// black.
		if ( tonemapping.IsValid() )
		{
			tonemapping.AutoExposureEnabled = false;
			tonemapping.MinimumExposure = Exposure;
			tonemapping.MaximumExposure = Exposure;
			tonemapping.ExposureCompensation = 0f;
		}

		if ( grade.IsValid() )
		{
			grade.Enabled = EnablePost;
			grade.Blend = 1f;
			grade.Saturation = Saturation;
			grade.Contrast = Contrast;
			grade.Brightness = Brightness;
			grade.HueRotate = 0f;
		}

		if ( sharpen.IsValid() )
		{
			sharpen.Enabled = EnablePost;
			sharpen.Scale = SharpenScale;
		}
	}
}
fpkreastudios.coilgarden / UI/GameOverPanel.razor
Game game
@using Sandbox;
@using Sandbox.UI;
@namespace Coilgarden
@inherits Panel
@attribute [StyleSheet( "/UI/GameUi.razor.scss" )]

<root class="scrim">
	<div class="@CardClass">
		<div class="heading">@EndTitle</div>
		<div class="cause">@EndCause</div>

		<div class="tally">
			<div class="tally-item">
				<span class="tally-label">SCORE</span>
				<span class="tally-value">@ScoreText</span>
			</div>
			<div class="tally-item">
				<span class="tally-label">LENGTH</span>
				<span class="tally-value">@LengthText</span>
			</div>
			<div class="tally-item">
				<span class="tally-label">BEST</span>
				<span class="tally-value">@BestText</span>
			</div>
		</div>

		<div class="menu">
			<div onmouseover=@UiSound.Hover class="btn primary" onclick=@Restart>Play Again</div>
			<div onmouseover=@UiSound.Hover class="btn" onclick=@Menu>Main Menu</div>
		</div>
	</div>
</root>

@code
{
	[Parameter] public GameSession Session { get; set; }

	private SnakeGame Run => Session?.Run;

	/// <summary>A record-beating run gets an apple-red edge, restating the good news in a
	/// second channel so it lands even if the words are skimmed.</summary>
	private string CardClass => Session?.IsNewBest == true ? "card record" : "card";

	private bool Won => Run?.EndedBy == StepOutcome.FilledArena;

	private string EndTitle => Session?.IsNewBest == true ? "New best" : Won ? "Garden full" : "Game over";

	private string EndCause => Run?.EndedBy switch
	{
		StepOutcome.HitWall => "You met the edge of the tray.",
		StepOutcome.HitSelf => "You crossed your own tail.",
		StepOutcome.FilledArena => "Not a single cell left. Remarkable.",
		_ => ""
	};

	private string ScoreText => (Run?.Score ?? 0).ToString();

	private string LengthText => (Run?.Snake.Length ?? 0).ToString();

	private string BestText => (Session?.Records?.BestScore ?? 0).ToString();

	private void Restart()
	{
		UiSound.Click();
		Session?.Restart();
	}

	private void Menu()
	{
		UiSound.Click();
		Session?.ReturnToMenu();
	}

	protected override int BuildHash() => System.HashCode.Combine(
		Run?.EndedBy, Session?.IsNewBest, Run?.Score, Run?.Snake?.Length, Session?.Records?.BestScore );
}
fpkreastudios.coilgarden / Rendering/AppleView.cs
Game game
namespace Coilgarden;

/// <summary>
/// Draws the apple, and animates the moment the whole game is built around.
/// <para>
/// The apple gets three animations and each has a job:
/// </para>
/// <list type="bullet">
/// <item><b>Spawn</b> - scales in with a slight overshoot. An apple that simply appears is the
/// difference between a world and a spreadsheet, and the overshoot draws the eye to the new
/// target without needing a flash or an arrow.</item>
/// <item><b>Idle</b> - a slow bob and an even slower spin. It says the apple is a thing rather
/// than a marker, and a moving object is found in peripheral vision far faster than a still
/// one.</item>
/// <item><b>Eaten</b> - swells and vanishes in about an eighth of a second. This is the reward,
/// so it has to be immediate; anything longer and the player is waiting for their prize
/// instead of already going after the next one.</item>
/// </list>
/// <para>
/// Presentation only: it reads where the apple is and never decides.
/// </para>
/// </summary>
public sealed class AppleView : Component
{
	[Property] public GameSession Session { get; set; }

	[Property] public float CellSize { get; set; } = GameConfig.CellSize;

	/// <summary>Scales the bob, spin, spawn and pop. 0 leaves a static apple.</summary>
	[Property, Range( 0f, 2f )] public float Animation { get; set; } = 1f;

	private GameObject visualRoot;
	private GameObject apple;
	private Primitives parts;
	private ArenaSpace space;

	/// <summary>Where the apple is being drawn, which lags the simulation during the eat pop.</summary>
	private GridPos drawnCell;
	private bool hasDrawnCell;

	private float spawnAge = 99f;
	private float eatAge = -1f;

	/// <summary>The cell the pop is playing at, kept because the apple has already moved on.</summary>
	private GridPos poppingCell;

	private int builtWidth;
	private int builtHeight;
	private float builtCellSize;

	protected override void OnEnabled()
	{
		Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();
	}

	protected override void OnDisabled() => TearDown();

	protected override void OnUpdate()
	{
		var run = Session?.Run;
		if ( run is null ) return;

		EnsureBuilt( run.Arena );

		if ( !apple.IsValid() ) return;

		// Zero while paused, so the apple stops bobbing and spinning with everything else.
		var delta = Session.FeelDelta;

		spawnAge += delta;
		if ( eatAge >= 0f ) eatAge += delta;

		Observe( run );
		Draw( run );
	}

	/// <summary>
	/// Notices a new apple, and notices the old one being eaten.
	/// <para>
	/// The eaten apple's cell has to be remembered, because by the time this runs the simulation
	/// has already put the next apple somewhere else - the pop has to play where the fruit
	/// actually was, not where the next one will be.
	/// </para>
	/// </summary>
	private void Observe( SnakeGame run )
	{
		if ( !run.Apple.HasValue )
		{
			// The arena is full, which is a win. Nothing to draw and nothing to animate.
			hasDrawnCell = false;
			return;
		}

		var cell = run.Apple.Value;

		if ( hasDrawnCell && cell == drawnCell ) return;

		// A different cell means the previous one was eaten - unless nothing was being drawn yet,
		// which is a fresh run rather than a meal.
		if ( hasDrawnCell && Session.LastStep.Grew )
		{
			poppingCell = drawnCell;
			eatAge = 0f;
		}

		drawnCell = cell;
		hasDrawnCell = true;
		spawnAge = 0f;
	}

	private void Draw( SnakeGame run )
	{
		if ( !hasDrawnCell )
		{
			apple.Enabled = false;
			return;
		}

		// While the pop is playing, the object is still showing the apple that was eaten. The new
		// one waits its turn, which keeps the two events from overlapping into visual mush.
		var popping = eatAge >= 0f && eatAge < GameConfig.AppleEatDuration;

		var cell = popping ? poppingCell : drawnCell;
		var scale = popping ? PopScale() : SpawnScale();

		var radius = GameConfig.AppleDiameter * 0.5f;
		var bob = popping ? 0f : Bob();

		apple.LocalPosition = space.Above( space.Cell( cell ), radius + bob );
		apple.LocalScale = new Vector3( scale, scale, scale );

		// Spun about the view axis, so from a top-down camera the stem and leaf sweep around the
		// fruit rather than tumbling out of the plane.
		apple.LocalRotation = Rotation.FromAxis( Vector3.Forward, Spin() );
		apple.Enabled = scale > 0.01f;
	}

	/// <summary>
	/// Swells, then collapses to nothing. The swell is what makes it read as bursting rather
	/// than blinking out, and the collapse accelerates so the last thing the eye registers is
	/// the apple being gone rather than a small apple lingering.
	/// </summary>
	private float PopScale()
	{
		var t = Ease.Progress( eatAge, GameConfig.AppleEatDuration );
		var swell = 1f + GameConfig.AppleEatSwell * Animation;

		const float rise = 0.35f;

		if ( t < rise ) return MathX.Lerp( 1f, swell, Ease.OutCubic( t / rise ) );

		return MathX.Lerp( swell, 0f, Ease.InQuad( (t - rise) / (1f - rise) ) );
	}

	private float SpawnScale()
	{
		var t = Ease.Progress( spawnAge, GameConfig.AppleSpawnDuration );

		if ( t >= 1f || Animation <= 0f ) return 1f;

		return MathX.Lerp( 0f, 1f, Ease.OutBack( t ) );
	}

	private float Bob() =>
		MathF.Sin( Session.FeelTime * GameConfig.AppleBobSpeed ) * GameConfig.AppleBobAmount * Animation;

	private float Spin() => Animation <= 0f ? 0f : Session.FeelTime * GameConfig.AppleSpinSpeed * Animation;

	private void EnsureBuilt( Arena arena )
	{
		var built = visualRoot.IsValid()
			&& builtWidth == arena.Width
			&& builtHeight == arena.Height
			&& builtCellSize.AlmostEqual( CellSize )
			&& apple.IsValid();

		if ( built ) return;

		TearDown();

		builtWidth = arena.Width;
		builtHeight = arena.Height;
		builtCellSize = CellSize;

		visualRoot = new GameObject( GameObject, true, "Apple" );
		visualRoot.Flags |= GameObjectFlags.NotSaved;

		space = new ArenaSpace( arena.Width, arena.Height, CellSize );

		// The parts hang off one object so the whole apple can be moved, scaled, spun and popped
		// as a single thing.
		apple = new GameObject( visualRoot, true, "Apple Body" );
		apple.Flags |= GameObjectFlags.NotSaved;

		parts = new Primitives( apple );

		var diameter = GameConfig.AppleDiameter * CellSize;

		parts.Sphere( "Flesh", Vector3.Zero, diameter, Palette.AppleFlesh );

		var stemLength = GameConfig.AppleStemLength * CellSize;

		// The stem leans towards the top of the screen (+Z) rather than straight out at the
		// camera (-X). Pointing it along the view axis hid it inside the apple's own silhouette,
		// where it read as a stray dot in the middle of the fruit instead of a stem - from a
		// top-down camera, anything that matters has to have screen-space extent.
		parts.Box( "Stem",
			new Vector3( -diameter * 0.30f, 0f, diameter * 0.40f ),
			new Vector3( diameter * 0.11f, diameter * 0.11f, stemLength ),
			Palette.AppleStem );

		parts.Sphere( "Leaf",
			new Vector3( -diameter * 0.34f, -diameter * 0.26f, diameter * 0.50f ),
			diameter * 0.26f,
			Palette.AppleLeaf );

		apple.Enabled = false;
		hasDrawnCell = false;
		spawnAge = 99f;
		eatAge = -1f;
	}

	private void TearDown()
	{
		visualRoot?.Destroy();
		visualRoot = null;
		apple = null;
		parts = null;
		hasDrawnCell = false;
		builtWidth = 0;
		builtHeight = 0;
		builtCellSize = 0f;
	}
}
fpkreastudios.coilgarden / Rendering/ArenaCamera.cs
Game game
namespace Coilgarden;

/// <summary>
/// Frames the whole arena, always. Fixed, orthographic, no scrolling.
/// <para>
/// Orthographic is not a stylistic choice here: it keeps every cell the same size on
/// screen, so judging a gap near the edge of the arena is exactly as reliable as judging
/// one in the middle. In a game about threading a gap, a perspective arena would quietly
/// make the corners harder than the centre.
/// </para>
/// <para>
/// The framing is recomputed every frame from the live aspect ratio, so a window resize is
/// handled by construction rather than by an event.
/// </para>
/// </summary>
public sealed class ArenaCamera : Component
{
	[Property] public GameSession Session { get; set; }

	[Property] public CameraComponent Camera { get; set; }

	/// <summary>How far back the camera sits. Orthographic, so this only has to be clear of the geometry.</summary>
	[Property] public float Distance { get; set; } = GameConfig.CameraDistance;

	/// <summary>
	/// Multiplier on the arena size, so the walls are not flush against the screen edge and
	/// the HUD has somewhere to sit.
	/// </summary>
	[Property, Range( 1f, 2f )] public float Padding { get; set; } = GameConfig.CameraPadding;

	[Property] public float CellSize { get; set; } = GameConfig.CellSize;

	protected override void OnAwake()
	{
		Camera ??= Components.GetOrCreate<CameraComponent>();
	}

	protected override void OnEnabled()
	{
		Session ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();
	}

	/// <summary>Zoom punch, 0..1 of its duration. Decays every frame.</summary>
	private float punchAmount;
	private float punchAge;

	private float shakeAmount;
	private float shakeAge;

	/// <summary>
	/// Asks for a brief zoom-in. Used for eating, because the board coming momentarily closer
	/// reads as a reward, where a shake would read as damage however small it was.
	/// </summary>
	public void Punch( float amount )
	{
		if ( amount <= 0f ) return;

		// Strongest wins. Two apples in quick succession is one punch, not a double-length one.
		if ( amount < punchAmount && punchAge < GameConfig.CameraPunchDuration ) return;

		punchAmount = MathF.Min( amount, MaxPunch );
		punchAge = 0f;
	}

	/// <summary>Asks for a positional kick. Death only.</summary>
	public void Shake( float amount )
	{
		if ( amount <= 0f ) return;
		if ( amount < shakeAmount && shakeAge < GameConfig.CameraShakeDuration ) return;

		shakeAmount = MathF.Min( amount, MaxShake );
		shakeAge = 0f;
	}

	/// <summary>
	/// Hard ceilings, so no call site can produce something absurd whatever it asks for. The
	/// clamp lives here rather than at the caller because there is no version of this game in
	/// which a bigger kick than this is correct.
	/// </summary>
	private const float MaxPunch = 0.05f;

	private const float MaxShake = 14f;

	/// <summary>
	/// Framing is applied in <see cref="OnPreRender"/> rather than in update, so it is
	/// always the last word before the frame is drawn and can never be a frame stale.
	/// </summary>
	protected override void OnPreRender()
	{
		if ( !Camera.IsValid() ) return;

		var arena = Session?.Run?.Arena;

		// Falling back to the configured size keeps the editor's edit-mode view framed
		// correctly, where no run exists yet.
		var columns = arena?.Width ?? GameConfig.GridWidth;
		var rows = arena?.Height ?? GameConfig.GridHeight;

		var needVertical = rows * CellSize * Padding;
		var needHorizontal = columns * CellSize * Padding;

		var aspect = MathF.Max( Screen.Aspect, 0.2f );
		var framed = MathF.Max( needVertical, needHorizontal / aspect );

		Camera.Orthographic = true;

		// A punch shrinks the framed height, which zooms in. Pulse ends exactly at zero, so the
		// framing always returns to precisely where it was rather than drifting.
		Camera.OrthographicHeight = framed * (1f - CurrentPunch());

		// Looking down +X with no rotation of its own, which is the orientation the views lay
		// the arena out for.
		WorldPosition = new Vector3( -Distance, 0f, 0f ) + CurrentShake();
		WorldRotation = Rotation.Identity;
	}

	private float CurrentPunch()
	{
		if ( punchAmount <= 0f ) return 0f;

		punchAge += Time.Delta;

		var t = Ease.Progress( punchAge, GameConfig.CameraPunchDuration );

		if ( t >= 1f )
		{
			punchAmount = 0f;
			return 0f;
		}

		return punchAmount * Ease.Pulse( t );
	}

	private Vector3 CurrentShake()
	{
		if ( shakeAmount <= 0f ) return Vector3.Zero;

		shakeAge += Time.Delta;

		var t = Ease.Progress( shakeAge, GameConfig.CameraShakeDuration );

		if ( t >= 1f )
		{
			shakeAmount = 0f;
			return Vector3.Zero;
		}

		var strength = shakeAmount * (1f - Ease.OutCubic( t ));

		// Two mismatched frequencies read as random over the fraction of a second a shake
		// lasts, with no noise source and no per-frame allocation. Only in the tray plane -
		// shaking along the view axis would change the apparent scale of everything.
		return new Vector3(
			0f,
			MathF.Sin( Time.Now * 97f ) * strength,
			MathF.Sin( Time.Now * 61f + 1.7f ) * strength );
	}
}
fpkreastudios.coilgarden / ui/controlsheet/controlsheetgroupheader.cs.scss
Game game
ControlSheetGroupHeader
{
	font-size: 1.33rem;
	color: red;
	gap: 2px;
	align-items: center;

	&.hidden
	{
		display: none;
	}

	> .title
	{
		font-weight: 600;
	}

	&.has-toggle
	{
		cursor: pointer;
		opacity: 0.8;

		&:before
		{
			content: ' ';
			width: 22px;
			height: 22px;
			background-color: #000a;
			align-items: center;
			justify-content: center;
			text-align: center;
			border-radius: 5px;
			border: 1px solid #555;
		}

		&:hover
		{
			opacity: 1;

			&:before
			{
				border-color: #888;
			}
		}

		&.checked
		{
			> .title
			{
				color: white;
			}

			&:before
			{
				content: '✓';
				font-weight: bold;
				color: #08f;
				border-color: #08f;
			}
		}
	}
}
fpkreastudios.coilgarden / ui/components/packagecard.razor.scss
Game game
@import "/styles/_theme.scss";
$background-size: 8px;

PackageCard
{
	flex-shrink: 0;

	&:hover
	{
		sound-in: "ui.button.over";
	}

	&:active
	{
		sound-in: "ui.button.press";
	}

	flex-direction: column;
	position: relative;
	background-color: rgba( $default-950, 0 );
	border-radius: $rounding-default;
	transition: all 150ms ease;
	cursor: pointer;
	z-index: 0;
	height: 200px;

	.image
	{
		flex-grow: 1;
		flex-shrink: 0;
		transition: all 150ms ease;
		border: 1px solid rgba( white, 0.025 );
		aspect-ratio: 16 / 9;
		background-position: center;
		background-size: cover;
		border-radius: $rounding-small;
		position: relative;
	}

	column
	{
		padding: 8px 2px; // Optically aligned
	}

	

	&.list
	{
		flex-grow: 1;
		height: 64px;
		flex-direction: row;
		gap: 12px;
		padding: 4px;

		.inner column
		{
			flex-grow: 1;
		}

		.image
		{
			height: 100%;
			aspect-ratio: 1;
			flex-grow: 0;
			flex-shrink: 0;
		}

		column
		{
			gap: 3px;
			justify-content: center;
		}

		.package-title
		{
			flex-shrink: 0;
			max-width: 500px;
		}

		.package-users
		{
			top: 1px;
			right: 1px;
		}

		&:hover
		{
			background-color: $default-800;
			transform: none;

			&::after
			{
				display: none;
			}
		}
	}

	&.wide
	{
		height: 250px;

		.package-title
		{
			max-width: 300px;
		}
	}

	&.small
	{
		height: 200px;
		aspect-ratio: 3/4;

		.image
		{
			aspect-ratio: 1;
		}

		.package-title
		{
			max-width: 140px;
		}
	}

	&.tall
	{
		height: 400px;

		.image
		{
			aspect-ratio: 9/16;
		}

		.package-title
		{
			max-width: 200px;
		}
	}

	.package-title
	{
		text-overflow: ellipsis;
		max-height: 24px;
		flex-shrink: 1;
		font-size: 14px;
	}

	.package-users
	{
		position: absolute;
		bottom: 4px;
		right: 4px;
		background-color: rgba( 10, 40, 10, 0.95 );
		padding: 3px 5px;
		border-radius: 2px;
		justify-content: center;
		align-items: center;
		gap: 3px;
		font-size: 11px;
		color: #def;
		border: 1px solid #252;
		color: #2f3;

		&:before
		{
			content: '●';
			font-size: 0.5rem;
		}
	}
	// Hover effect (not for list)
	&:not(.list)
	{
		&::after
		{
			content: "";
			position: absolute;
			top: 0;
			left: 0;
			bottom: 0;
			right: 0;
			background-color: rgba( $default-950, 0 );
			transition: all 150ms ease;
			z-index: -10;
			border-radius: $rounding-large;
			pointer-events: none;
		}

		&:hover
		{
			transform: scale( 1.05 );

			&::after
			{
				background-color: $default-800;
				top: -$background-size;
				left: -$background-size;
				right: -$background-size;
				bottom: -$background-size;
				box-shadow: 0 0 25px rgba( black, 0.3 );
			}

			z-index: 100;
			background-color: $default-900;
		}
	}
}


.package-card.list packageflairbar
{
	display: none;
}

.package-card.list .package-users
{
	display: none;
}
fpkreastudios.coilgarden / styles/form.scss
Game game
$form-control-height: 28px !default;

@import "form/_checkbox.scss";
@import "form/_switch.scss";
@import "form/_dropdown.scss";
@import "form/_coloreditor.scss";
@import "form/_colorproperty.scss";

.form
{
	flex-direction: column;
	align-items: stretch;
	justify-content: flex-start;
	overflow: scroll;
}

.field-group
{
	flex-direction: column;
	flex-shrink: 0;
}

.field-header
{
	flex-shrink: 0;
}

.field
{
	color: white;
	font-size: 14px;
	flex-shrink: 0;
	flex-grow: 0;

	> .label
	{
		flex-grow: 0;
		flex-shrink: 0;
		font-weight: 600;
		opacity: 0.4;
		width: 20%;
		font-size: 13px;
	}

	> .control
	{
		flex-shrink: 0;
		flex-grow: 1;
		flex-direction: column;
	}
}

.is-vertical > .field, .field.is-vertical
{
	flex-direction: column;

	> .label
	{
		width: auto;
		height: auto;
	}
}
Debug: View Raw JSON Response
{
    "TotalCount": 75,
    "Files": [
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "styles/form/_switch.scss",
            "FileName": "_switch.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "\r\n$primary: red !default;\r\n$primary-alt: white !default;\r\n\r\n$switch-padding: 6px !default;\r\n\r\n.checkbox.switch\r\n{\r\n\tcursor: pointer;\r\n\r\n\t> .checkmark\r\n\t{\r\n\t\tfont-size: 22px;\r\n\t\tborder: 0px solid $primary;\r\n\t\tborder-radius: 100px;\r\n\t\ttext-align: center;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tcolor: $primary-alt;\r\n\t\tpadding: $switch-padding;\r\n\t\tpadding-right: 32px;\r\n\t\tpadding-left: $switch-padding;\r\n\t\ttransition: all 0.3s ease;\r\n\t\tbackground-color: rgba( $primary, 0.1 );\r\n\r\n\t\t> .handle\r\n\t\t{\r\n\t\t\tbackground-color: $primary-alt;\r\n\t\t\twidth: 20px;\r\n\t\t\theight: 20px;\r\n\t\t\tborder-radius: 100px;\r\n\t\t\tbox-shadow: 2px 2px 12px black;\r\n\t\t}\r\n\t}\r\n\r\n\t&.checked\r\n\t{\r\n\t\t> .checkmark\r\n\t\t{\r\n\t\t\tbackground-color: $primary;\r\n\t\t\tpadding-left: 32px;\r\n\t\t\tpadding-right: $switch-padding;\r\n\t\t}\r\n\t}\r\n\r\n\t&:active\r\n\t{\r\n\t\ttransform: scale( 0.9 );\r\n\t\ttransform-origin: 20px 50%;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "ui/_theme.scss",
            "FileName": "_theme.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "// The shared visual language for Coilgarden's UI.\n//\n// The UI is not styled independently of the game. Every colour below is the same colour as\n// something in the world (see Code/Core/Palette.cs) - the panels are the tray's terracotta,\n// the text is the sand, the accent is the apple. That is what makes the interface feel like\n// it belongs to the game rather than sitting on top of it.\n//\n// Three rules drive every value here.\n//\n// 1. Rank by consequence. Score is the biggest thing on screen because score is the point.\n//    Best is a third of its size. Labels are tiny - a player reads the word \"SCORE\" once and\n//    never again, so it exists only to explain the number the first time.\n//\n// 2. Nothing over the playfield during play. The arena has to stay readable, so the HUD sits\n//    above the tray and only a finished or paused run is allowed to cover it.\n//\n// 3. One rhythm. Spacing is multiples of $u, radii come from one pair of values, and there is\n//    a single easing curve. Consistency is most of what \"polished\" means.\n\n// ---------------------------------------------------------------- palette\n// Mirrors Code/Core/Palette.cs. Change one, change the other.\n\n$backdrop:      #1b171d;\n$table:         #2a242b;\n$tray:          #965f47;\n$tray-light:    #b47b5c;\n$tray-dark:     #69402e;\n\n$sand:          #c7c4a9;\n$sand-dim:      #bdb99d;\n\n$snake:         #2e6f43;\n$snake-light:   #4a9252;\n$apple:         #e03d31;\n$leaf:          #6fa145;\n\n// Text sits on dark panels, so it is the sand tone rather than pure white - white on warm\n// brown reads as clinical, and nothing in this game should.\n$text:          #f2ede2;\n$text-soft:     rgba(242, 237, 226, 0.66);\n$text-faint:    rgba(242, 237, 226, 0.34);\n\n// Panels are the backdrop hue, near-opaque so a card reads as the room dimming without the\n// playfield showing through what sits on it. 0.90 was not enough: the sand is the brightest\n// thing in the game, and the checker was visible straight through the buttons.\n$panel:         rgba(27, 23, 29, 0.985);\n$hairline:      rgba(242, 237, 226, 0.10);\n\n// Controls sit *on* a card, so they tint it rather than being transparent themselves - the\n// card behind them is already opaque, which is what keeps the world out of them.\n$plate:         rgba(56, 50, 58, 1);\n$plate-hover:   rgba(74, 66, 76, 1);\n\n// ---------------------------------------------------------------- rhythm\n\n// The spacing unit, and the one rule about it: **never write `$u * n`**. s&box's SCSS does not\n// evaluate arithmetic in lengths and drops the whole declaration - with a warning for `gap` and\n// in complete silence for margins and padding. The entire spacing scale of this interface was\n// resolving to nothing for exactly that reason, which is why every space below is written as a\n// literal multiple of 4. The unit is kept as documentation of the rhythm, not as a calculator.\n$u: 4px;\n\n$radius: 14px;\n$radius-lg: 26px;\n\n// One curve for everything. A settle with a touch of overshoot reads as soft rather than\n// mechanical, which is the whole target for this game's feel.\n$ease: cubic-bezier(0.22, 1, 0.36, 1);\n\n// ---------------------------------------------------------------- type\n\n// Poppins is a geometric, round-shouldered face that ships with s&box. It is doing real work\n// here: the roundness is the same idea as the sphere-based snake.\n@mixin face {\n\tfont-family: \"Poppins\", \"Roboto\", sans-serif;\n\tfont-weight: 500;\n}\n\n@mixin label {\n\t@include face;\n\tfont-size: 13px;\n\tfont-weight: 600;\n\tletter-spacing: 3px;\n\ttext-transform: uppercase;\n\tcolor: $text-faint;\n}\n\n@mixin numeral {\n\t@include face;\n\tfont-weight: 700;\n\n\t// Numbers are tabular so a score counting up does not shift its own layout. A number\n\t// that jitters while it changes is the most avoidable kind of unpolished.\n\tfont-family: \"Poppins\", \"Roboto\", sans-serif;\n}\n\n@mixin card {\n\tbackground-color: $panel;\n\tborder-radius: $radius-lg;\n\tborder: 1px solid $hairline;\n\tflex-direction: column;\n\talign-items: center;\n\n\t// Each side written out rather than a `padding: A B` shorthand. s&box's SCSS applies the\n\t// shorthand only partly - the card came out with a correct top and no bottom, so the last\n\t// button on every screen sat flush against the rounded corner.\n\tpadding-top: 48px;\n\tpadding-bottom: 48px;\n\tpadding-left: 56px;\n\tpadding-right: 56px;\n\n\t// Without this the centring scrim compresses the card to less than its content.\n\tflex-shrink: 0;\n}\n\n// ---------------------------------------------------------------- screen chrome\n// Shared by every full-screen state - the main menu, pause, game over and settings all sit\n// in a scrim over the arena and rise in on the same card. One treatment for \"a screen is up\"\n// is what makes the four of them read as one system rather than four separate designs.\n\n@keyframes scrim-in {\n\tfrom { opacity: 0; }\n\tto { opacity: 1; }\n}\n\n@keyframes card-in {\n\t0% {\n\t\topacity: 0;\n\t\ttransform: translateY(18px) scale(0.94);\n\t}\n\n\t100% {\n\t\topacity: 1;\n\t\ttransform: translateY(0px) scale(1);\n\t}\n}\n\n.scrim {\n\tposition: absolute;\n\twidth: 100%;\n\theight: 100%;\n\talign-items: center;\n\tjustify-content: center;\n\tpointer-events: all;\n\n\t// Warm rather than neutral black, so a menu or a pause reads as the lights dimming in\n\t// the same room rather than a grey sheet dropped over the game.\n\tbackground-color: rgba(27, 23, 29, 0.62);\n\n\t// Fades up rather than cutting in. A hard cut to a dimmed screen reads as a glitch;\n\t// over a sixth of a second it reads as the room dimming.\n\tanimation: scrim-in 0.18s $ease both;\n}\n\n.card {\n\t@include card;\n\tmin-width: 460px;\n\tanimation: card-in 0.26s $ease both;\n\n\t// A record-beating run gets an apple-red edge. Restating the good news in a second\n\t// channel, so it lands even if the words are skimmed.\n\t&.record {\n\t\tborder: 2px solid $apple;\n\t}\n\n\t.wordmark {\n\t\t@include face;\n\t\tfont-size: 62px;\n\t\tfont-weight: 700;\n\t\tletter-spacing: -1px;\n\t\tcolor: $snake-light;\n\t}\n\n\t.tagline {\n\t\tfont-size: 19px;\n\t\tcolor: $text-soft;\n\t\tmargin-top: 8px;\n\t}\n\n\t.heading {\n\t\t@include face;\n\t\tfont-size: 46px;\n\t\tfont-weight: 700;\n\t\tletter-spacing: -0.5px;\n\t\tcolor: $text;\n\t}\n\n\t.cause {\n\t\tfont-size: 19px;\n\t\tcolor: $text-soft;\n\t\tmargin-top: 8px;\n\t\ttext-align: center;\n\t}\n\n\t.tally {\n\t\tflex-direction: row;\n\t\tmargin-top: 28px;\n\t\tgap: 40px;\n\n\t\t.tally-item {\n\t\t\tflex-direction: column;\n\t\t\talign-items: center;\n\n\t\t\t.tally-label { @include label; }\n\n\t\t\t.tally-value {\n\t\t\t\t@include numeral;\n\t\t\t\tfont-size: 32px;\n\t\t\t\tcolor: $text;\n\t\t\t}\n\t\t}\n\t}\n\n\t.prompt {\n\t\t@include label;\n\t\tfont-size: 14px;\n\t\tletter-spacing: 2px;\n\t\tcolor: $text-faint;\n\t\tmargin-top: 24px;\n\t}\n}\n\n// ---------------------------------------------------------------- buttons\n// One control, three intents. Ghost is the default - a low-key rectangle that only asserts\n// itself on hover - primary is the one action on a screen the player most likely wants, and\n// danger is reserved for anything that throws a run away.\n\n.menu {\n\tflex-direction: column;\n\talign-items: stretch;\n\twidth: 320px;\n\tmargin-top: 40px;\n\tgap: 12px;\n}\n\n.menu-row {\n\tflex-direction: row;\n\talign-items: stretch;\n\twidth: 320px;\n\n\t// Same standoff as a stacked menu, so a screen ending in a row of buttons has the same\n\t// rhythm as one ending in a column of them.\n\tmargin-top: 40px;\n\tgap: 12px;\n\n\t.btn { flex-grow: 1; }\n}\n\n.btn {\n\t@include face;\n\n\t// Sides written out - see the note on the card mixin about the padding shorthand.\n\tpadding-top: 14px;\n\tpadding-bottom: 14px;\n\tpadding-left: 24px;\n\tpadding-right: 24px;\n\n\tborder-radius: $radius;\n\tborder: 1px solid $hairline;\n\tbackground-color: $plate;\n\tcolor: $text;\n\tfont-size: 17px;\n\tfont-weight: 600;\n\ttext-align: center;\n\tjustify-content: center;\n\talign-items: center;\n\tpointer-events: all;\n\tcursor: pointer;\n\n\ttransition: background-color 0.12s $ease, border-color 0.12s $ease,\n\t\tcolor 0.12s $ease, transform 0.12s $ease;\n\ttransform: scale(1);\n\n\t&:hover {\n\t\tbackground-color: $plate-hover;\n\t\tborder-color: rgba(242, 237, 226, 0.24);\n\t\ttransform: scale(1.03);\n\t}\n\n\t// Click feedback: a quick dip below rest, so a press always reads as a press even on a\n\t// hover state that looks similar at a glance.\n\t&:active {\n\t\ttransform: scale(0.97);\n\t\ttransition-duration: 0.05s;\n\t}\n\n\t&.primary {\n\t\tbackground-color: $apple;\n\t\tborder-color: $apple;\n\t\tcolor: $text;\n\n\t\t&:hover { background-color: #ff5647; transform: scale(1.03); }\n\t}\n\n\t&.danger {\n\t\t&:hover { border-color: $apple; color: $apple; }\n\t}\n\n\t&.small {\n\t\tpadding-top: 8px;\n\t\tpadding-bottom: 8px;\n\t\tpadding-left: 16px;\n\t\tpadding-right: 16px;\n\t\tfont-size: 14px;\n\t}\n}\n\n// ---------------------------------------------------------------- settings controls\n\n.setting-row {\n\tflex-direction: row;\n\talign-items: center;\n\tjustify-content: space-between;\n\twidth: 100%;\n\tpadding-top: 14px;\n\tpadding-bottom: 14px;\n\tpadding-left: 20px;\n\tpadding-right: 20px;\n\tborder-radius: $radius;\n\tbackground-color: $plate;\n\tborder: 1px solid transparent;\n\tmargin-bottom: 8px;\n\ttransition: border-color 0.12s $ease, background-color 0.12s $ease;\n\n\t.setting-name {\n\t\t@include face;\n\t\tfont-size: 16px;\n\t\tcolor: $text;\n\n\t\t// Fixed and non-shrinking, so the longest label sets the column and no row wraps its\n\t\t// name onto a second line while its neighbours stay on one.\n\t\twidth: 120px;\n\t\tflex-shrink: 0;\n\t}\n}\n\n.stepper {\n\tflex-direction: row;\n\talign-items: center;\n\tgap: 12px;\n\n\t.step {\n\t\twidth: 30px;\n\t\theight: 30px;\n\t\tborder-radius: 999px;\n\t\tbackground-color: rgba(242, 237, 226, 0.08);\n\t\tborder: 1px solid $hairline;\n\t\tcolor: $text;\n\t\tfont-size: 17px;\n\t\tfont-weight: 700;\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t\talign-items: center;\n\t\tpointer-events: all;\n\t\tcursor: pointer;\n\t\ttransition: background-color 0.1s $ease, border-color 0.1s $ease, transform 0.1s $ease;\n\n\t\t&:hover { background-color: rgba(242, 237, 226, 0.16); border-color: $apple; }\n\t\t&:active { transform: scale(0.9); }\n\t}\n\n\t// Ten pips rather than a continuous bar. A single filled element sized from an inline\n\t// `style` never drew - the attribute did not reach the panel at all - and pips are the\n\t// better control anyway: one pip is exactly one tap of the stepper beside it, so the meter\n\t// shows what a click will do rather than only where the value currently sits.\n\t.meter {\n\t\tflex-direction: row;\n\t\tflex-shrink: 0;\n\t\talign-items: center;\n\t\tgap: 3px;\n\n\t\t.pip {\n\t\t\twidth: 8px;\n\t\t\theight: 10px;\n\t\t\tflex-shrink: 0;\n\t\t\tborder-radius: 3px;\n\t\t\tbackground-color: rgba(242, 237, 226, 0.14);\n\t\t\ttransition: background-color 0.12s $ease;\n\n\t\t\t&.on { background-color: $apple; }\n\t\t}\n\t}\n\n\t.setting-value {\n\t\t@include numeral;\n\t\tfont-size: 14px;\n\t\tcolor: $text-soft;\n\t\tmin-width: 38px;\n\t\ttext-align: center;\n\t\tjustify-content: center;\n\t}\n}\n\n// ---------------------------------------------------------------- records readout\n\n.records {\n\tflex-direction: column;\n\talign-items: stretch;\n\twidth: 320px;\n\tmargin-top: 28px;\n\tpadding-top: 18px;\n\tpadding-bottom: 18px;\n\tpadding-left: 22px;\n\tpadding-right: 22px;\n\tbackground-color: $plate;\n\tborder-radius: $radius;\n\n\t.records-title { @include label; margin-bottom: 12px; }\n\n\t.record-row {\n\t\tflex-direction: row;\n\t\tjustify-content: space-between;\n\t\tpadding-top: 4px;\n\t\tpadding-bottom: 4px;\n\t\tfont-size: 15px;\n\t\tcolor: $text-soft;\n\n\t\tspan:last-child { @include numeral; color: $text; }\n\t}\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "Audio/GameSounds.cs",
            "FileName": "GameSounds.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "namespace Coilgarden;\n\n/// <summary>\n/// The names of every sound event, in one place, so a typo is a compile error rather than a\n/// silent nothing.\n/// <para>\n/// A misspelled sound path is one of the worst kinds of bug to find: the game runs, nothing\n/// throws, and the only symptom is an event that is quieter than you remembered. Routing every\n/// play through a constant removes the entire category.\n/// </para>\n/// <para>\n/// The resources live in <c>Assets/sounds</c> and wrap <c>.wav</c> files synthesised by\n/// <c>Tools/generate_audio.py</c>. Nothing here is sampled or third-party.\n/// </para>\n/// </summary>\npublic static class GameSounds\n{\n\t// ------------------------------------------------------------------ gameplay\n\n\t/// <summary>Three variants plus a pitch range, because this one plays more than any other.</summary>\n\tpublic const string AppleEat = \"sounds/coilgarden.apple.eat.sound\";\n\n\tpublic const string AppleSpawn = \"sounds/coilgarden.apple.spawn.sound\";\n\n\t/// <summary>\n\t/// Played on a direction change only, never per step. Turning is the player's one input;\n\t/// stepping happens whether they act or not, and at six steps a second a footstep is a\n\t/// machine gun.\n\t/// </summary>\n\tpublic const string Turn = \"sounds/coilgarden.turn.sound\";\n\n\tpublic const string GameStart = \"sounds/coilgarden.game.start.sound\";\n\n\tpublic const string GameOver = \"sounds/coilgarden.game.over.sound\";\n\n\tpublic const string Restart = \"sounds/coilgarden.restart.sound\";\n\n\t/// <summary>Beating the stored best. Rare enough to stay special.</summary>\n\tpublic const string HighScore = \"sounds/coilgarden.high.score.sound\";\n\n\t/// <summary>Filling the whole tray - the rarest event in the game, and the only win state.</summary>\n\tpublic const string ArenaFilled = \"sounds/coilgarden.arena.filled.sound\";\n\n\t// ------------------------------------------------------------------ ui\n\n\tpublic const string UiHover = \"sounds/coilgarden.ui.hover.sound\";\n\n\tpublic const string UiClick = \"sounds/coilgarden.ui.click.sound\";\n\n\t// ------------------------------------------------------------------ beds\n\n\t/// <summary>Menu and pre-run bed. Almost nothing happens in it, deliberately.</summary>\n\tpublic const string MusicCalm = \"sounds/coilgarden.music.calm.sound\";\n\n\t/// <summary>In-run bed. Same harmony as the calm one, so a crossfade never changes key.</summary>\n\tpublic const string MusicPlay = \"sounds/coilgarden.music.play.sound\";\n\n\t/// <summary>Always running, very quiet. Stops silence sounding like a bug.</summary>\n\tpublic const string Ambience = \"sounds/coilgarden.ambience.garden.sound\";\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "Core/DebugCommands.cs",
            "FileName": "DebugCommands.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "namespace Coilgarden;\n\n/// <summary>\n/// Console commands used to verify behaviour in the running engine, where the headless test\n/// suite cannot reach. All prefixed <c>cg_</c>.\n/// <para>\n/// These are a development tool and are expected to be removed by the final presentation\n/// phase. They deliberately avoid touching any Razor component, because the headless build\n/// does not compile Razor and a reference from here would break it.\n/// </para>\n/// <para>\n/// Every one of them tolerates there being no live run. Components cache themselves in the\n/// component lifecycle, which the editor also runs for the edit-mode scene, so a command can\n/// be invoked while there is no session at all - and a null dereference in a console command\n/// reads like a bug in the game.\n/// </para>\n/// </summary>\npublic static class DebugCommands\n{\n\t/// <summary>The live session, or null with a reason already logged.</summary>\n\tprivate static GameSession Session( string command )\n\t{\n\t\tvar session = GameSession.Current;\n\n\t\tif ( session?.Run is not null ) return session;\n\n\t\tLog.Info( $\"{command}: no running session. Is play mode started?\" );\n\t\treturn null;\n\t}\n\n\t/// <summary>Dumps the live session's state - the quickest way to see what the game thinks is happening.</summary>\n\t[ConCmd( \"cg_state\" )]\n\tpublic static void State()\n\t{\n\t\tvar session = Session( \"cg_state\" );\n\t\tif ( session is null ) return;\n\n\t\tvar run = session.Run;\n\t\tvar apple = run.Apple.HasValue ? run.Apple.Value.ToString() : \"none\";\n\n\t\tLog.Info( $\"cg_state: state={session.State} arena={run.Arena.Width}x{run.Arena.Height} \" +\n\t\t\t$\"head={run.Snake.Head} dir={run.Snake.Direction} next={run.Snake.NextDirection} \" +\n\t\t\t$\"length={run.Snake.Length} buffered={run.Snake.BufferedTurns} apple={apple} score={run.Score} \" +\n\t\t\t$\"apples={run.ApplesEaten} ticks={run.Ticks} endedBy={run.EndedBy?.ToString() ?? \"-\"} \" +\n\t\t\t$\"lastStep={run.EndedBy?.ToString() ?? session.LastStep.Outcome.ToString()} \" +\n\t\t\t$\"tickFraction={session.TickFraction:F2} best={session.Records.BestScore} newBest={session.IsNewBest} \" +\n\t\t\t$\"modal={session.ModalOpen} stateAge={session.StateAge:F2} feelTime={session.FeelTime:F2} \" +\n\t\t\t$\"autoPause={session.AutoPauseOnFocusLoss} focused={Application.IsFocused}\" );\n\t}\n\n\t/// <summary>Dumps the persistent record, for checking that it survives a restart.</summary>\n\t[ConCmd( \"cg_records\" )]\n\tpublic static void Records()\n\t{\n\t\tvar session = Session( \"cg_records\" );\n\t\tif ( session is null ) return;\n\n\t\tvar records = session.Records;\n\n\t\tLog.Info( $\"cg_records: bestScore={records.BestScore} bestLength={records.BestLength} \" +\n\t\t\t$\"runs={records.RunsPlayed} applesTotal={records.ApplesEatenTotal} \" +\n\t\t\t$\"version={records.Version} file={HighScoreStore.FileName}\" );\n\t}\n\n\t/// <summary>Wipes the stored record. Needed to test first-launch behaviour more than once.</summary>\n\t[ConCmd( \"cg_clearrecords\" )]\n\tpublic static void ClearRecords()\n\t{\n\t\tHighScoreStore.Save( new HighScoreData() );\n\t\tLog.Info( \"cg_clearrecords: record cleared on disk. Restart play mode to reload it.\" );\n\t}\n\n\t/// <summary>Restarts the live run, for checking that a restart really does reset everything.</summary>\n\t[ConCmd( \"cg_restart\" )]\n\tpublic static void RestartRun()\n\t{\n\t\tvar session = Session( \"cg_restart\" );\n\t\tif ( session is null ) return;\n\n\t\tsession.Restart();\n\t\tLog.Info( $\"cg_restart: restarted, state={session.State}.\" );\n\t}\n\n\t/// <summary>Starts play from the waiting state, so a run can be driven without a keyboard.</summary>\n\t[ConCmd( \"cg_start\" )]\n\tpublic static void StartRun()\n\t{\n\t\tvar session = Session( \"cg_start\" );\n\t\tif ( session is null ) return;\n\n\t\tsession.StartRun();\n\t\tLog.Info( $\"cg_start: state is now {session.State}.\" );\n\t}\n\n\t/// <summary>Returns to the title screen, abandoning any run in progress.</summary>\n\t[ConCmd( \"cg_menu\" )]\n\tpublic static void ReturnToMenu()\n\t{\n\t\tvar session = Session( \"cg_menu\" );\n\t\tif ( session is null ) return;\n\n\t\tsession.ReturnToMenu();\n\t\tLog.Info( $\"cg_menu: state is now {session.State}.\" );\n\t}\n\n\t/// <summary>Pauses or resumes: <c>cg_pause 1</c> or <c>cg_pause 0</c>.</summary>\n\t[ConCmd( \"cg_pause\" )]\n\tpublic static void Pause( int paused )\n\t{\n\t\tvar session = Session( \"cg_pause\" );\n\t\tif ( session is null ) return;\n\n\t\tsession.SetPaused( paused != 0 );\n\t\tLog.Info( $\"cg_pause: state is now {session.State}.\" );\n\t}\n\n\t/// <summary>\n\t/// Turns focus auto-pause off or on: <c>cg_autopause 0</c>.\n\t/// <para>\n\t/// Needed because the feature works. Driving the game from outside the engine means the\n\t/// editor window is not the foreground window, so a run pauses the instant it starts and\n\t/// no automated verification can get past it.\n\t/// </para>\n\t/// </summary>\n\t[ConCmd( \"cg_autopause\" )]\n\tpublic static void AutoPause( int enabled )\n\t{\n\t\tvar session = Session( \"cg_autopause\" );\n\t\tif ( session is null ) return;\n\n\t\tsession.AutoPauseOnFocusLoss = enabled != 0;\n\t\tLog.Info( $\"cg_autopause: focus auto-pause is {(session.AutoPauseOnFocusLoss ? \"on\" : \"off\")}.\" );\n\t}\n\n\t/// <summary>\n\t/// Sets the live tick interval in seconds: <c>cg_speed 1.0</c>.\n\t/// <para>\n\t/// Its real purpose is verification rather than tuning. A console command cannot advance\n\t/// a frame, so at the real tick rate there is no way to observe a specific tick from\n\t/// outside the game - slowing the clock to a second a step makes the whole loop\n\t/// steerable and inspectable one tick at a time.\n\t/// </para>\n\t/// </summary>\n\t[ConCmd( \"cg_speed\" )]\n\tpublic static void Speed( float seconds )\n\t{\n\t\tvar session = Session( \"cg_speed\" );\n\t\tif ( session is null ) return;\n\n\t\tsession.TickInterval = seconds.Clamp( 0.02f, 5f );\n\t\tLog.Info( $\"cg_speed: tick interval is now {session.TickInterval}s.\" );\n\t}\n\n\t/// <summary>\n\t/// Queues a turn on the live run: <c>cg_turn up|down|left|right</c>. This is how input\n\t/// handling can be exercised from outside without a human at the keyboard.\n\t/// </summary>\n\t[ConCmd( \"cg_turn\" )]\n\tpublic static void Turn( string direction )\n\t{\n\t\tvar session = Session( \"cg_turn\" );\n\t\tif ( session is null ) return;\n\n\t\tvar parsed = Parse( direction );\n\n\t\tif ( parsed is null )\n\t\t{\n\t\t\tLog.Info( \"cg_turn: expected up, down, left or right.\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar accepted = session.Run.TryTurn( parsed.Value );\n\n\t\tLog.Info( $\"cg_turn {parsed.Value}: {(accepted ? \"accepted\" : \"refused\")}, \" +\n\t\t\t$\"buffered={session.Run.Snake.BufferedTurns}.\" );\n\t}\n\n\t/// <summary>Reports which headings survive the next tick, from the game's own rule.</summary>\n\t[ConCmd( \"cg_safe\" )]\n\tpublic static void Safe()\n\t{\n\t\tvar session = Session( \"cg_safe\" );\n\t\tif ( session is null ) return;\n\n\t\tvar run = session.Run;\n\t\tvar safe = new List<string>();\n\n\t\tforeach ( Direction direction in Enum.GetValues<Direction>() )\n\t\t{\n\t\t\tif ( run.WouldSurvive( direction ) ) safe.Add( direction.ToString() );\n\t\t}\n\n\t\tLog.Info( $\"cg_safe: head={run.Snake.Head} survivable=[{string.Join( \", \", safe )}]\" );\n\t}\n\n\tprivate static Direction? Parse( string direction ) => direction?.ToLowerInvariant() switch\n\t{\n\t\t\"up\" => Direction.Up,\n\t\t\"down\" => Direction.Down,\n\t\t\"left\" => Direction.Left,\n\t\t\"right\" => Direction.Right,\n\t\t_ => null\n\t};\n\n\t/// <summary>\n\t/// Reports the live feel state: where the tick is, and what the camera is doing.\n\t/// <para>\n\t/// A console command cannot advance a frame, so run it twice in quick succession to watch\n\t/// something decay - that is the only way to observe a frame-dependent animation from\n\t/// outside the game.\n\t/// </para>\n\t/// </summary>\n\t[ConCmd( \"cg_feel\" )]\n\tpublic static void Feel()\n\t{\n\t\tvar session = Session( \"cg_feel\" );\n\t\tif ( session is null ) return;\n\n\t\tvar camera = Sandbox.Game.ActiveScene?.GetAllComponents<ArenaCamera>().FirstOrDefault();\n\t\tvar height = camera?.Camera?.OrthographicHeight ?? 0f;\n\n\t\tLog.Info( $\"cg_feel: state={session.State} tickFraction={session.TickFraction:F3} \" +\n\t\t\t$\"interval={session.TickInterval:F3} head={session.Run.Snake.Head} \" +\n\t\t\t$\"orthoHeight={height:F1} camPos={camera?.WorldPosition}\" );\n\t}\n\n\t/// <summary>\n\t/// Reports the audio state: the music mix, the group levels, and how many effects have played\n\t/// or failed to.\n\t/// <para>\n\t/// <c>failed</c> is the one to watch. A null handle is what a missing or uncompiled\n\t/// <c>.vsnd</c> looks like from code, and it is otherwise completely silent - in both senses.\n\t/// </para>\n\t/// </summary>\n\t[ConCmd( \"cg_audio\" )]\n\tpublic static void Audio()\n\t{\n\t\tvar music = Sandbox.Game.ActiveScene?.GetAllComponents<MusicDirector>().FirstOrDefault();\n\t\tvar audio = GameAudio.Current;\n\n\t\tLog.Info( music is null\n\t\t\t? \"cg_audio: no MusicDirector in the scene.\"\n\t\t\t: $\"cg_audio: blend={music.Blend:F2} calm={music.CalmPlaying} \" +\n\t\t\t  $\"play={music.PlayPlaying} ambience={music.AmbiencePlaying} \" +\n\t\t\t  $\"musicVol={music.MusicVolume:F2} ambienceVol={music.AmbienceVolume:F2}\" );\n\n\t\tLog.Info( audio is null\n\t\t\t? \"cg_audio: no GameAudio in the scene.\"\n\t\t\t: $\"cg_audio: sfxVol={audio.SfxVolume:F2} played={audio.PlayCount} \" +\n\t\t\t  $\"failed={audio.FailedCount} last={audio.LastPlayed}\" );\n\t}\n\n\t/// <summary>\n\t/// Reports whether the mouse can actually reach the interface. A UI whose buttons are\n\t/// perfect and whose cursor is hidden is indistinguishable, from the player's side, from a\n\t/// UI that is broken.\n\t/// </summary>\n\t[ConCmd( \"cg_cursor\" )]\n\tpublic static void Cursor()\n\t{\n\t\tLog.Info( $\"cg_cursor: visibility={Mouse.Visibility} active={Mouse.Active} \" +\n\t\t\t$\"position={Mouse.Position} focused={Application.IsFocused} \" +\n\t\t\t$\"engineMenu={Game.IsMainMenuVisible}\" );\n\t}\n\n\t/// <summary>Dumps the live settings - the group volumes the settings panel edits.</summary>\n\t[ConCmd( \"cg_settings\" )]\n\tpublic static void Settings()\n\t{\n\t\tvar session = Session( \"cg_settings\" );\n\t\tif ( session is null ) return;\n\n\t\tvar s = session.Settings;\n\n\t\tLog.Info( $\"cg_settings: master={s.MasterVolume:F2} sfx={s.SfxVolume:F2} \" +\n\t\t\t$\"music={s.MusicVolume:F2} ambience={s.AmbienceVolume:F2} file={SettingsStore.FileName}\" );\n\t}\n\n\t/// <summary>\n\t/// Sets a volume group to an absolute value, for checking the settings screen without a\n\t/// mouse: <c>cg_setvolume sfx 0.5</c>. Channel is one of master, sfx, music, ambience.\n\t/// </summary>\n\t[ConCmd( \"cg_setvolume\" )]\n\tpublic static void SetVolume( string channel, float value )\n\t{\n\t\tvar session = Session( \"cg_setvolume\" );\n\t\tif ( session is null ) return;\n\n\t\tvar s = session.Settings;\n\n\t\tswitch ( channel?.ToLowerInvariant() )\n\t\t{\n\t\t\tcase \"master\": session.AdjustMasterVolume( value - s.MasterVolume ); break;\n\t\t\tcase \"sfx\": session.AdjustSfxVolume( value - s.SfxVolume ); break;\n\t\t\tcase \"music\": session.AdjustMusicVolume( value - s.MusicVolume ); break;\n\t\t\tcase \"ambience\": session.AdjustAmbienceVolume( value - s.AmbienceVolume ); break;\n\t\t\tdefault:\n\t\t\t\tLog.Info( \"cg_setvolume: expected master, sfx, music or ambience.\" );\n\t\t\t\treturn;\n\t\t}\n\n\t\tLog.Info( $\"cg_setvolume: {channel} is now {value:F2}.\" );\n\t}\n\n\t/// <summary>\n\t/// Puts every setting back to the designed balance - the same thing the settings screen's\n\t/// Restore Defaults button does, reachable without a mouse.\n\t/// </summary>\n\t[ConCmd( \"cg_resetsettings\" )]\n\tpublic static void ResetSettings()\n\t{\n\t\tvar session = Session( \"cg_resetsettings\" );\n\t\tif ( session is null ) return;\n\n\t\tsession.ResetSettings();\n\n\t\tvar s = session.Settings;\n\n\t\tLog.Info( $\"cg_resetsettings: master={s.MasterVolume:F2} sfx={s.SfxVolume:F2} \" +\n\t\t\t$\"music={s.MusicVolume:F2} ambience={s.AmbienceVolume:F2}\" );\n\t}\n\n\t/// <summary>\n\t/// Plays one sound by its short name, so each can be checked in isolation:\n\t/// <c>cg_sound apple.eat</c>.\n\t/// </summary>\n\t[ConCmd( \"cg_sound\" )]\n\tpublic static void PlaySound( string which )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( which ) )\n\t\t{\n\t\t\tLog.Info( \"cg_sound: expected a sound name, e.g. apple.eat. Use cg_audio for state.\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar path = $\"sounds/coilgarden.{which}.sound\";\n\n\t\t// Routed through GameAudio so it is heard at the same level the game plays it at -\n\t\t// checking balance against a raw full-volume play would be checking nothing.\n\t\tif ( GameAudio.Current is not null )\n\t\t{\n\t\t\tGameAudio.Current.Play( path );\n\t\t\tLog.Info( $\"cg_sound: played {path} through GameAudio.\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar handle = Sound.Play( path );\n\n\t\tLog.Info( handle is null\n\t\t\t? $\"cg_sound: '{path}' did not play - is the wav compiled?\"\n\t\t\t: $\"cg_sound: played {path} directly (no GameAudio in the scene).\" );\n\t}\n\n\t/// <summary>\n\t/// Fires the camera's punch and shake without needing to eat or die.\n\t/// <para>\n\t/// Both last about a fifth of a second, which is shorter than a round trip - so pair this\n\t/// with <c>cg_feel</c> in one batched request to catch the camera actually displaced.\n\t/// </para>\n\t/// </summary>\n\t[ConCmd( \"cg_kick\" )]\n\tpublic static void Kick()\n\t{\n\t\tvar camera = Sandbox.Game.ActiveScene?.GetAllComponents<ArenaCamera>().FirstOrDefault();\n\n\t\tif ( camera is null )\n\t\t{\n\t\t\tLog.Info( \"cg_kick: no ArenaCamera in the scene.\" );\n\t\t\treturn;\n\t\t}\n\n\t\tcamera.Punch( GameConfig.CameraPunchAmount );\n\t\tcamera.Shake( GameConfig.CameraShakeAmount );\n\n\t\tLog.Info( \"cg_kick: punch and shake requested.\" );\n\t}\n\n\t/// <summary>\n\t/// Fires a sparkle burst at the apple's cell without eating it.\n\t/// <para>\n\t/// Exists because the eat burst lasts under half a second, which is far shorter than a\n\t/// screenshot round trip - there is otherwise no way to look at the particles at all.\n\t/// </para>\n\t/// </summary>\n\t[ConCmd( \"cg_burst\" )]\n\tpublic static void Burst()\n\t{\n\t\tvar session = Session( \"cg_burst\" );\n\t\tif ( session is null ) return;\n\n\t\tvar effects = Sandbox.Game.ActiveScene?.GetAllComponents<Effects>().FirstOrDefault();\n\n\t\tif ( effects is null )\n\t\t{\n\t\t\tLog.Info( \"cg_burst: no Effects component in the scene.\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar cell = session.Run.Apple ?? session.Run.Snake.Head;\n\n\t\teffects.Burst( cell );\n\n\t\t// Reports the pool state, because a silent early return inside Burst is indistinguishable\n\t\t// from particles that spawn and are then invisible for some other reason.\n\t\tLog.Info( $\"cg_burst: cell={cell} origin={effects.LastBurstOrigin} alive={effects.AliveCount} \" +\n\t\t\t$\"enable={effects.Enable} timeScale={effects.TimeScale:F2}\" );\n\t}\n\n\t/// <summary>\n\t/// Exercises the rules against a throwaway simulation and reports pass/fail per check.\n\t/// <para>\n\t/// This duplicates part of the headless suite on purpose. Running the same rules inside\n\t/// the engine is what proves they behave the same there - the headless build knows\n\t/// nothing about s&amp;box's API whitelist, so a call that is legal to Roslyn and rejected\n\t/// by the engine would otherwise only be found by playing.\n\t/// </para>\n\t/// </summary>\n\t[ConCmd( \"cg_selftest\" )]\n\tpublic static void SelfTest()\n\t{\n\t\tvar passed = 0;\n\t\tvar failed = 0;\n\n\t\tvoid Check( string what, bool condition )\n\t\t{\n\t\t\tif ( condition )\n\t\t\t{\n\t\t\t\tpassed++;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tfailed++;\n\t\t\tLog.Warning( $\"cg_selftest FAILED: {what}\" );\n\t\t}\n\n\t\tvar rules = GameRules.Default with { Width = 11, Height = 11 };\n\t\tvar run = new SnakeGame( rules, 1234 );\n\n\t\t// A fresh run is set up correctly.\n\t\tCheck( \"starts at the configured length\", run.Snake.Length == rules.StartLength );\n\t\tCheck( \"head starts at the arena centre\", run.Snake.Head == run.Arena.Centre );\n\t\tCheck( \"the rules were kept\", run.Rules.Width == 11 && run.Rules.Height == 11 );\n\t\tCheck( \"an apple exists\", run.Apple.HasValue );\n\t\tCheck( \"the apple is not under the snake\", run.Apple.HasValue && !run.Snake.Occupies( run.Apple.Value ) );\n\t\tCheck( \"score starts at zero\", run.Score == 0 );\n\t\tCheck( \"the run is not over\", !run.IsOver );\n\n\t\t// Turning.\n\t\tCheck( \"a reversal is refused\", !run.TryTurn( run.Snake.Direction.Opposite() ) );\n\t\tCheck( \"the current heading is refused as a turn\", !run.TryTurn( run.Snake.Direction ) );\n\t\tCheck( \"a legal turn is accepted\", run.TryTurn( Direction.Up ) );\n\n\t\t// Moving.\n\t\tvar before = run.Snake.Head;\n\t\tvar step = run.Step();\n\n\t\tCheck( \"the buffered turn was taken\", run.Snake.Direction == Direction.Up );\n\t\tCheck( \"the head moved one cell\", step.To == before + Direction.Up.Delta() );\n\t\tCheck( \"length is unchanged by a plain move\", run.Snake.Length == rules.StartLength );\n\n\t\t// The survivability query has to agree with what a step actually does, or the two\n\t\t// copies of the collision rule have drifted apart.\n\t\tvar predictedSurvival = run.WouldSurvive( run.Snake.Direction );\n\t\tvar actuallySurvived = !run.Step().IsTerminal;\n\n\t\tCheck( \"WouldSurvive agrees with the step it predicted\", predictedSurvival == actuallySurvived );\n\n\t\t// Walls end the run. Drive straight up until something happens.\n\t\tvar guard = 0;\n\t\twhile ( !run.IsOver && guard++ < 500 )\n\t\t{\n\t\t\trun.Step();\n\t\t}\n\n\t\tCheck( \"driving into a wall ends the run\", run.IsOver );\n\t\tCheck( \"the run ended for a stated reason\", run.EndedBy.HasValue );\n\t\tCheck( \"a finished run survives nothing\", !run.WouldSurvive( Direction.Up ) );\n\n\t\t// Restart clears everything.\n\t\trun.Restart();\n\n\t\tCheck( \"restart clears the end state\", !run.IsOver && run.EndedBy is null );\n\t\tCheck( \"restart resets the score\", run.Score == 0 );\n\t\tCheck( \"restart resets the length\", run.Snake.Length == rules.StartLength );\n\t\tCheck( \"restart resets the tick count\", run.Ticks == 0 );\n\t\tCheck( \"restart puts the head back at the centre\", run.Snake.Head == run.Arena.Centre );\n\t\tCheck( \"restart leaves an apple on the board\", run.Apple.HasValue );\n\n\t\t// Eating grows and scores.\n\t\tvar eaten = false;\n\t\tvar lengthBefore = run.Snake.Length;\n\t\tguard = 0;\n\n\t\twhile ( !run.IsOver && !eaten && guard++ < 5000 )\n\t\t{\n\t\t\tSteerTowardsApple( run );\n\t\t\teaten = run.Step().Grew;\n\t\t}\n\n\t\tCheck( \"the snake can reach an apple\", eaten );\n\n\t\tif ( eaten )\n\t\t{\n\t\t\tCheck( \"eating grew the snake\", run.Snake.Length == lengthBefore + 1 );\n\t\t\tCheck( \"eating scored the rules' value\", run.Score == rules.ApplePoints );\n\t\t\tCheck( \"eating counted an apple\", run.ApplesEaten == 1 );\n\t\t\tCheck( \"a new apple was spawned\", run.Apple.HasValue );\n\t\t\tCheck( \"the new apple is not under the snake\", run.Apple.HasValue && !run.Snake.Occupies( run.Apple.Value ) );\n\t\t}\n\n\t\t// Records, which need no filesystem to be checked.\n\t\tvar records = new HighScoreData();\n\n\t\tCheck( \"a first score is a new best\", records.Submit( 50, 9, 5 ) );\n\t\tCheck( \"the best was stored\", records.BestScore == 50 );\n\t\tCheck( \"a worse score is not a new best\", !records.Submit( 10, 4, 1 ) );\n\t\tCheck( \"a worse score does not lower the best\", records.BestScore == 50 );\n\t\tCheck( \"matching the best is not beating it\", !records.Submit( 50, 9, 5 ) );\n\t\tCheck( \"every run is counted\", records.RunsPlayed == 3 );\n\n\t\tLog.Info( $\"cg_selftest: {passed}/{passed + failed} checks passed.\" );\n\t}\n\n\t/// <summary>\n\t/// Apple seeking, steering only through the public API. Survivability comes from\n\t/// <see cref=\"SnakeGame.WouldSurvive\"/> rather than a second copy of the collision rule -\n\t/// which is exactly what this used to be, in two places.\n\t/// </summary>\n\tprivate static void SteerTowardsApple( SnakeGame run )\n\t{\n\t\tif ( !run.Apple.HasValue ) return;\n\n\t\tvar snake = run.Snake;\n\t\tvar delta = run.Apple.Value - snake.Head;\n\n\t\tvar towardsX = delta.X > 0 ? Direction.Right : Direction.Left;\n\t\tvar towardsY = delta.Y > 0 ? Direction.Up : Direction.Down;\n\n\t\tvar preferences = Math.Abs( delta.X ) >= Math.Abs( delta.Y )\n\t\t\t? new[] { towardsX, towardsY, snake.Direction, towardsY.Opposite(), towardsX.Opposite() }\n\t\t\t: new[] { towardsY, towardsX, snake.Direction, towardsX.Opposite(), towardsY.Opposite() };\n\n\t\tforeach ( var candidate in preferences )\n\t\t{\n\t\t\tif ( snake.Direction.IsOpposite( candidate ) ) continue;\n\t\t\tif ( !run.WouldSurvive( candidate ) ) continue;\n\n\t\t\tif ( candidate != snake.Direction ) run.TryTurn( candidate );\n\t\t\treturn;\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "Core/TickClock.cs",
            "FileName": "TickClock.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "namespace Coilgarden;\n\n/// <summary>\n/// The fixed-step clock that decides when a logical tick happens. Pure: no engine types, no\n/// ambient time, so the whole of its behaviour is testable headlessly.\n/// <para>\n/// It was extracted from <see cref=\"GameSession\"/> after a defect that no test could have\n/// caught while it lived inside a component. The session cleared its accumulator outright once\n/// the per-frame tick budget was spent - which, with a budget of one, is every single tick - so\n/// the fraction of an interval that had legitimately elapsed was thrown away each time. A tick\n/// could then only land on a frame boundary, making the real interval the configured one\n/// rounded up to the next whole frame: measured 7.5% slow at 60Hz, and 68% slow when a frame\n/// took longer than a tick. In a game played for a high score, speed must not depend on the\n/// player's hardware.\n/// </para>\n/// <para>\n/// The rule that fixes it is the distinction this class exists to hold: <b>whole ticks past the\n/// budget are dropped, the sub-tick remainder is always kept.</b> Dropping backlog is\n/// deliberate - a frame that overran must not be paid back as a burst of steps the player never\n/// saw - but the remainder is not backlog, it is simply where the clock has got to.\n/// </para>\n/// </summary>\npublic sealed class TickClock\n{\n\tprivate float timer;\n\n\t/// <summary>How far through the current tick the clock is, 0 to 1.</summary>\n\tpublic float Fraction( float interval ) =>\n\t\tinterval <= 0f ? 0f : Math.Clamp( timer / interval, 0f, 1f );\n\n\t/// <summary>\n\t/// Puts the clock back to the start of a tick, optionally with a grace period before the\n\t/// first one can land.\n\t/// <para>\n\t/// The grace is stored as a negative timer, so <see cref=\"Fraction\"/> clamps to zero and\n\t/// anything interpolating on it simply sits still rather than easing into motion.\n\t/// </para>\n\t/// </summary>\n\tpublic void Reset( float grace = 0f )\n\t{\n\t\t// Written as a positive test so a zero grace stores positive zero. Negating instead gave\n\t\t// negative zero, which survives the clamp in Fraction and surfaces as \"-0.00\" in the\n\t\t// state readout - harmless arithmetically and exactly the kind of thing that sends\n\t\t// somebody hunting a bug that is not there.\n\t\ttimer = grace > 0f ? -grace : 0f;\n\t}\n\n\t/// <summary>\n\t/// Advances by one frame and reports how many logical ticks are now due, never more than\n\t/// <paramref name=\"maxTicks\"/>.\n\t/// </summary>\n\tpublic int Advance( float delta, float interval, int maxTicks )\n\t{\n\t\t// A non-positive interval would mean infinite ticks per frame. Treated as \"no clock\"\n\t\t// rather than throwing, because it can only arrive from a config value or a slider.\n\t\tif ( interval <= 0f ) return 0;\n\t\tif ( maxTicks < 1 ) return 0;\n\n\t\ttimer += delta;\n\n\t\tif ( timer < interval ) return 0;\n\n\t\tvar due = (int)(timer / interval);\n\n\t\tif ( due <= maxTicks )\n\t\t{\n\t\t\ttimer -= due * interval;\n\t\t\treturn due;\n\t\t}\n\n\t\t// Past the budget: the extra whole ticks are dropped, and the remainder is what is left\n\t\t// over after them - not zero.\n\t\ttimer %= interval;\n\n\t\treturn maxTicks;\n\t}\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "Feel/Effects.cs",
            "FileName": "Effects.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "namespace Coilgarden;\n\n/// <summary>\n/// A small pool of sparkles, thrown when an apple is eaten.\n/// <para>\n/// Hand-rolled rather than driven by the engine's particle system, for two reasons. The first\n/// is control: at ten particles an event, every one of them is visible, and being able to tune\n/// the exact arc, life and shrink of each is worth more here than any feature a general\n/// particle system offers. The second is that the whole visual identity is \"two primitives and\n/// code\", and a sparkle is a small sphere.\n/// </para>\n/// <para>\n/// <b>Budgeted, not unbounded.</b> The pool is fixed and allocated once; a burst that would\n/// exceed it simply throws fewer. There is no path by which a fast player fills the screen -\n/// which matters, because the one thing an effect must never do here is hide the board.\n/// </para>\n/// </summary>\npublic sealed class Effects : Component\n{\n\t[Property] public GameSession Session { get; set; }\n\n\t[Property] public float CellSize { get; set; } = GameConfig.CellSize;\n\n\t/// <summary>Turn the sparkles off, to check the game still reads without them.</summary>\n\t[Property] public bool Enable { get; set; } = true;\n\n\t/// <summary>\n\t/// Slows the sparkles down, for looking at them.\n\t/// <para>\n\t/// A burst lasts under half a second, which is shorter than a screenshot round trip - so\n\t/// without this there is no way to inspect the arc, the spread or the shrink at all. Left at\n\t/// 1 in play; drop it to about 0.08 to study a burst frame by frame.\n\t/// </para>\n\t/// </summary>\n\t[Property, Range( 0.02f, 1f )] public float TimeScale { get; set; } = 1f;\n\n\tprivate struct Sparkle\n\t{\n\t\tpublic ModelRenderer Renderer;\n\t\tpublic Vector3 Position;\n\t\tpublic Vector3 Velocity;\n\t\tpublic float Age;\n\t\tpublic float Life;\n\t\tpublic bool Alive;\n\t}\n\n\tprivate GameObject visualRoot;\n\tprivate Primitives primitives;\n\tprivate ArenaSpace space;\n\tprivate Sparkle[] pool;\n\tprivate int nextIndex;\n\n\tprivate int builtWidth;\n\tprivate int builtHeight;\n\n\t/// <summary>Deterministic per-session, so a burst is not a different shape every run.</summary>\n\tprivate readonly System.Random random = new( 0x5EED );\n\n\tprotected override void OnEnabled()\n\t{\n\t\tSession ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();\n\t}\n\n\tprotected override void OnDisabled() => TearDown();\n\n\t/// <summary>How many sparkles are currently in flight. For the debug readout.</summary>\n\tpublic int AliveCount\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( pool is null ) return -1;\n\n\t\t\tvar alive = 0;\n\n\t\t\tfor ( var i = 0; i < pool.Length; i++ )\n\t\t\t{\n\t\t\t\tif ( pool[i].Alive ) alive++;\n\t\t\t}\n\n\t\t\treturn alive;\n\t\t}\n\t}\n\n\t/// <summary>Where the last burst was asked to happen, and whether the pool was ready for it.</summary>\n\tpublic Vector3 LastBurstOrigin { get; private set; }\n\n\t/// <summary>Throws a burst of sparkles out of a cell.</summary>\n\tpublic void Burst( GridPos cell )\n\t{\n\t\tif ( !Enable || pool is null ) return;\n\n\t\tvar origin = space.Above( space.Cell( cell ), GameConfig.AppleDiameter * 0.5f );\n\n\t\tLastBurstOrigin = origin;\n\n\t\tfor ( var i = 0; i < GameConfig.SparklesPerApple; i++ )\n\t\t{\n\t\t\tLaunch( origin );\n\t\t}\n\t}\n\n\tprivate void Launch( Vector3 origin )\n\t{\n\t\t// Round-robin through the pool. The oldest sparkle is the one recycled, so a burst\n\t\t// during a burst degrades by dropping the stalest particle rather than by refusing.\n\t\tvar index = nextIndex;\n\t\tnextIndex = (nextIndex + 1) % pool.Length;\n\n\t\t// Outwards in the tray plane, biased towards the viewer so the burst arcs up off the\n\t\t// sand rather than sliding along it.\n\t\tvar angle = (float)(random.NextDouble() * MathF.PI * 2f);\n\t\tvar speed = GameConfig.SparkleSpeed * CellSize * (0.55f + (float)random.NextDouble() * 0.7f);\n\n\t\tvar velocity = new Vector3(\n\t\t\t-speed * (0.5f + (float)random.NextDouble() * 0.5f),\n\t\t\tMathF.Cos( angle ) * speed * 0.55f,\n\t\t\tMathF.Sin( angle ) * speed * 0.55f );\n\n\t\tpool[index].Position = origin;\n\t\tpool[index].Velocity = velocity;\n\t\tpool[index].Age = 0f;\n\t\tpool[index].Life = GameConfig.SparkleLife * (0.7f + (float)random.NextDouble() * 0.6f);\n\t\tpool[index].Alive = true;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tvar arena = Session?.Run?.Arena;\n\t\tif ( arena is null ) return;\n\n\t\tEnsureBuilt( arena );\n\n\t\t// Sparkles in flight stop where they are while paused, rather than continuing to arc and\n\t\t// fall behind the pause card.\n\t\tvar delta = Session.FeelDelta * TimeScale;\n\n\t\tfor ( var i = 0; i < pool.Length; i++ )\n\t\t{\n\t\t\tif ( !pool[i].Alive )\n\t\t\t{\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tpool[i].Age += delta;\n\n\t\t\tvar t = Ease.Progress( pool[i].Age, pool[i].Life );\n\n\t\t\tif ( t >= 1f )\n\t\t\t{\n\t\t\t\tpool[i].Alive = false;\n\t\t\t\tpool[i].Renderer.Enabled = false;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// \"Up\" out of the tray is -X, so gravity pulls back towards +X.\n\t\t\tpool[i].Velocity += new Vector3( GameConfig.SparkleGravity * CellSize * delta, 0f, 0f );\n\t\t\tpool[i].Position += pool[i].Velocity * delta;\n\n\t\t\t// A sparkle that has fallen back to the sand is done. Without this they keep going\n\t\t\t// and end up *behind* the sand bed, still alive and invisible - which looks exactly\n\t\t\t// like the particles never having worked.\n\t\t\tif ( pool[i].Position.x >= 0f )\n\t\t\t{\n\t\t\t\tpool[i].Alive = false;\n\t\t\t\tpool[i].Renderer.Enabled = false;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar renderer = pool[i].Renderer;\n\n\t\t\trenderer.GameObject.LocalPosition = pool[i].Position;\n\n\t\t\t// Shrinking to nothing is what makes them read as sparks rather than as debris that\n\t\t\t// vanishes. InQuad keeps them full-size for most of their life and then goes quickly.\n\t\t\tprimitives.Resize( renderer, GameConfig.SparkleSize * CellSize * (1f - Ease.InQuad( t )) );\n\t\t\trenderer.Enabled = true;\n\t\t}\n\t}\n\n\tprivate void EnsureBuilt( Arena arena )\n\t{\n\t\tvar built = visualRoot.IsValid()\n\t\t\t&& builtWidth == arena.Width\n\t\t\t&& builtHeight == arena.Height\n\t\t\t&& pool is not null;\n\n\t\tif ( built ) return;\n\n\t\tTearDown();\n\n\t\tbuiltWidth = arena.Width;\n\t\tbuiltHeight = arena.Height;\n\n\t\tvisualRoot = new GameObject( GameObject, true, \"Sparkles\" );\n\t\tvisualRoot.Flags |= GameObjectFlags.NotSaved;\n\n\t\tprimitives = new Primitives( visualRoot );\n\t\tspace = new ArenaSpace( arena.Width, arena.Height, CellSize );\n\n\t\tpool = new Sparkle[GameConfig.SparkleBudget];\n\n\t\tfor ( var i = 0; i < pool.Length; i++ )\n\t\t{\n\t\t\tpool[i].Renderer = primitives.Sphere( $\"Sparkle {i}\", Vector3.Zero,\n\t\t\t\tGameConfig.SparkleSize * CellSize, Palette.Sparkle );\n\n\t\t\tpool[i].Renderer.Enabled = false;\n\t\t}\n\t}\n\n\tprivate void TearDown()\n\t{\n\t\tvisualRoot?.Destroy();\n\t\tvisualRoot = null;\n\t\tprimitives = null;\n\t\tpool = null;\n\t\tnextIndex = 0;\n\t\tbuiltWidth = 0;\n\t\tbuiltHeight = 0;\n\t}\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "Snake/SnakeGame.cs",
            "FileName": "SnakeGame.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "namespace Coilgarden;\n\n/// <summary>\n/// One run, as pure rules: the arena, the snake, the apple and the score, with a single\n/// <see cref=\"Step\"/> that advances all of them by one logical tick.\n/// <para>\n/// This is the whole game as far as correctness is concerned. It has no engine dependency\n/// at all, which is what lets the rules be exercised thousands of times in a headless test\n/// run, and it is the seam that stops the feel layer from ever becoming load-bearing:\n/// <see cref=\"GameSession\"/> decides <em>when</em> to step, and the views decide how it\n/// looks, but neither can change what happens.\n/// </para>\n/// </summary>\npublic sealed class SnakeGame\n{\n\tprivate readonly AppleSpawner spawner;\n\n\tpublic SnakeGame( GameRules rules, int seed )\n\t{\n\t\t// Clamped here rather than trusted, so there is no way to construct a run whose\n\t\t// rules cannot be played - whatever the caller read them from.\n\t\tRules = rules.Clamped();\n\n\t\tArena = new Arena( Rules.Width, Rules.Height );\n\t\tSnake = new Snake( Arena, Rules.StartLength, Rules.StartDirection, Rules.MaxBufferedTurns );\n\t\tspawner = new AppleSpawner( Arena, seed );\n\n\t\tRestart();\n\t}\n\n\t/// <summary>Convenience for the common case and for tests that do not vary the rules.</summary>\n\tpublic SnakeGame( int seed ) : this( GameRules.Default, seed )\n\t{\n\t}\n\n\t/// <summary>The rules this run is being played under. Fixed for its lifetime.</summary>\n\tpublic GameRules Rules { get; }\n\n\tpublic Arena Arena { get; }\n\n\tpublic Snake Snake { get; }\n\n\t/// <summary>Where the apple is, or null when the arena had no room for one.</summary>\n\tpublic GridPos? Apple { get; private set; }\n\n\tpublic int Score { get; private set; }\n\n\tpublic int ApplesEaten { get; private set; }\n\n\t/// <summary>Ticks survived this run. The difficulty curve and the HUD both read it.</summary>\n\tpublic int Ticks { get; private set; }\n\n\t/// <summary>Set once the run has ended, and the reason why.</summary>\n\tpublic StepOutcome? EndedBy { get; private set; }\n\n\tpublic bool IsOver => EndedBy.HasValue;\n\n\t/// <summary>\n\t/// Returns every field to its starting value. Written as a single method that touches\n\t/// all of them so a field added later has one obvious place to be reset, rather than\n\t/// being forgotten in one of several branches.\n\t/// <para>\n\t/// The apple RNG is deliberately <em>not</em> reseeded: consecutive runs in one sitting\n\t/// should not open with the same apple in the same place.\n\t/// </para>\n\t/// </summary>\n\tpublic void Restart()\n\t{\n\t\tSnake.Reset( Rules.StartLength, Rules.StartDirection );\n\n\t\tScore = 0;\n\t\tApplesEaten = 0;\n\t\tTicks = 0;\n\t\tEndedBy = null;\n\t\tApple = spawner.Pick( Snake );\n\t}\n\n\t/// <summary>Records a turn for an upcoming tick. Ignored once the run is over.</summary>\n\tpublic bool TryTurn( Direction direction ) => !IsOver && Snake.TryTurn( direction );\n\n\t/// <summary>\n\t/// Would heading this way on the next tick keep the run alive?\n\t/// <para>\n\t/// Answers from the same rule <see cref=\"Step\"/> resolves with rather than a second copy\n\t/// of it, so the two cannot drift apart. Used by the test and self-test bots to play the\n\t/// game through the public API, and it is the query a hint or accessibility cue would\n\t/// be built on.\n\t/// </para>\n\t/// <para>\n\t/// Note this asks about a <em>heading</em>, not about whether the turn is legal -\n\t/// <see cref=\"TryTurn\"/> answers that.\n\t/// </para>\n\t/// </summary>\n\tpublic bool WouldSurvive( Direction direction )\n\t{\n\t\tif ( IsOver ) return false;\n\n\t\tvar target = Snake.Head + direction.Delta();\n\n\t\tif ( !Arena.Contains( target ) ) return false;\n\n\t\tvar growing = Apple.HasValue && Apple.Value == target;\n\n\t\treturn !Snake.CollidesWithBody( target, growing );\n\t}\n\n\t/// <summary>\n\t/// Advances one logical tick. Does nothing once the run has ended, so a session that\n\t/// keeps ticking through a death animation cannot accidentally step past it.\n\t/// </summary>\n\tpublic StepResult Step()\n\t{\n\t\tif ( IsOver )\n\t\t{\n\t\t\treturn new StepResult( EndedBy.Value, Snake.Head, Snake.Head, Snake.Direction, false, 0 );\n\t\t}\n\n\t\tvar result = Snake.Step( Apple );\n\n\t\tTicks++;\n\n\t\tif ( result.Grew )\n\t\t{\n\t\t\tApplesEaten++;\n\n\t\t\tvar points = ScoreRules.ApplePoints( ApplesEaten, Rules );\n\t\t\tScore += points;\n\n\t\t\tresult = result with { ScoreGained = points };\n\n\t\t\t// A null here means the snake now covers every cell, which the step already\n\t\t\t// reported as FilledArena. Leaving Apple null is correct: there is nowhere to\n\t\t\t// put one, and the run is over anyway.\n\t\t\tApple = spawner.Pick( Snake );\n\t\t}\n\n\t\tif ( result.IsTerminal )\n\t\t{\n\t\t\tEndedBy = result.Outcome;\n\t\t}\n\n\t\treturn result;\n\t}\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "UI/HudPanel.razor",
            "FileName": "HudPanel.razor",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "@using Sandbox;\n@using Sandbox.UI;\n@namespace Coilgarden\n@inherits Panel\n@attribute [StyleSheet( \"/UI/GameUi.razor.scss\" )]\n\n@*\n\tThe live readout: score and best, and nothing else. Deliberately small - everything else\n\tthe player needs during a run is in the arena itself, and length lives in the game-over\n\tsummary instead of here because it is interesting after a run and noise during one.\n\n\tRefreshes itself off its own Tick(), independently of whichever overlay GameUi has on top\n\tof it, so a paused or finished run still shows a live-looking score without dragging the\n\trest of the interface through a rebuild to do it.\n*@\n\n<root class=\"readout\">\n\t<div class=\"@ScoreClass\">@ScoreText</div>\n\t<div class=\"best\">\n\t\t<span class=\"best-label\">BEST</span>\n\t\t<span class=\"best-value\">@BestText</span>\n\t</div>\n</root>\n\n@code\n{\n\t[Parameter] public GameSession Session { get; set; }\n\n\tprivate string ScoreText { get; set; } = \"0\";\n\tprivate string BestText { get; set; } = \"0\";\n\n\t/// <summary>Carries the pop class while the score is counting, built here rather than in\n\t/// markup so the attribute is never a mix of literal text and an @@expression.</summary>\n\tprivate string ScoreClass { get; set; } = \"score\";\n\n\t/// <summary>\n\t/// The score as it is being shown, which chases the real one rather than jumping to it.\n\t/// A number that counts up turns a state change into an event the eye can follow, and it\n\t/// is the difference between the score being a readout and the score being the reward.\n\t/// Kept as a float so the chase is smooth, and always floored so the player never sees a\n\t/// number higher than what they have actually earned.\n\t/// </summary>\n\tprivate float shownScore;\n\n\tprivate int lastRealScore;\n\n\t/// <summary>Seconds since the score last changed, driving the pop.</summary>\n\tprivate float scoreAge = 99f;\n\n\tpublic override void Tick()\n\t{\n\t\tbase.Tick();\n\n\t\tvar run = Session?.Run;\n\t\tif ( run is null ) return;\n\n\t\tTickScore( run.Score );\n\n\t\tScoreText = ((int)shownScore).ToString();\n\t\tBestText = (Session.Records?.BestScore ?? 0).ToString();\n\t}\n\n\t/// <summary>\n\t/// Advances the displayed score towards the real one and drives the pop.\n\t/// <para>\n\t/// A restart drops the target to zero, and that is the one case where counting is wrong -\n\t/// watching your score tick down to nothing is a strange thing to do to somebody who just\n\t/// asked to play again, so it snaps instead.\n\t/// </para>\n\t/// </summary>\n\tprivate void TickScore( int real )\n\t{\n\t\tif ( real != lastRealScore )\n\t\t{\n\t\t\tif ( real < lastRealScore ) shownScore = real;\n\n\t\t\tlastRealScore = real;\n\t\t\tscoreAge = 0f;\n\t\t}\n\n\t\tscoreAge += Time.Delta;\n\n\t\t// Rate comes from the size of the gap, so an award of any size lands in about the same\n\t\t// time rather than a big one taking noticeably longer.\n\t\tvar gap = MathF.Abs( real - shownScore );\n\n\t\tif ( gap > 0.01f )\n\t\t{\n\t\t\tvar rate = MathF.Max( gap, 1f ) / GameConfig.ScoreCountDuration;\n\n\t\t\tshownScore = shownScore < real\n\t\t\t\t? MathF.Min( shownScore + rate * Time.Delta, real )\n\t\t\t\t: MathF.Max( shownScore - rate * Time.Delta, real );\n\t\t}\n\t\telse\n\t\t{\n\t\t\tshownScore = real;\n\t\t}\n\n\t\tScoreClass = scoreAge < GameConfig.ScoreCountDuration ? \"score popped\" : \"score\";\n\t}\n\n\t/// <summary>\n\t/// The counting score is folded in as its whole number, so the panel redraws once per\n\t/// digit change rather than every frame.\n\t/// </summary>\n\tprotected override int BuildHash() => System.HashCode.Combine( (int)shownScore, ScoreClass, BestText );\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "UI/MainMenuPanel.razor",
            "FileName": "MainMenuPanel.razor",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "@using Sandbox;\n@using Sandbox.UI;\n@namespace Coilgarden\n@inherits Panel\n@attribute [StyleSheet( \"/UI/GameUi.razor.scss\" )]\n\n@*\n\tThe title screen. A keyboard player can skip it entirely by just pressing a direction -\n\tGameSession takes that as both \"start\" and the first turn - so the buttons here exist for\n\tthe mouse player and for reaching Settings, not because the game waits on them.\n*@\n\n<root class=\"scrim\">\n\t<div class=\"card\">\n\t\t<div class=\"wordmark\">Coilgarden</div>\n\t\t<div class=\"tagline\">A small garden. One apple at a time.</div>\n\n\t\t<div class=\"menu\">\n\t\t\t<div onmouseover=@UiSound.Hover class=\"btn primary\" onclick=@Play>Play</div>\n\t\t\t<div onmouseover=@UiSound.Hover class=\"btn\" onclick=@OpenSettingsClicked>Settings</div>\n\t\t</div>\n\n\t\t<div class=\"prompt\">or press a direction to begin</div>\n\n\t\t@if ( HasRecord )\n\t\t{\n\t\t\t<div class=\"records\">\n\t\t\t\t<div class=\"records-title\">Best run</div>\n\t\t\t\t<div class=\"record-row\"><span>Score</span><span>@BestScoreText</span></div>\n\t\t\t\t<div class=\"record-row\"><span>Length</span><span>@BestLengthText</span></div>\n\t\t\t\t<div class=\"record-row\"><span>Runs played</span><span>@RunsText</span></div>\n\t\t\t</div>\n\t\t}\n\t</div>\n</root>\n\n@code\n{\n\t[Parameter] public GameSession Session { get; set; }\n\n\t[Parameter] public Action SettingsRequested { get; set; }\n\n\tprivate HighScoreData Records => Session?.Records;\n\n\tprivate bool HasRecord => Records is { RunsPlayed: > 0 };\n\n\tprivate string BestScoreText => (Records?.BestScore ?? 0).ToString();\n\n\tprivate string BestLengthText => (Records?.BestLength ?? 0).ToString();\n\n\tprivate string RunsText => (Records?.RunsPlayed ?? 0).ToString();\n\n\tprivate void Play()\n\t{\n\t\tUiSound.Click();\n\t\tSession?.StartRun();\n\t}\n\n\tprivate void OpenSettingsClicked()\n\t{\n\t\tUiSound.Click();\n\t\tSettingsRequested?.Invoke();\n\t}\n\n\tprotected override int BuildHash() => System.HashCode.Combine(\n\t\tRecords?.BestScore, Records?.BestLength, Records?.RunsPlayed );\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "styles/form/_colorproperty.scss",
            "FileName": "_colorproperty.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "\r\n\r\n.colorproperty\r\n{\r\n\talign-items: center;\r\n\tposition: relative;\r\n\r\n\t.colorsquare\r\n\t{\r\n\t\twidth: 20px;\r\n\t\theight: 20px;\r\n\t\tborder-radius: 4px;\r\n\t\tmargin-right: 8px;\r\n\t\tposition: absolute;\r\n\t\tleft: 7px;\r\n\t\tz-index: 1;\r\n\t\tcursor: pointer;\r\n\t}\r\n\r\n\t> .textentry:not( .a.b.c )\r\n\t{\r\n\t\tpadding-left: 36px;\r\n\t}\r\n}"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "ui/controls/color/coloralphacontrol.cs.scss",
            "FileName": "coloralphacontrol.cs.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "ColorAlphaControl\r\n{\r\n\tgap: 0.5rem;\r\n\tflex-grow: 1;\r\n\tpointer-events: all;\r\n\tbackground: linear-gradient( to right, black, white );\r\n\tborder-radius: 4px;\r\n\tpadding: 2px;\r\n\theight: 12px;\r\n\tposition: relative;\r\n\tcursor: pointer;\r\n\tborder: 1px solid #333;\r\n\r\n\t&:hover\r\n\t{\r\n\t\tborder: 1px solid #08f;\r\n\t}\r\n\r\n\t&:active\r\n\t{\r\n\t\tborder: 1px solid #fff;\r\n\t}\r\n\r\n\t.handle\r\n\t{\r\n\t\ttop: -5px;\r\n\t\tbottom: -5px;\r\n\t\taspect-ratio: 1;\r\n\t\tborder-radius: 100px;\r\n\t\tborder: 2px solid #444;\r\n\t\tposition: absolute;\r\n\t\tbackground-color: white;\r\n\t\tbox-shadow: 2px 2px 16px #000a;\r\n\t\ttransform: translateX( -50% );\r\n\t\tpointer-events: none;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "Core/GameState.cs",
            "FileName": "GameState.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "namespace Coilgarden;\n\n/// <summary>Where the app is.</summary>\npublic enum GameState\n{\n\t/// <summary>\n\t/// The title screen. A fresh run is already set up underneath it and waiting for the\n\t/// player to ask for it - by clicking Play, or by pressing a direction key directly,\n\t/// which is taken as both \"start\" and the first turn so a keyboard player's first press\n\t/// is never swallowed by a menu they did not read.\n\t/// </summary>\n\tMainMenu,\n\n\tPlaying,\n\n\tPaused,\n\n\t/// <summary>The run ended. Restarting from here is the only way out.</summary>\n\tGameOver\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "Feel/Ease.cs",
            "FileName": "Ease.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "namespace Coilgarden;\n\n/// <summary>\n/// The easing vocabulary, and all of it. Four curves, each with one job.\n/// <para>\n/// Keeping the set this small is the point rather than a limitation: a consistent easing\n/// vocabulary is most of what separates \"animated\" from \"polished\". Anything that arrives\n/// uses <see cref=\"OutCubic\"/>, anything that should feel springy uses <see cref=\"OutBack\"/>,\n/// anything that leaves uses <see cref=\"InQuad\"/>, and one-shot flashes use\n/// <see cref=\"Pulse\"/>.\n/// </para>\n/// <para>\n/// Pure maths with no engine dependency, so the curves are covered by the headless suite -\n/// which matters more than it sounds. An easing function with the wrong endpoints does not\n/// look wrong, it looks like a <em>positioning</em> bug somewhere else entirely.\n/// </para>\n/// </summary>\npublic static class Ease\n{\n\t/// <summary>Decelerates into its destination. The default for anything that arrives.</summary>\n\tpublic static float OutCubic( float t )\n\t{\n\t\tt = Clamp01( t );\n\n\t\tvar inverted = 1f - t;\n\n\t\treturn 1f - inverted * inverted * inverted;\n\t}\n\n\t/// <summary>Accelerates away. For anything leaving, shrinking or being consumed.</summary>\n\tpublic static float InQuad( float t )\n\t{\n\t\tt = Clamp01( t );\n\n\t\treturn t * t;\n\t}\n\n\t/// <summary>\n\t/// Overshoots past 1 and settles back. This is what makes something feel like it has\n\t/// weight and springiness rather than being placed.\n\t/// <para>\n\t/// Returns values above 1 partway through, which is deliberate - callers must be scaling\n\t/// or offsetting, not writing into something that clamps.\n\t/// </para>\n\t/// </summary>\n\tpublic static float OutBack( float t, float overshoot = 1.70158f )\n\t{\n\t\tt = Clamp01( t );\n\n\t\tvar inverted = t - 1f;\n\n\t\treturn 1f + (overshoot + 1f) * inverted * inverted * inverted + overshoot * inverted * inverted;\n\t}\n\n\t/// <summary>\n\t/// Rises to 1 and falls back to 0 across 0..1, peaking early.\n\t/// <para>\n\t/// For one-shot punches - a squash, a flash, a camera kick - where the value has to end\n\t/// exactly where it started or the effect leaves a permanent offset behind. The early peak\n\t/// is what makes it read as an impact rather than a swell.\n\t/// </para>\n\t/// </summary>\n\tpublic static float Pulse( float t )\n\t{\n\t\tt = Clamp01( t );\n\n\t\t// Rises over the first quarter, decays over the rest.\n\t\tconst float peak = 0.25f;\n\n\t\tif ( t <= peak ) return OutCubic( t / peak );\n\n\t\treturn 1f - InQuad( (t - peak) / (1f - peak) );\n\t}\n\n\t/// <summary>\n\t/// Progress through a duration, clamped, and 1 when the duration is not positive.\n\t/// <para>\n\t/// Every animation here is \"elapsed over duration\", and a zero duration from a mistyped\n\t/// config value would otherwise divide by zero. Returning 1 means the animation reads as\n\t/// already finished, which is the harmless outcome.\n\t/// </para>\n\t/// </summary>\n\tpublic static float Progress( float elapsed, float duration ) =>\n\t\tduration <= 0f ? 1f : Clamp01( elapsed / duration );\n\n\tprivate static float Clamp01( float t ) => t < 0f ? 0f : t > 1f ? 1f : t;\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "Rendering/SceneLook.cs",
            "FileName": "SceneLook.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "namespace Coilgarden;\n\n/// <summary>\n/// Drives the scene's lighting and post-processing from <see cref=\"Palette\"/> in code.\n/// <para>\n/// The look used to be authored on the scene's components. That was wrong for two reasons.\n/// The first is architectural: the project's rule is that tuning values live in one file, and\n/// having the entire visual identity spread across component properties in a JSON scene made\n/// it the one part of the game that could not be read, reviewed or diffed. The second is\n/// practical: the scene is only editable through the editor, play mode runs a <em>clone</em> of\n/// it, and the views only build in <c>OnUpdate</c> - so every colour change cost a save, a play\n/// restart and a round trip, and half of them silently edited the wrong copy.\n/// </para>\n/// <para>\n/// Applied <b>once</b>, on enable, and never per frame. An earlier version wrote everything in\n/// <c>OnPreRender</c> to get instant hot-reload feedback, and that rendered the entire frame\n/// black - re-writing exposure and light state every frame fights whatever the renderer is\n/// doing between frames. Play mode is restarted for every visual change anyway, so applying on\n/// enable loses nothing.\n/// </para>\n/// <para>\n/// It also deliberately does <b>not</b> touch <see cref=\"Tonemapping\"/>. Exposure is left to\n/// the values authored on the scene; driving it from here was part of what caused the black\n/// frame, and it is the one setting where the engine's own adaptation needs to be left alone.\n/// </para>\n/// </summary>\npublic sealed class SceneLook : Component\n{\n\t/// <summary>\n\t/// Shadow bias. Emphatically not zero: at zero every surface shadows itself and the whole\n\t/// scene renders black, which cost a full debugging cycle to find. It looks exactly like\n\t/// \"the lights are not working\".\n\t/// </summary>\n\tpublic const float ShadowBias = 0.08f;\n\n\tpublic const float ShadowHardness = 0.85f;\n\n\t/// <summary>Key light heading, as pitch/yaw. Comes from above and to the left of the viewer.</summary>\n\tpublic static readonly Angles KeyAngles = new( 22f, -20f, 0f );\n\n\t/// <summary>Fill light heading. Opposite side, shallower, so it fills without flattening.</summary>\n\tpublic static readonly Angles FillAngles = new( -24f, 30f, 0f );\n\n\t/// <summary>\n\t/// The vignette is off.\n\t/// <para>\n\t/// It banded visibly into concentric rings against the near-black backdrop at every\n\t/// intensity that was strong enough to be worth having, and at that point it was adding an\n\t/// artefact rather than atmosphere. The dark backdrop already frames the tray, which is all\n\t/// the vignette was there to do. Worth revisiting in the polish pass with a lighter\n\t/// backdrop to dither against.\n\t/// </para>\n\t/// </summary>\n\tpublic const bool EnableVignette = false;\n\n\t/// <summary>\n\t/// Lit surfaces come back noticeably duller than their albedo, so a little saturation is\n\t/// added back at the end rather than by pushing the palette - channels above 1 wrap.\n\t/// </summary>\n\tpublic const float Saturation = 1.24f;\n\n\tpublic const float Contrast = 1.04f;\n\n\t/// <summary>Lifts the whole image slightly; tonemapping lands the palette darker than authored.</summary>\n\tpublic const float Brightness = 1.56f;\n\n\tpublic const float SharpenScale = 0.18f;\n\n\t// ---------------------------------------------------------------- live tunables\n\t// Static rather than const so `cg_light` can drive them without a play restart. Lighting is\n\t// the one part of this game that cannot be judged by reasoning, and a restart per experiment\n\t// made iterating on it prohibitively slow.\n\n\t/// <summary>\n\t/// Multiplier on the key light. Above 1 the sand comes back closer to the pale cream it is\n\t/// authored as - a lit surface returns well below its albedo, and the floor was reading as a\n\t/// dull greige rather than as sand.\n\t/// </summary>\n\tpublic static float KeyScale = 1f;\n\n\tpublic static float FillScale = 1f;\n\n\t/// <summary>\n\t/// Multiplier on the ambient wash. Below 1 deepens shadows; the original value lit every\n\t/// surface from all sides hard enough that nothing cast a shadow worth seeing.\n\t/// </summary>\n\tpublic static float AmbientScale = 1f;\n\n\t/// <summary>\n\t/// Off, having been tried and made no visible difference.\n\t/// <para>\n\t/// Screen-space contact shadows are exactly the feature the missing shadow under each piece\n\t/// calls for, and enabling them changed nothing that could be seen at any lighting balance -\n\t/// so it is GPU cost bought for nothing. Left here as a switch because it is the first thing\n\t/// worth re-testing if the lighting is ever reworked.\n\t/// </para>\n\t/// </summary>\n\tpublic static bool UseContactShadows = false;\n\n\t/// <summary>\n\t/// Fixed exposure for the tonemapper. This is the only global brightness control that\n\t/// actually moves the image: the sand's albedo is already at the legal ceiling and a lit\n\t/// surface still comes back well under it, because the filmic curve compresses everything\n\t/// below white. Light intensity cannot reach past that; exposure can.\n\t/// </summary>\n\tpublic static float Exposure = 1f;\n\n\t/// <summary>\n\t/// A light colour at a given intensity.\n\t/// <para>\n\t/// Channels are allowed past 1 here, unlike anywhere else in this project. The wrap-above-1\n\t/// hazard recorded in the conventions is a property of the <em>tint</em> path on a\n\t/// <see cref=\"ModelRenderer\"/>, which truncates through 8 bits per channel; a light's colour\n\t/// is consumed as a float and scaling it is the only way to actually add intensity rather\n\t/// than merely desaturate towards white.\n\t/// </para>\n\t/// </summary>\n\tprivate static Color Scaled( Color colour, float scale ) =>\n\t\tnew( colour.r * scale, colour.g * scale, colour.b * scale, colour.a );\n\n\t[Property] public DirectionalLight KeyLight { get; set; }\n\n\t[Property] public DirectionalLight FillLight { get; set; }\n\n\t[Property] public AmbientLight Ambient { get; set; }\n\n\t[Property] public CameraComponent Camera { get; set; }\n\n\t/// <summary>Turn the whole grade off, to check the game still reads without it.</summary>\n\t[Property] public bool EnablePost { get; set; } = true;\n\n\tprivate Vignette vignette;\n\tprivate ColorAdjustments grade;\n\tprivate Sharpen sharpen;\n\tprivate Tonemapping tonemapping;\n\n\t/// <summary>\n\t/// The live look, so the lighting can be re-applied from a console command. Lighting is the\n\t/// one thing in this project that cannot be judged without looking at it, and a play restart\n\t/// per experiment makes iterating on it prohibitively slow.\n\t/// </summary>\n\tpublic static SceneLook Current { get; private set; }\n\n\tprotected override void OnDisabled()\n\t{\n\t\tif ( Current == this ) Current = null;\n\t}\n\n\t/// <summary>Re-applies lighting and grade. Safe to call at any time; never per frame.</summary>\n\tpublic void Apply()\n\t{\n\t\tApplyLighting();\n\t\tApplyPost();\n\t}\n\n\tprotected override void OnEnabled()\n\t{\n\t\tCurrent = this;\n\n\t\tCamera ??= Components.Get<CameraComponent>();\n\n\t\tvar lights = Scene.GetAllComponents<DirectionalLight>().ToList();\n\n\t\t// Identified by object name, not by which one currently casts shadows. Picking the key\n\t\t// by `l.Shadows` was circular: this component is what turns shadows on, so before it had\n\t\t// ever run the test could match nothing and silently hand the key's warm colour and\n\t\t// angle to the fill light - swapping the two whenever the scene was in the wrong state.\n\t\tKeyLight ??= Find( lights, \"Key\" ) ?? lights.FirstOrDefault();\n\t\tFillLight ??= Find( lights, \"Fill\" ) ?? lights.FirstOrDefault( l => l != KeyLight );\n\t\tAmbient ??= Scene.GetAllComponents<AmbientLight>().FirstOrDefault();\n\n\t\tvignette = Components.Get<Vignette>();\n\t\tgrade = Components.Get<ColorAdjustments>();\n\t\tsharpen = Components.Get<Sharpen>();\n\t\ttonemapping = Components.Get<Tonemapping>();\n\n\t\tApplyLighting();\n\t\tApplyPost();\n\t}\n\n\t/// <summary>\n\t/// Retunes the lighting live: <c>cg_light &lt;key&gt; &lt;ambient&gt; &lt;contactShadows&gt;</c>.\n\t/// <para>\n\t/// Development only. It exists because lighting cannot be judged without looking at it, and\n\t/// applying on enable alone means a play restart for every experiment.\n\t/// </para>\n\t/// </summary>\n\t[ConCmd( \"cg_light\" )]\n\tpublic static void Light( float key, float ambient, float exposure )\n\t{\n\t\tif ( Current is null )\n\t\t{\n\t\t\tLog.Info( \"cg_light: no SceneLook in the scene. Is play mode started?\" );\n\t\t\treturn;\n\t\t}\n\n\t\tKeyScale = key.Clamp( 0.1f, 6f );\n\t\tAmbientScale = ambient.Clamp( 0f, 3f );\n\t\tExposure = exposure.Clamp( 0.1f, 6f );\n\n\t\tCurrent.Apply();\n\n\t\tLog.Info( $\"cg_light: key={KeyScale:F2} ambient={AmbientScale:F2} exposure={Exposure:F2} \" +\n\t\t\t$\"contactShadows={UseContactShadows}\" );\n\t}\n\n\tprivate static DirectionalLight Find( List<DirectionalLight> lights, string nameContains ) =>\n\t\tlights.FirstOrDefault( l =>\n\t\t\tl.GameObject.IsValid() &&\n\t\t\tl.GameObject.Name.Contains( nameContains, StringComparison.OrdinalIgnoreCase ) );\n\n\tprivate void ApplyLighting()\n\t{\n\t\tif ( Camera.IsValid() ) Camera.BackgroundColor = Palette.Backdrop;\n\n\t\tif ( KeyLight.IsValid() )\n\t\t{\n\t\t\tKeyLight.WorldRotation = KeyAngles;\n\t\t\tKeyLight.LightColor = Scaled( Palette.KeyLight, KeyScale );\n\n\t\t\t// This is a *second* ambient term on top of the AmbientLight component. Running both\n\t\t\t// was suspected of being why nothing casts a visible shadow, but removing it was\n\t\t\t// tried and made the image plainly worse - the tray went dull and the whole frame\n\t\t\t// shifted red, with the shadows no more visible than before. It stays.\n\t\t\tKeyLight.SkyColor = Scaled( Palette.Ambient, AmbientScale );\n\n\t\t\tKeyLight.Shadows = true;\n\t\t\tKeyLight.ShadowBias = ShadowBias;\n\t\t\tKeyLight.ShadowHardness = ShadowHardness;\n\n\t\t\t// The cascaded shadow map is far too coarse for a sphere resting on a flat tray;\n\t\t\t// contact shadows are the screen-space pass that catches exactly that detail, and\n\t\t\t// it is what puts the pieces *on* the sand rather than floating above it.\n\t\t\tKeyLight.ContactShadows = UseContactShadows;\n\t\t}\n\n\t\tif ( FillLight.IsValid() )\n\t\t{\n\t\t\tFillLight.WorldRotation = FillAngles;\n\t\t\tFillLight.LightColor = Scaled( Palette.FillLight, FillScale );\n\n\t\t\t// Black, so the fill adds direction without also adding another ambient term.\n\t\t\tFillLight.SkyColor = Color.Black;\n\n\t\t\t// One shadow-casting light. A second set of shadows on a top-down board reads as\n\t\t\t// dirt rather than as depth. Contact shadows off here for the same reason.\n\t\t\tFillLight.Shadows = false;\n\t\t\tFillLight.ContactShadows = false;\n\t\t}\n\n\t\tif ( Ambient.IsValid() ) Ambient.Color = Scaled( Palette.Ambient, AmbientScale );\n\n\t\t// Framing is driven from here too, so GameConfig is the single source of truth for it.\n\t\t// A value authored on the scene component otherwise wins over the code default, which\n\t\t// is how a padding change can appear to do nothing at all.\n\t\tvar framing = Components.Get<ArenaCamera>();\n\n\t\tif ( framing.IsValid() )\n\t\t{\n\t\t\tframing.Padding = GameConfig.CameraPadding;\n\t\t\tframing.CellSize = GameConfig.CellSize;\n\t\t\tframing.Distance = GameConfig.CameraDistance;\n\t\t}\n\t}\n\n\tprivate void ApplyPost()\n\t{\n\t\tif ( vignette.IsValid() ) vignette.Enabled = EnablePost && EnableVignette;\n\n\t\t// Pinned rather than adapting. Auto exposure on a board whose contents change brightness\n\t\t// as the snake grows would make the tray subtly breathe, and a fixed value is the one\n\t\t// control that can actually lift the sand to the cream it is authored as. Written once\n\t\t// on enable, never per frame - driving exposure every frame renders the whole scene\n\t\t// black.\n\t\tif ( tonemapping.IsValid() )\n\t\t{\n\t\t\ttonemapping.AutoExposureEnabled = false;\n\t\t\ttonemapping.MinimumExposure = Exposure;\n\t\t\ttonemapping.MaximumExposure = Exposure;\n\t\t\ttonemapping.ExposureCompensation = 0f;\n\t\t}\n\n\t\tif ( grade.IsValid() )\n\t\t{\n\t\t\tgrade.Enabled = EnablePost;\n\t\t\tgrade.Blend = 1f;\n\t\t\tgrade.Saturation = Saturation;\n\t\t\tgrade.Contrast = Contrast;\n\t\t\tgrade.Brightness = Brightness;\n\t\t\tgrade.HueRotate = 0f;\n\t\t}\n\n\t\tif ( sharpen.IsValid() )\n\t\t{\n\t\t\tsharpen.Enabled = EnablePost;\n\t\t\tsharpen.Scale = SharpenScale;\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "UI/GameOverPanel.razor",
            "FileName": "GameOverPanel.razor",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "@using Sandbox;\n@using Sandbox.UI;\n@namespace Coilgarden\n@inherits Panel\n@attribute [StyleSheet( \"/UI/GameUi.razor.scss\" )]\n\n<root class=\"scrim\">\n\t<div class=\"@CardClass\">\n\t\t<div class=\"heading\">@EndTitle</div>\n\t\t<div class=\"cause\">@EndCause</div>\n\n\t\t<div class=\"tally\">\n\t\t\t<div class=\"tally-item\">\n\t\t\t\t<span class=\"tally-label\">SCORE</span>\n\t\t\t\t<span class=\"tally-value\">@ScoreText</span>\n\t\t\t</div>\n\t\t\t<div class=\"tally-item\">\n\t\t\t\t<span class=\"tally-label\">LENGTH</span>\n\t\t\t\t<span class=\"tally-value\">@LengthText</span>\n\t\t\t</div>\n\t\t\t<div class=\"tally-item\">\n\t\t\t\t<span class=\"tally-label\">BEST</span>\n\t\t\t\t<span class=\"tally-value\">@BestText</span>\n\t\t\t</div>\n\t\t</div>\n\n\t\t<div class=\"menu\">\n\t\t\t<div onmouseover=@UiSound.Hover class=\"btn primary\" onclick=@Restart>Play Again</div>\n\t\t\t<div onmouseover=@UiSound.Hover class=\"btn\" onclick=@Menu>Main Menu</div>\n\t\t</div>\n\t</div>\n</root>\n\n@code\n{\n\t[Parameter] public GameSession Session { get; set; }\n\n\tprivate SnakeGame Run => Session?.Run;\n\n\t/// <summary>A record-beating run gets an apple-red edge, restating the good news in a\n\t/// second channel so it lands even if the words are skimmed.</summary>\n\tprivate string CardClass => Session?.IsNewBest == true ? \"card record\" : \"card\";\n\n\tprivate bool Won => Run?.EndedBy == StepOutcome.FilledArena;\n\n\tprivate string EndTitle => Session?.IsNewBest == true ? \"New best\" : Won ? \"Garden full\" : \"Game over\";\n\n\tprivate string EndCause => Run?.EndedBy switch\n\t{\n\t\tStepOutcome.HitWall => \"You met the edge of the tray.\",\n\t\tStepOutcome.HitSelf => \"You crossed your own tail.\",\n\t\tStepOutcome.FilledArena => \"Not a single cell left. Remarkable.\",\n\t\t_ => \"\"\n\t};\n\n\tprivate string ScoreText => (Run?.Score ?? 0).ToString();\n\n\tprivate string LengthText => (Run?.Snake.Length ?? 0).ToString();\n\n\tprivate string BestText => (Session?.Records?.BestScore ?? 0).ToString();\n\n\tprivate void Restart()\n\t{\n\t\tUiSound.Click();\n\t\tSession?.Restart();\n\t}\n\n\tprivate void Menu()\n\t{\n\t\tUiSound.Click();\n\t\tSession?.ReturnToMenu();\n\t}\n\n\tprotected override int BuildHash() => System.HashCode.Combine(\n\t\tRun?.EndedBy, Session?.IsNewBest, Run?.Score, Run?.Snake?.Length, Session?.Records?.BestScore );\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "Rendering/AppleView.cs",
            "FileName": "AppleView.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "namespace Coilgarden;\n\n/// <summary>\n/// Draws the apple, and animates the moment the whole game is built around.\n/// <para>\n/// The apple gets three animations and each has a job:\n/// </para>\n/// <list type=\"bullet\">\n/// <item><b>Spawn</b> - scales in with a slight overshoot. An apple that simply appears is the\n/// difference between a world and a spreadsheet, and the overshoot draws the eye to the new\n/// target without needing a flash or an arrow.</item>\n/// <item><b>Idle</b> - a slow bob and an even slower spin. It says the apple is a thing rather\n/// than a marker, and a moving object is found in peripheral vision far faster than a still\n/// one.</item>\n/// <item><b>Eaten</b> - swells and vanishes in about an eighth of a second. This is the reward,\n/// so it has to be immediate; anything longer and the player is waiting for their prize\n/// instead of already going after the next one.</item>\n/// </list>\n/// <para>\n/// Presentation only: it reads where the apple is and never decides.\n/// </para>\n/// </summary>\npublic sealed class AppleView : Component\n{\n\t[Property] public GameSession Session { get; set; }\n\n\t[Property] public float CellSize { get; set; } = GameConfig.CellSize;\n\n\t/// <summary>Scales the bob, spin, spawn and pop. 0 leaves a static apple.</summary>\n\t[Property, Range( 0f, 2f )] public float Animation { get; set; } = 1f;\n\n\tprivate GameObject visualRoot;\n\tprivate GameObject apple;\n\tprivate Primitives parts;\n\tprivate ArenaSpace space;\n\n\t/// <summary>Where the apple is being drawn, which lags the simulation during the eat pop.</summary>\n\tprivate GridPos drawnCell;\n\tprivate bool hasDrawnCell;\n\n\tprivate float spawnAge = 99f;\n\tprivate float eatAge = -1f;\n\n\t/// <summary>The cell the pop is playing at, kept because the apple has already moved on.</summary>\n\tprivate GridPos poppingCell;\n\n\tprivate int builtWidth;\n\tprivate int builtHeight;\n\tprivate float builtCellSize;\n\n\tprotected override void OnEnabled()\n\t{\n\t\tSession ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();\n\t}\n\n\tprotected override void OnDisabled() => TearDown();\n\n\tprotected override void OnUpdate()\n\t{\n\t\tvar run = Session?.Run;\n\t\tif ( run is null ) return;\n\n\t\tEnsureBuilt( run.Arena );\n\n\t\tif ( !apple.IsValid() ) return;\n\n\t\t// Zero while paused, so the apple stops bobbing and spinning with everything else.\n\t\tvar delta = Session.FeelDelta;\n\n\t\tspawnAge += delta;\n\t\tif ( eatAge >= 0f ) eatAge += delta;\n\n\t\tObserve( run );\n\t\tDraw( run );\n\t}\n\n\t/// <summary>\n\t/// Notices a new apple, and notices the old one being eaten.\n\t/// <para>\n\t/// The eaten apple's cell has to be remembered, because by the time this runs the simulation\n\t/// has already put the next apple somewhere else - the pop has to play where the fruit\n\t/// actually was, not where the next one will be.\n\t/// </para>\n\t/// </summary>\n\tprivate void Observe( SnakeGame run )\n\t{\n\t\tif ( !run.Apple.HasValue )\n\t\t{\n\t\t\t// The arena is full, which is a win. Nothing to draw and nothing to animate.\n\t\t\thasDrawnCell = false;\n\t\t\treturn;\n\t\t}\n\n\t\tvar cell = run.Apple.Value;\n\n\t\tif ( hasDrawnCell && cell == drawnCell ) return;\n\n\t\t// A different cell means the previous one was eaten - unless nothing was being drawn yet,\n\t\t// which is a fresh run rather than a meal.\n\t\tif ( hasDrawnCell && Session.LastStep.Grew )\n\t\t{\n\t\t\tpoppingCell = drawnCell;\n\t\t\teatAge = 0f;\n\t\t}\n\n\t\tdrawnCell = cell;\n\t\thasDrawnCell = true;\n\t\tspawnAge = 0f;\n\t}\n\n\tprivate void Draw( SnakeGame run )\n\t{\n\t\tif ( !hasDrawnCell )\n\t\t{\n\t\t\tapple.Enabled = false;\n\t\t\treturn;\n\t\t}\n\n\t\t// While the pop is playing, the object is still showing the apple that was eaten. The new\n\t\t// one waits its turn, which keeps the two events from overlapping into visual mush.\n\t\tvar popping = eatAge >= 0f && eatAge < GameConfig.AppleEatDuration;\n\n\t\tvar cell = popping ? poppingCell : drawnCell;\n\t\tvar scale = popping ? PopScale() : SpawnScale();\n\n\t\tvar radius = GameConfig.AppleDiameter * 0.5f;\n\t\tvar bob = popping ? 0f : Bob();\n\n\t\tapple.LocalPosition = space.Above( space.Cell( cell ), radius + bob );\n\t\tapple.LocalScale = new Vector3( scale, scale, scale );\n\n\t\t// Spun about the view axis, so from a top-down camera the stem and leaf sweep around the\n\t\t// fruit rather than tumbling out of the plane.\n\t\tapple.LocalRotation = Rotation.FromAxis( Vector3.Forward, Spin() );\n\t\tapple.Enabled = scale > 0.01f;\n\t}\n\n\t/// <summary>\n\t/// Swells, then collapses to nothing. The swell is what makes it read as bursting rather\n\t/// than blinking out, and the collapse accelerates so the last thing the eye registers is\n\t/// the apple being gone rather than a small apple lingering.\n\t/// </summary>\n\tprivate float PopScale()\n\t{\n\t\tvar t = Ease.Progress( eatAge, GameConfig.AppleEatDuration );\n\t\tvar swell = 1f + GameConfig.AppleEatSwell * Animation;\n\n\t\tconst float rise = 0.35f;\n\n\t\tif ( t < rise ) return MathX.Lerp( 1f, swell, Ease.OutCubic( t / rise ) );\n\n\t\treturn MathX.Lerp( swell, 0f, Ease.InQuad( (t - rise) / (1f - rise) ) );\n\t}\n\n\tprivate float SpawnScale()\n\t{\n\t\tvar t = Ease.Progress( spawnAge, GameConfig.AppleSpawnDuration );\n\n\t\tif ( t >= 1f || Animation <= 0f ) return 1f;\n\n\t\treturn MathX.Lerp( 0f, 1f, Ease.OutBack( t ) );\n\t}\n\n\tprivate float Bob() =>\n\t\tMathF.Sin( Session.FeelTime * GameConfig.AppleBobSpeed ) * GameConfig.AppleBobAmount * Animation;\n\n\tprivate float Spin() => Animation <= 0f ? 0f : Session.FeelTime * GameConfig.AppleSpinSpeed * Animation;\n\n\tprivate void EnsureBuilt( Arena arena )\n\t{\n\t\tvar built = visualRoot.IsValid()\n\t\t\t&& builtWidth == arena.Width\n\t\t\t&& builtHeight == arena.Height\n\t\t\t&& builtCellSize.AlmostEqual( CellSize )\n\t\t\t&& apple.IsValid();\n\n\t\tif ( built ) return;\n\n\t\tTearDown();\n\n\t\tbuiltWidth = arena.Width;\n\t\tbuiltHeight = arena.Height;\n\t\tbuiltCellSize = CellSize;\n\n\t\tvisualRoot = new GameObject( GameObject, true, \"Apple\" );\n\t\tvisualRoot.Flags |= GameObjectFlags.NotSaved;\n\n\t\tspace = new ArenaSpace( arena.Width, arena.Height, CellSize );\n\n\t\t// The parts hang off one object so the whole apple can be moved, scaled, spun and popped\n\t\t// as a single thing.\n\t\tapple = new GameObject( visualRoot, true, \"Apple Body\" );\n\t\tapple.Flags |= GameObjectFlags.NotSaved;\n\n\t\tparts = new Primitives( apple );\n\n\t\tvar diameter = GameConfig.AppleDiameter * CellSize;\n\n\t\tparts.Sphere( \"Flesh\", Vector3.Zero, diameter, Palette.AppleFlesh );\n\n\t\tvar stemLength = GameConfig.AppleStemLength * CellSize;\n\n\t\t// The stem leans towards the top of the screen (+Z) rather than straight out at the\n\t\t// camera (-X). Pointing it along the view axis hid it inside the apple's own silhouette,\n\t\t// where it read as a stray dot in the middle of the fruit instead of a stem - from a\n\t\t// top-down camera, anything that matters has to have screen-space extent.\n\t\tparts.Box( \"Stem\",\n\t\t\tnew Vector3( -diameter * 0.30f, 0f, diameter * 0.40f ),\n\t\t\tnew Vector3( diameter * 0.11f, diameter * 0.11f, stemLength ),\n\t\t\tPalette.AppleStem );\n\n\t\tparts.Sphere( \"Leaf\",\n\t\t\tnew Vector3( -diameter * 0.34f, -diameter * 0.26f, diameter * 0.50f ),\n\t\t\tdiameter * 0.26f,\n\t\t\tPalette.AppleLeaf );\n\n\t\tapple.Enabled = false;\n\t\thasDrawnCell = false;\n\t\tspawnAge = 99f;\n\t\teatAge = -1f;\n\t}\n\n\tprivate void TearDown()\n\t{\n\t\tvisualRoot?.Destroy();\n\t\tvisualRoot = null;\n\t\tapple = null;\n\t\tparts = null;\n\t\thasDrawnCell = false;\n\t\tbuiltWidth = 0;\n\t\tbuiltHeight = 0;\n\t\tbuiltCellSize = 0f;\n\t}\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "Rendering/ArenaCamera.cs",
            "FileName": "ArenaCamera.cs",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "namespace Coilgarden;\n\n/// <summary>\n/// Frames the whole arena, always. Fixed, orthographic, no scrolling.\n/// <para>\n/// Orthographic is not a stylistic choice here: it keeps every cell the same size on\n/// screen, so judging a gap near the edge of the arena is exactly as reliable as judging\n/// one in the middle. In a game about threading a gap, a perspective arena would quietly\n/// make the corners harder than the centre.\n/// </para>\n/// <para>\n/// The framing is recomputed every frame from the live aspect ratio, so a window resize is\n/// handled by construction rather than by an event.\n/// </para>\n/// </summary>\npublic sealed class ArenaCamera : Component\n{\n\t[Property] public GameSession Session { get; set; }\n\n\t[Property] public CameraComponent Camera { get; set; }\n\n\t/// <summary>How far back the camera sits. Orthographic, so this only has to be clear of the geometry.</summary>\n\t[Property] public float Distance { get; set; } = GameConfig.CameraDistance;\n\n\t/// <summary>\n\t/// Multiplier on the arena size, so the walls are not flush against the screen edge and\n\t/// the HUD has somewhere to sit.\n\t/// </summary>\n\t[Property, Range( 1f, 2f )] public float Padding { get; set; } = GameConfig.CameraPadding;\n\n\t[Property] public float CellSize { get; set; } = GameConfig.CellSize;\n\n\tprotected override void OnAwake()\n\t{\n\t\tCamera ??= Components.GetOrCreate<CameraComponent>();\n\t}\n\n\tprotected override void OnEnabled()\n\t{\n\t\tSession ??= Scene.GetAllComponents<GameSession>().FirstOrDefault();\n\t}\n\n\t/// <summary>Zoom punch, 0..1 of its duration. Decays every frame.</summary>\n\tprivate float punchAmount;\n\tprivate float punchAge;\n\n\tprivate float shakeAmount;\n\tprivate float shakeAge;\n\n\t/// <summary>\n\t/// Asks for a brief zoom-in. Used for eating, because the board coming momentarily closer\n\t/// reads as a reward, where a shake would read as damage however small it was.\n\t/// </summary>\n\tpublic void Punch( float amount )\n\t{\n\t\tif ( amount <= 0f ) return;\n\n\t\t// Strongest wins. Two apples in quick succession is one punch, not a double-length one.\n\t\tif ( amount < punchAmount && punchAge < GameConfig.CameraPunchDuration ) return;\n\n\t\tpunchAmount = MathF.Min( amount, MaxPunch );\n\t\tpunchAge = 0f;\n\t}\n\n\t/// <summary>Asks for a positional kick. Death only.</summary>\n\tpublic void Shake( float amount )\n\t{\n\t\tif ( amount <= 0f ) return;\n\t\tif ( amount < shakeAmount && shakeAge < GameConfig.CameraShakeDuration ) return;\n\n\t\tshakeAmount = MathF.Min( amount, MaxShake );\n\t\tshakeAge = 0f;\n\t}\n\n\t/// <summary>\n\t/// Hard ceilings, so no call site can produce something absurd whatever it asks for. The\n\t/// clamp lives here rather than at the caller because there is no version of this game in\n\t/// which a bigger kick than this is correct.\n\t/// </summary>\n\tprivate const float MaxPunch = 0.05f;\n\n\tprivate const float MaxShake = 14f;\n\n\t/// <summary>\n\t/// Framing is applied in <see cref=\"OnPreRender\"/> rather than in update, so it is\n\t/// always the last word before the frame is drawn and can never be a frame stale.\n\t/// </summary>\n\tprotected override void OnPreRender()\n\t{\n\t\tif ( !Camera.IsValid() ) return;\n\n\t\tvar arena = Session?.Run?.Arena;\n\n\t\t// Falling back to the configured size keeps the editor's edit-mode view framed\n\t\t// correctly, where no run exists yet.\n\t\tvar columns = arena?.Width ?? GameConfig.GridWidth;\n\t\tvar rows = arena?.Height ?? GameConfig.GridHeight;\n\n\t\tvar needVertical = rows * CellSize * Padding;\n\t\tvar needHorizontal = columns * CellSize * Padding;\n\n\t\tvar aspect = MathF.Max( Screen.Aspect, 0.2f );\n\t\tvar framed = MathF.Max( needVertical, needHorizontal / aspect );\n\n\t\tCamera.Orthographic = true;\n\n\t\t// A punch shrinks the framed height, which zooms in. Pulse ends exactly at zero, so the\n\t\t// framing always returns to precisely where it was rather than drifting.\n\t\tCamera.OrthographicHeight = framed * (1f - CurrentPunch());\n\n\t\t// Looking down +X with no rotation of its own, which is the orientation the views lay\n\t\t// the arena out for.\n\t\tWorldPosition = new Vector3( -Distance, 0f, 0f ) + CurrentShake();\n\t\tWorldRotation = Rotation.Identity;\n\t}\n\n\tprivate float CurrentPunch()\n\t{\n\t\tif ( punchAmount <= 0f ) return 0f;\n\n\t\tpunchAge += Time.Delta;\n\n\t\tvar t = Ease.Progress( punchAge, GameConfig.CameraPunchDuration );\n\n\t\tif ( t >= 1f )\n\t\t{\n\t\t\tpunchAmount = 0f;\n\t\t\treturn 0f;\n\t\t}\n\n\t\treturn punchAmount * Ease.Pulse( t );\n\t}\n\n\tprivate Vector3 CurrentShake()\n\t{\n\t\tif ( shakeAmount <= 0f ) return Vector3.Zero;\n\n\t\tshakeAge += Time.Delta;\n\n\t\tvar t = Ease.Progress( shakeAge, GameConfig.CameraShakeDuration );\n\n\t\tif ( t >= 1f )\n\t\t{\n\t\t\tshakeAmount = 0f;\n\t\t\treturn Vector3.Zero;\n\t\t}\n\n\t\tvar strength = shakeAmount * (1f - Ease.OutCubic( t ));\n\n\t\t// Two mismatched frequencies read as random over the fraction of a second a shake\n\t\t// lasts, with no noise source and no per-frame allocation. Only in the tray plane -\n\t\t// shaking along the view axis would change the apparent scale of everything.\n\t\treturn new Vector3(\n\t\t\t0f,\n\t\t\tMathF.Sin( Time.Now * 97f ) * strength,\n\t\t\tMathF.Sin( Time.Now * 61f + 1.7f ) * strength );\n\t}\n}\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "ui/controlsheet/controlsheetgroupheader.cs.scss",
            "FileName": "controlsheetgroupheader.cs.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "ControlSheetGroupHeader\r\n{\r\n\tfont-size: 1.33rem;\r\n\tcolor: red;\r\n\tgap: 2px;\r\n\talign-items: center;\r\n\r\n\t&.hidden\r\n\t{\r\n\t\tdisplay: none;\r\n\t}\r\n\r\n\t> .title\r\n\t{\r\n\t\tfont-weight: 600;\r\n\t}\r\n\r\n\t&.has-toggle\r\n\t{\r\n\t\tcursor: pointer;\r\n\t\topacity: 0.8;\r\n\r\n\t\t&:before\r\n\t\t{\r\n\t\t\tcontent: ' ';\r\n\t\t\twidth: 22px;\r\n\t\t\theight: 22px;\r\n\t\t\tbackground-color: #000a;\r\n\t\t\talign-items: center;\r\n\t\t\tjustify-content: center;\r\n\t\t\ttext-align: center;\r\n\t\t\tborder-radius: 5px;\r\n\t\t\tborder: 1px solid #555;\r\n\t\t}\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\topacity: 1;\r\n\r\n\t\t\t&:before\r\n\t\t\t{\r\n\t\t\t\tborder-color: #888;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t&.checked\r\n\t\t{\r\n\t\t\t> .title\r\n\t\t\t{\r\n\t\t\t\tcolor: white;\r\n\t\t\t}\r\n\r\n\t\t\t&:before\r\n\t\t\t{\r\n\t\t\t\tcontent: '\u2713';\r\n\t\t\t\tfont-weight: bold;\r\n\t\t\t\tcolor: #08f;\r\n\t\t\t\tborder-color: #08f;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "ui/components/packagecard.razor.scss",
            "FileName": "packagecard.razor.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "@import \"/styles/_theme.scss\";\r\n$background-size: 8px;\r\n\r\nPackageCard\r\n{\r\n\tflex-shrink: 0;\r\n\r\n\t&:hover\r\n\t{\r\n\t\tsound-in: \"ui.button.over\";\r\n\t}\r\n\r\n\t&:active\r\n\t{\r\n\t\tsound-in: \"ui.button.press\";\r\n\t}\r\n\r\n\tflex-direction: column;\r\n\tposition: relative;\r\n\tbackground-color: rgba( $default-950, 0 );\r\n\tborder-radius: $rounding-default;\r\n\ttransition: all 150ms ease;\r\n\tcursor: pointer;\r\n\tz-index: 0;\r\n\theight: 200px;\r\n\r\n\t.image\r\n\t{\r\n\t\tflex-grow: 1;\r\n\t\tflex-shrink: 0;\r\n\t\ttransition: all 150ms ease;\r\n\t\tborder: 1px solid rgba( white, 0.025 );\r\n\t\taspect-ratio: 16 / 9;\r\n\t\tbackground-position: center;\r\n\t\tbackground-size: cover;\r\n\t\tborder-radius: $rounding-small;\r\n\t\tposition: relative;\r\n\t}\r\n\r\n\tcolumn\r\n\t{\r\n\t\tpadding: 8px 2px; // Optically aligned\r\n\t}\r\n\r\n\t\r\n\r\n\t&.list\r\n\t{\r\n\t\tflex-grow: 1;\r\n\t\theight: 64px;\r\n\t\tflex-direction: row;\r\n\t\tgap: 12px;\r\n\t\tpadding: 4px;\r\n\r\n\t\t.inner column\r\n\t\t{\r\n\t\t\tflex-grow: 1;\r\n\t\t}\r\n\r\n\t\t.image\r\n\t\t{\r\n\t\t\theight: 100%;\r\n\t\t\taspect-ratio: 1;\r\n\t\t\tflex-grow: 0;\r\n\t\t\tflex-shrink: 0;\r\n\t\t}\r\n\r\n\t\tcolumn\r\n\t\t{\r\n\t\t\tgap: 3px;\r\n\t\t\tjustify-content: center;\r\n\t\t}\r\n\r\n\t\t.package-title\r\n\t\t{\r\n\t\t\tflex-shrink: 0;\r\n\t\t\tmax-width: 500px;\r\n\t\t}\r\n\r\n\t\t.package-users\r\n\t\t{\r\n\t\t\ttop: 1px;\r\n\t\t\tright: 1px;\r\n\t\t}\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\tbackground-color: $default-800;\r\n\t\t\ttransform: none;\r\n\r\n\t\t\t&::after\r\n\t\t\t{\r\n\t\t\t\tdisplay: none;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n\r\n\t&.wide\r\n\t{\r\n\t\theight: 250px;\r\n\r\n\t\t.package-title\r\n\t\t{\r\n\t\t\tmax-width: 300px;\r\n\t\t}\r\n\t}\r\n\r\n\t&.small\r\n\t{\r\n\t\theight: 200px;\r\n\t\taspect-ratio: 3/4;\r\n\r\n\t\t.image\r\n\t\t{\r\n\t\t\taspect-ratio: 1;\r\n\t\t}\r\n\r\n\t\t.package-title\r\n\t\t{\r\n\t\t\tmax-width: 140px;\r\n\t\t}\r\n\t}\r\n\r\n\t&.tall\r\n\t{\r\n\t\theight: 400px;\r\n\r\n\t\t.image\r\n\t\t{\r\n\t\t\taspect-ratio: 9/16;\r\n\t\t}\r\n\r\n\t\t.package-title\r\n\t\t{\r\n\t\t\tmax-width: 200px;\r\n\t\t}\r\n\t}\r\n\r\n\t.package-title\r\n\t{\r\n\t\ttext-overflow: ellipsis;\r\n\t\tmax-height: 24px;\r\n\t\tflex-shrink: 1;\r\n\t\tfont-size: 14px;\r\n\t}\r\n\r\n\t.package-users\r\n\t{\r\n\t\tposition: absolute;\r\n\t\tbottom: 4px;\r\n\t\tright: 4px;\r\n\t\tbackground-color: rgba( 10, 40, 10, 0.95 );\r\n\t\tpadding: 3px 5px;\r\n\t\tborder-radius: 2px;\r\n\t\tjustify-content: center;\r\n\t\talign-items: center;\r\n\t\tgap: 3px;\r\n\t\tfont-size: 11px;\r\n\t\tcolor: #def;\r\n\t\tborder: 1px solid #252;\r\n\t\tcolor: #2f3;\r\n\r\n\t\t&:before\r\n\t\t{\r\n\t\t\tcontent: '\u25cf';\r\n\t\t\tfont-size: 0.5rem;\r\n\t\t}\r\n\t}\r\n\t// Hover effect (not for list)\r\n\t&:not(.list)\r\n\t{\r\n\t\t&::after\r\n\t\t{\r\n\t\t\tcontent: \"\";\r\n\t\t\tposition: absolute;\r\n\t\t\ttop: 0;\r\n\t\t\tleft: 0;\r\n\t\t\tbottom: 0;\r\n\t\t\tright: 0;\r\n\t\t\tbackground-color: rgba( $default-950, 0 );\r\n\t\t\ttransition: all 150ms ease;\r\n\t\t\tz-index: -10;\r\n\t\t\tborder-radius: $rounding-large;\r\n\t\t\tpointer-events: none;\r\n\t\t}\r\n\r\n\t\t&:hover\r\n\t\t{\r\n\t\t\ttransform: scale( 1.05 );\r\n\r\n\t\t\t&::after\r\n\t\t\t{\r\n\t\t\t\tbackground-color: $default-800;\r\n\t\t\t\ttop: -$background-size;\r\n\t\t\t\tleft: -$background-size;\r\n\t\t\t\tright: -$background-size;\r\n\t\t\t\tbottom: -$background-size;\r\n\t\t\t\tbox-shadow: 0 0 25px rgba( black, 0.3 );\r\n\t\t\t}\r\n\r\n\t\t\tz-index: 100;\r\n\t\t\tbackground-color: $default-900;\r\n\t\t}\r\n\t}\r\n}\r\n\r\n\r\n.package-card.list packageflairbar\r\n{\r\n\tdisplay: none;\r\n}\r\n\r\n.package-card.list .package-users\r\n{\r\n\tdisplay: none;\r\n}"
        },
        {
            "Ident": "fpkreastudios.coilgarden",
            "Path": "styles/form.scss",
            "FileName": "form.scss",
            "PackageType": "game",
            "CodeKind": "Game",
            "AssetVersionId": 340479,
            "Code": "\r\n$form-control-height: 28px !default;\r\n\r\n@import \"form/_checkbox.scss\";\r\n@import \"form/_switch.scss\";\r\n@import \"form/_dropdown.scss\";\r\n@import \"form/_coloreditor.scss\";\r\n@import \"form/_colorproperty.scss\";\r\n\r\n.form\r\n{\r\n\tflex-direction: column;\r\n\talign-items: stretch;\r\n\tjustify-content: flex-start;\r\n\toverflow: scroll;\r\n}\r\n\r\n.field-group\r\n{\r\n\tflex-direction: column;\r\n\tflex-shrink: 0;\r\n}\r\n\r\n.field-header\r\n{\r\n\tflex-shrink: 0;\r\n}\r\n\r\n.field\r\n{\r\n\tcolor: white;\r\n\tfont-size: 14px;\r\n\tflex-shrink: 0;\r\n\tflex-grow: 0;\r\n\r\n\t> .label\r\n\t{\r\n\t\tflex-grow: 0;\r\n\t\tflex-shrink: 0;\r\n\t\tfont-weight: 600;\r\n\t\topacity: 0.4;\r\n\t\twidth: 20%;\r\n\t\tfont-size: 13px;\r\n\t}\r\n\r\n\t> .control\r\n\t{\r\n\t\tflex-shrink: 0;\r\n\t\tflex-grow: 1;\r\n\t\tflex-direction: column;\r\n\t}\r\n}\r\n\r\n.is-vertical > .field, .field.is-vertical\r\n{\r\n\tflex-direction: column;\r\n\r\n\t> .label\r\n\t{\r\n\t\twidth: auto;\r\n\t\theight: auto;\r\n\t}\r\n}"
        }
    ]
}