Core/GameConfig.cs

Static configuration class for the game Coilgarden. It declares dozens of public constants that define arena size, snake behaviour, pacing, visuals, audio and UI tuning defaults and limits used across the game.

namespace Coilgarden;

/// <summary>
/// Every tuning value in the game, in one place.
/// <para>
/// The rule is that no gameplay number appears at a call site. When a value needs
/// changing there is exactly one line to change, and the whole design of the game can be
/// read off this file - which is the point. Values the <em>player</em> should be able to
/// change will live in a settings type when there is something worth changing; these are
/// the designer's.
/// </para>
/// <para>
/// Values that affect how a run <em>behaves</em> are bundled into <see cref="GameRules"/>
/// and travel with the run. The constants here are that bundle's defaults plus the limits
/// used to clamp it.
/// </para>
/// </summary>
public static class GameConfig
{
	// ------------------------------------------------------------------ arena

	/// <summary>Odd numbers, so the arena has a true centre cell for the snake to start on.</summary>
	public const int GridWidth = 19;

	public const int GridHeight = 19;

	/// <summary>Below this there is no room to turn around; above it a cell is unreadably small.</summary>
	public const int MinGridSize = 5;

	public const int MaxGridSize = 60;

	/// <summary>World units per cell. Only the presentation layer cares.</summary>
	public const float CellSize = 40f;

	// ------------------------------------------------------------------ snake

	public const int StartLength = 4;

	public const Direction StartDirection = Direction.Right;

	/// <summary>
	/// How many turns can be waiting at once. Two is deliberate: it covers the one real
	/// case - a quick double-tap to round a corner - without letting a player queue up a
	/// path they can no longer see the consequences of.
	/// </summary>
	public const int MaxBufferedTurns = 2;

	/// <summary>
	/// A hard ceiling on the above. Past about four, a queued path outlives the player's
	/// picture of the board and the snake stops feeling like it is under their control.
	/// </summary>
	public const int MaxBufferedTurnsLimit = 4;

	// ------------------------------------------------------------------ pacing

	/// <summary>
	/// Seconds per logical tick at the start of a run. Roughly six and a half steps a
	/// second: quick enough to feel alive, slow enough that a 19-cell arena is readable.
	/// </summary>
	public const float BaseTickInterval = 0.155f;

	/// <summary>
	/// A still beat between asking to play and the snake actually moving.
	/// <para>
	/// Without it the first tick lands a sixth of a second after the button is clicked, which is
	/// long enough to start a run and nowhere near long enough to get a hand back to the
	/// keyboard - and the whole first run is only about a second and a half from the wall. Short
	/// enough that a keyboard player who started with a direction key never notices it, and it
	/// gives the game-start figure somewhere to land.
	/// </para>
	/// </summary>
	public const float StartGrace = 0.6f;

	/// <summary>
	/// How long the death is allowed to play before the game-over card covers it.
	/// <para>
	/// The wilt runs for <see cref="DeathDuration"/>, and the card used to arrive on the same
	/// frame the run ended - so the one animation the player most wants to see was hidden behind
	/// a panel before it had begun. Long enough for the collapse to read, short enough that
	/// nobody is waiting to press restart.
	/// </para>
	/// </summary>
	public const float GameOverCardDelay = 0.38f;

	/// <summary>
	/// Logical ticks a single frame may run.
	/// <para>
	/// One, deliberately. A frame that overran - a hitch, a shader compile, a window drag -
	/// would otherwise be paid back as several steps the player never saw and could not
	/// react to, which is a death with no cause. Losing a fraction of a step of pace during
	/// a stutter is invisible; dying during one is not.
	/// </para>
	/// </summary>
	public const int MaxTicksPerFrame = 1;

	/// <summary>
	/// Pause the run when the window loses focus.
	/// <para>
	/// Without this, alt-tabbing kills a good run: the clock keeps ticking against a snake
	/// nobody is steering.
	/// </para>
	/// </summary>
	public const bool AutoPauseOnFocusLoss = true;

	// ------------------------------------------------------------------ scoring

	public const int ApplePoints = 10;

	// ------------------------------------------------------------------ camera

	/// <summary>How far the camera sits back from the arena. Orthographic, so this only has to clear the geometry.</summary>
	public const float CameraDistance = 1400f;

	/// <summary>
	/// Multiplier on the arena size when framing it.
	/// <para>
	/// It has to leave room above the tray for the score, which is the one thing on screen
	/// during a run - at 1.15 the tray filled the frame and clipped the number against the top
	/// edge. Screens are wider than they are tall and the arena is square, so the horizontal
	/// slack this also creates costs nothing.
	/// </para>
	/// </summary>
	public const float CameraPadding = 1.28f;

