🔍 s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
🔗 Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=fpkreastudios.coilgarden&take=20
Showing code results for query:
*
(75 total matches found)
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%;
}
}
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; }
}
}
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";
}
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&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;
}
}
}
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;
}
}
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;
}
}
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;
}
}
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 );
}
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 );
}
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;
}
}
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;
}
}
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
}
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;
}
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 <key> <ambient> <contactShadows></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;
}
}
}
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 );
}
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;
}
}
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 );
}
}
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;
}
}
}
}
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;
}
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&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 <key> <ambient> <contactShadows></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}"
}
]
}