	// ------------------------------------------------------------------ visual scale
	// Sizes are fractions of a cell, so changing CellSize rescales the whole look.

	/// <summary>Head diameter. Clearly the largest thing on the board after the tray.</summary>
	public const float HeadDiameter = 1.30f;

	/// <summary>Body diameter. Just over a cell, so consecutive segments overlap into one soft form.</summary>
	public const float BodyDiameter = 1.20f;

	/// <summary>
	/// Tail diameter, reached at the last segment. The taper is what makes it a snake.
	/// <para>
	/// Not much below a cell, deliberately. Segment centres are exactly one cell apart, so a
	/// pair separates as soon as their average diameter drops under one - and at 0.66 the tail
	/// end came apart into loose beads. Enough taper to read as a tail, little enough that only
	/// the very last joint shows daylight.
	/// </para>
	/// </summary>
	public const float TailDiameter = 0.80f;

	/// <summary>Eye diameter, as a fraction of the head.</summary>
	public const float EyeDiameter = 0.245f;

	/// <summary>How far the eyes sit forward of the head's centre, as a fraction of the head.</summary>
	public const float EyeForward = 0.235f;

	/// <summary>How far apart the eyes sit, as a fraction of the head.</summary>
	public const float EyeSpread = 0.255f;

	public const float AppleDiameter = 0.88f;

	public const float AppleStemLength = 0.30f;

	/// <summary>Height of the tray's rim above the sand, in cells.</summary>
	public const float RimHeight = 0.62f;

	/// <summary>Thickness of the rim wall, in cells.</summary>
	public const float RimThickness = 0.78f;

	/// <summary>How deep the sand sits below the top of the rim, in cells.</summary>
	public const float SandInset = 0.34f;

	// ------------------------------------------------------------------ feel
	// Durations are seconds. Almost everything here is between 80 and 250ms: long enough to
	// perceive, short enough that it is never something the player waits for. If an effect is
	// long enough to notice *as an animation*, it is too long.

	/// <summary>
	/// Movement between cells is <b>linear</b>, and this is the one place easing would be
	/// wrong. Easing each step would make the snake accelerate and brake once per tick, which
	/// reads as a limp rather than as travel. Character comes from what happens at the events -
	/// turns, eating, dying - not from the constant march.
	/// </summary>
	public const bool LinearMovement = true;

	/// <summary>How long the head's squash lasts after a direction change.</summary>
	public const float TurnPunchDuration = 0.17f;

	/// <summary>
	/// How far the head squashes into a turn, as a fraction of its size. Small on purpose: the
	/// head is the thing the player tracks, so it has to stay the same readable blob.
	/// </summary>
	public const float TurnSquash = 0.20f;

	/// <summary>A new segment scales in rather than appearing. Fast, or growth feels laggy.</summary>
	public const float GrowDuration = 0.20f;

	/// <summary>Idle breathing along the body. Secondary motion, purely to say "alive".</summary>
	public const float BreathAmount = 0.030f;

	public const float BreathSpeed = 2.1f;

	/// <summary>Segments per wave of the breathing ripple. Longer than the snake early on, so it reads as one gentle swell.</summary>
	public const float BreathWavelength = 5.5f;

	// ------------------------------------------------------------------ apple

	/// <summary>Scale-in on spawn, with a slight overshoot, so an apple never just appears.</summary>
	public const float AppleSpawnDuration = 0.26f;

	/// <summary>The pop as it is eaten. Very fast - this is a reward, not a cutscene.</summary>
	public const float AppleEatDuration = 0.13f;

	/// <summary>How much the apple swells at the peak of the eat pop.</summary>
	public const float AppleEatSwell = 0.55f;

	/// <summary>Idle bob, in cells, towards and away from the viewer.</summary>
	public const float AppleBobAmount = 0.055f;

	public const float AppleBobSpeed = 1.7f;

	/// <summary>Idle spin, degrees per second. Slow enough to be subliminal.</summary>
	public const float AppleSpinSpeed = 22f;

	// ------------------------------------------------------------------ camera

	/// <summary>
	/// Zoom punch on eating, as a fraction of the framed height. About one percent - the
	/// intent is that the player feels it and never sees it.
	/// </summary>
	public const float CameraPunchAmount = 0.014f;

	public const float CameraPunchDuration = 0.20f;

	/// <summary>Shake on death, in world units. Single-digit pixels on screen.</summary>
	public const float CameraShakeAmount = 7f;

	public const float CameraShakeDuration = 0.36f;

	// ------------------------------------------------------------------ death

	/// <summary>
	/// The whole death animation. Deliberately short: the target feeling is "again",
	/// immediately, and a long death is the fastest way to lose that.
	/// </summary>
	public const float DeathDuration = 0.52f;

	/// <summary>Delay per segment as the collapse travels down the body.</summary>
	public const float DeathStagger = 0.022f;

	/// <summary>How long the head flashes white at the moment of impact.</summary>
	public const float DeathFlashDuration = 0.14f;

	/// <summary>
	/// The size a wilted segment settles at, as a fraction of its normal size. Not zero: the
	/// dead snake stays on the tray so the player can see the shape of the run they just made,
	/// and shrinking all the way left a row of specks that read as debris.
	/// </summary>
	public const float DeathRestScale = 0.58f;

	// ------------------------------------------------------------------ particles

	/// <summary>Sparkles per apple. Budgeted per event, not per frame.</summary>
	public const int SparklesPerApple = 10;

	/// <summary>Hard global cap. No sequence of events can exceed this.</summary>
	public const int SparkleBudget = 48;

	public const float SparkleLife = 0.44f;

	/// <summary>Launch speed in cells per second.</summary>
	public const float SparkleSpeed = 3.1f;

	/// <summary>Diameter in cells, at birth. They shrink to nothing over their life.</summary>
	public const float SparkleSize = 0.30f;

	/// <summary>Pulls sparkles back down to the sand, in cells per second squared.</summary>
	public const float SparkleGravity = 9f;

	// ------------------------------------------------------------------ ui feel

	/// <summary>How long the score takes to count up to a new value.</summary>
	public const float ScoreCountDuration = 0.24f;

	// ------------------------------------------------------------------ audio
	// Group levels, as multipliers applied on top of each SoundEvent's own decibels. These are
	// the balance: the beds sit well under the effects, because music in a game played for
	// hundreds of short runs has to be something the player stops noticing.

	public const float MasterVolume = 1.0f;

	/// <summary>Effects carry the feedback, so they are the loudest group.</summary>
	public const float SfxVolume = 0.85f;

	/// <summary>
	/// The music bed. Roughly a third of the effects, which is what keeps it supporting rather
	/// than performing - if a player ever thinks about the music during a run it is too loud.
	/// </summary>
	public const float MusicVolume = 0.34f;

	/// <summary>Barely present. Its only job is to stop silence sounding like a fault.</summary>
	public const float AmbienceVolume = 0.20f;

	/// <summary>
	/// Minimum gap between repeats of the turn sound. Two turns can be queued inside a fifth of
	/// a second, and two identical plucks that close together read as a glitch, not two events.
	/// </summary>
	public const float TurnSoundGap = 0.09f;

	/// <summary>Minimum gap for the apple sound. Generous, since it can never legitimately double up.</summary>
	public const float AppleSoundGap = 0.05f;

	/// <summary>
	/// Minimum gap for the UI hover tick. A cursor dragged across a column of buttons fires a
	/// hover on every one of them; without a floor that reads as a buzz rather than feedback.
	/// </summary>
	public const float UiHoverSoundGap = 0.05f;

	public const float UiClickSoundGap = 0.03f;

	/// <summary>
	/// How far the apple sound rises across a run, in semitones, and how many apples it takes to
	/// get there.
	/// <para>
	/// A rising pitch is the cheapest possible progress meter: the player hears their run getting
	/// further along without a single number changing. It is capped so it never becomes shrill,
	/// and it resets with the run.
	/// </para>
	/// </summary>
	public const float ApplePitchRise = 5f;

	public const int ApplePitchRiseOver = 24;

	/// <summary>Seconds to crossfade between the calm and playing beds.</summary>
	public const float MusicCrossfade = 1.4f;

	// ------------------------------------------------------------------ settings ui

	/// <summary>
	/// The ceiling a player can push a volume to. One, rather than a boost range: the meter is
	/// ten pips and one tap of a stepper is one pip, which is only true while the ceiling and
	/// the step divide evenly. Anyone who needs it louder than designed has a system volume.
	/// </summary>
	public const float MaxSettingsVolume = 1f;

	/// <summary>How far one tap of a volume stepper moves it. One pip of <see cref="SettingsMeterPips"/>.</summary>
	public const float SettingsVolumeStep = 0.1f;

	/// <summary>Segments in a volume meter. Ten, so a pip is exactly one tap of the stepper.</summary>
	public const int SettingsMeterPips = 10;
}