DiamondGame.cs
using Sandbox;
using Sandbox.UI;

namespace Diamonds;

/// <summary>Hosts the Painter canvas on a ScreenPanel and routes keyboard input.</summary>
public sealed class DiamondGame : PanelComponent
{
	readonly DiamondBoard board = new();
	readonly DiamondFeedback feedback = new();
	readonly DiamondDebris debris = new();
	readonly DiamondAtmosphere atmosphere = new();
	DiamondScoreEffects scoreEffects = new();
	DiamondMusic music = new();
	DiamondPanel canvas;
	bool gameStarted;
	// B both closes overlays and moves right; a press that closed one is ignored for movement until released.
	bool closeMenuHeld;
	// A (confirming PLAY or clicking RESTART) and the sticks (aiming the virtual cursor) also play. Each one still held
	// when a game starts is ignored for play until released, so the press that started it never soft-drops or moves.
	[System.Flags] enum PadInput { None = 0, SoftDrop = 1, Left = 2, Right = 4, Up = 8, Down = 16 }
	PadInput carriedPad;
	// The board reset whose game-over score was submitted.
	int submittedResetVersion = -1;
	// The board reset started by diamonds_fill, kept off the leaderboard.
	int debugFilledResetVersion = -1;
	// Board counters already added to the lifetime achievement stats, for the board reset bankedResetVersion.
	int bankedResetVersion = -1;
	long bankedBroken, bankedCrushed, bankedScore;
	RealTimeSince sinceAchievementBank;
	// A long game is banked now and then, so a crash or forced exit loses at most this much of its progress.
	const float AchievementBankInterval = 60;
	MouseVisibility previousMouseVisibility;
	// Queries the engine's modal state: the s&box escape menu freezes the whole game, like Block Party.
	readonly Game.Overlay engineOverlay = new();

	/// <summary>Debug score applied when starting or restarting a game. Zero or negative values start at zero.</summary>
	[Property, Group( "Debug" ), Title( "Starting Score" )]
	public long StartingScore
	{
		get => board.StartingScore;
		set => board.StartingScore = value;
	}

	/// <summary>Editor only: generated players mixed into the leaderboard, to preview scrolling and a busy board. Zero shows only real scores.</summary>
	[Property, Group( "Debug" ), Title( "Leaderboard Test Players" ), Range( 0, DiamondLeaderboard.MaxEntries )]
	public int LeaderboardTestPlayers { get; set; }

	/// <summary>Editor only, for thumbnail screenshots: draw just the background dye (the menu dye on the title, the board dye in game) and nothing else.</summary>
	[Property, Group( "Thumbnail" ), Title( "Only Render Dye" )]
	public bool ThumbnailOnlyDye { get; set; }

	/// <summary>Editor only, for thumbnail screenshots: never draw any dye, including the static Disable Fluid background and the menu dye.</summary>
	[Property, Group( "Thumbnail" ), Title( "Hide Dye" )]
	public bool ThumbnailHideDye { get; set; }

	/// <summary>Editor only, for bright thumbnail screenshots: brighten the dye by Vibrancy.</summary>
	[Property, Group( "Thumbnail" ), Title( "Vibrant Dye" )]
	public bool ThumbnailVibrantDye { get; set; }

	/// <summary>How far Vibrant Dye pushes the dye from its normal faint wash (0) to opaque, fully saturated color (1).</summary>
	[Property, Group( "Thumbnail" ), Title( "Vibrancy" ), Range( 0, 1 ), ShowIf( nameof( ThumbnailVibrantDye ), true )]
	public float ThumbnailVibrancy { get; set; } = 0.5f;

	/// <summary>Fill both bottom corner pockets. Changing this starts a fresh game.</summary>
	[Property, Group( "Board" ), Title( "Fill Corner Pockets" )]
	public bool FillCornerPockets
	{
		get => board.FillCornerPockets;
		set => board.FillCornerPockets = value;
	}

	/// <summary>Size of the glowing marker at the active piece's landing contact points. One is the original size.</summary>
	[Property, Group( "Board" ), Title( "Landing Indicator Scale" ), Range( 0, 4 )]
	public float LandingIndicatorScale { get; set; } = 1;

	/// <summary>Seconds a sideways step takes to draw. Collision still snaps to the lattice; zero draws it instantly.</summary>
	[Property, Group( "Controls" ), Title( "Move Smoothing (s)" ), Range( 0, 0.3f )]
	public float MoveSmoothing
	{
		get => board.MoveSmoothing;
		set => board.MoveSmoothing = value;
	}

	/// <summary>Seconds a quarter turn takes to draw. Collision still snaps to the lattice; zero draws it instantly.</summary>
	[Property, Group( "Controls" ), Title( "Rotate Smoothing (s)" ), Range( 0, 0.3f )]
	public float RotateSmoothing
	{
		get => board.RotateSmoothing;
		set => board.RotateSmoothing = value;
	}

	/// <summary>How long rotate must be held before it starts repeating.</summary>
	[Property, Group( "Controls" ), Title( "Rotate Repeat Delay (s)" ), Range( 0.05f, 1 )]
	public float RotateRepeatDelay
	{
		get => board.RotateRepeatDelay;
		set => board.RotateRepeatDelay = value;
	}

	/// <summary>Seconds between repeated turns while rotate stays held.</summary>
	[Property, Group( "Controls" ), Title( "Rotate Repeat Interval (s)" ), Range( 0.03f, 1 )]
	public float RotateRepeatInterval
	{
		get => board.RotateRepeatInterval;
		set => board.RotateRepeatInterval = value;
	}

	/// <summary>Normal fall speed at the start of a game, in diamond heights per second.</summary>
	[Property, Group( "Difficulty" ), Title( "Starting Fall Speed" ), Range( 0.1f, 8 )]
	public float StartingFallSpeed
	{
		get => board.StartingFallSpeed;
		set => board.StartingFallSpeed = value;
	}

	/// <summary>Minutes until normal falling reaches the chosen speed multiplier.</summary>
	[Property, Group( "Difficulty" ), Title( "Ramp Reference Time (min)" ), Range( 0.5f, 60 )]
	public float FallRampMinutes
	{
		get => board.FallRampMinutes;
		set => board.FallRampMinutes = value;
	}

	/// <summary>Normal fall speed at the reference time, relative to the starting speed.</summary>
	[Property, Group( "Difficulty" ), Title( "Speed at Reference Time (x)" ), Range( 1, 10 )]
	public float FallRampMultiplier
	{
		get => board.FallRampMultiplier;
		set => board.FallRampMultiplier = value;
	}

	/// <summary>Values above one delay most of the speed increase until later in the ramp.</summary>
	[Property, Group( "Difficulty" ), Title( "Ramp Curve Exponent" ), Range( 0.5f, 4 )]
	public float FallRampExponent
	{
		get => board.FallRampExponent;
		set => board.FallRampExponent = value;
	}

	/// <summary>Minutes of play before pieces can gain extra distinct colors. Chance is zero at this time.</summary>
	[Property, Group( "Difficulty - Colors" ), Title( "Mix Starts At (min)" ), Range( 0, 60 )]
	public float ColorMixStartMinutes
	{
		get => board.ColorMixStartMinutes;
		set => board.ColorMixStartMinutes = value;
	}

	/// <summary>Minutes after the mix starts until the reference chance is reached.</summary>
	[Property, Group( "Difficulty - Colors" ), Title( "Mix Reference Time (min)" ), Range( 0.5f, 60 )]
	public float ColorMixRampMinutes
	{
		get => board.ColorMixRampMinutes;
		set => board.ColorMixRampMinutes = value;
	}

	/// <summary>Chance of a three- or four-color piece at the reference time. The chance keeps rising afterward, up to 100%.</summary>
	[Property, Group( "Difficulty - Colors" ), Title( "Mix Chance at Reference (%)" ), Range( 0, 100 )]
	public float ColorMixChanceAtReference
	{
		get => board.ColorMixChanceAtReference;
		set => board.ColorMixChanceAtReference = value;
	}

	/// <summary>Higher values keep the mix chance low for longer after it starts.</summary>
	[Property, Group( "Difficulty - Colors" ), Title( "Mix Curve Exponent" ), Range( 0.5f, 4 )]
	public float ColorMixRampExponent
	{
		get => board.ColorMixRampExponent;
		set => board.ColorMixRampExponent = value;
	}

	/// <summary>Among varied four-diamond pieces, the percentage with four different colors rather than three.</summary>
	[Property, Group( "Difficulty - Colors" ), Title( "Four-Color Share (%)" ), Range( 0, 100 )]
	public float FourColorShare
	{
		get => board.FourColorShare;
		set => board.FourColorShare = value;
	}

	/// <summary>Scales gameplay dye opacity. One preserves the original appearance.</summary>
	[Property, Group( "Fluid" ), Title( "Dye Opacity" ), Range( 0, 1 )]
	public float DyeOpacity
	{
		get => atmosphere.Opacity;
		set => atmosphere.Opacity = value;
	}

	/// <summary>Total dye added at each piece spawn, divided among its diamond colors.</summary>
	[Property, Group( "Fluid" ), Title( "Spawn Dye Amount" ), Range( 0, 30 )]
	public float SpawnDyeAmount
	{
		get => atmosphere.SpawnDyeAmount;
		set => atmosphere.SpawnDyeAmount = value;
	}

	/// <summary>Dye added in the broken diamond's color for each shatter.</summary>
	[Property, Group( "Fluid" ), Title( "Shatter Dye Amount" ), Range( 0, 15 )]
	public float ShatterDyeAmount
	{
		get => atmosphere.ShatterDyeAmount;
		set => atmosphere.ShatterDyeAmount = value;
	}

	/// <summary>Minimum seconds between explosion dye pushes. Each batch pushes at most eight gems; dye emission is never throttled.</summary>
	[Property, Group( "Fluid" ), Title( "Shatter Push Interval (s)" ), Range( 0, 1 )]
	public float ShatterPushInterval
	{
		get => atmosphere.ShatterPushInterval;
		set => atmosphere.ShatterPushInterval = value;
	}

	/// <summary>Percentage of dye faded per second, shared by every color and source.</summary>
	[Property, Group( "Fluid" ), Title( "Dye Fade Rate (%/s)" ), Range( 0, 10 )]
	public float DyeFadeRate
	{
		get => atmosphere.DyeFadeRate;
		set => atmosphere.DyeFadeRate = value;
	}

	/// <summary>Scales launch speed for impact, rotation, sliding, and shatter sparks. One is the original force.</summary>
	[Property, Group( "Sparks" ), Title( "Force Multiplier" ), Range( 0, 10 )]
	public float SparkForceMultiplier
	{
		get => debris.SparkForceMultiplier;
		set => debris.SparkForceMultiplier = value;
	}

	/// <summary>Spark bounce restitution, from zero to one. The original value was 0.24. Applies to existing sparks too.</summary>
	[Property, Group( "Sparks" ), Title( "Bounciness" ), Range( 0, 1 )]
	public float SparkBounciness
	{
		get => debris.SparkBounciness;
		set => debris.SparkBounciness = value;
	}

	/// <summary>Scales the original 0.5–0.8 second lifetime for newly emitted sparks. Four gives 2–3.2 seconds.</summary>
	[Property, Group( "Sparks" ), Title( "Lifetime Multiplier" ), Range( 0.1f, 10 )]
	public float SparkLifetimeMultiplier
	{
		get => debris.SparkLifetimeMultiplier;
		set => debris.SparkLifetimeMultiplier = value;
	}

	/// <summary>Lowest per-spark gravity scale for newly emitted sparks. One is the original world gravity.</summary>
	[Property, Group( "Sparks" ), Title( "Gravity Min" ), Range( 0, 4 )]
	public float SparkGravityMin
	{
		get => debris.SparkGravityMin;
		set => debris.SparkGravityMin = value;
	}

	/// <summary>Highest per-spark gravity scale for newly emitted sparks. Each spark picks uniformly between min and max.</summary>
	[Property, Group( "Sparks" ), Title( "Gravity Max" ), Range( 0, 4 )]
	public float SparkGravityMax
	{
		get => debris.SparkGravityMax;
		set => debris.SparkGravityMax = value;
	}

	/// <summary>Shortest lifetime of a newly created shard before it blinks out, whether moving or resting.</summary>
	[Property, Group( "Shards" ), Title( "Lifetime Min (s)" ), Range( 0, 120 )]
	public float ShardLifetimeMin
	{
		get => debris.ShardLifetimeMin;
		set => debris.ShardLifetimeMin = value;
	}

	/// <summary>Longest lifetime of a newly created shard before it blinks out. Each shard picks uniformly between min and max.</summary>
	[Property, Group( "Shards" ), Title( "Lifetime Max (s)" ), Range( 0, 120 )]
	public float ShardLifetimeMax
	{
		get => debris.ShardLifetimeMax;
		set => debris.ShardLifetimeMax = value;
	}

	/// <summary>Layered music level, on top of the Music mixer and scaled by the player's Options volumes.</summary>
	[Property, Group( "Music" ), Title( "Volume" ), Range( 0, 1 )]
	public float MusicVolume { get; set; } = 0.5f;

	/// <summary>Forces a music tier (0–3) for auditioning. -1 follows pieces placed.</summary>
	[Property, Group( "Music" ), Title( "Tier Override" ), Range( -1, 3 )]
	public int MusicTierOverride { get; set; } = -1;

	/// <summary>Tier the menu music holds at. Each menu visit picks a fresh random motif and clips at this tier.</summary>
	[Property, Group( "Music" ), Title( "Menu Tier" ), Range( 0, 3 )]
	public int MusicMenuTier { get; set; } = 2;

	/// <summary>Pieces placed before the music moves to tier 1.</summary>
	[Property, Group( "Music" ), Title( "Tier 1 At (pieces)" ), Range( 0, 200 )]
	public int MusicTier1Pieces { get; set; } = 4;

	/// <summary>Pieces placed before the music moves to tier 2.</summary>
	[Property, Group( "Music" ), Title( "Tier 2 At (pieces)" ), Range( 0, 200 )]
	public int MusicTier2Pieces { get; set; } = 12;

	/// <summary>Pieces placed before the music moves to tier 3.</summary>
	[Property, Group( "Music" ), Title( "Tier 3 At (pieces)" ), Range( 0, 200 )]
	public int MusicTier3Pieces { get; set; } = 28;

	/// <summary>Chance per 8-bar swap roll that a layer changes to another clip fitting the current tier. Tier changes only take effect through these rolls.</summary>
	[Property, Group( "Music" ), Title( "Swap Chance" ), Range( 0, 1 )]
	public float MusicSwapChance { get; set; } = 0.25f;

	/// <summary>Each swap in a roll multiplies Swap Chance for the layers still to roll (0.35: 25% for the first, then about 9%, 3%...), so several rarely change at once.</summary>
	[Property, Group( "Music" ), Title( "Subsequent Swap Multiplier" ), Range( 0, 1 )]
	public float MusicSwapFalloff { get; set; } = 0.35f;

	/// <summary>Editor only: overlay the live music state (tier, motif, clip per layer, mix levels).</summary>
	[Property, Group( "Music" ), Title( "Show Debug" )]
	public bool MusicDebug { get; set; }

	/// <summary>Looping 150 BPM clips. Each layer plays one eligible clip at a time; runs pick a motif from the clips' tags.</summary>
	[Property, Group( "Music" ), Title( "Clips" )]
	public List<DiamondMusicClip> MusicClips { get; set; } = new();

	// Hotload keeps old instances without running initializers for new fields, so rebuild the runtime player instead.
	DiamondMusic Music
	{
		get
		{
			if ( music is null || !music.IsIntact )
			{
				music?.Stop();
				music = new DiamondMusic();
			}
			return music;
		}
	}

	protected override void OnEnabled()
	{
		previousMouseVisibility = Mouse.Visibility;
		Mouse.Visibility = MouseVisibility.Visible;
		feedback.Prepare();
		DiamondDebris.Prepare();
		DiamondAchievements.CheckProgress();
	}

	protected override void OnDisabled()
	{
		BankAchievementProgress();
		canvas?.CloseTutorial();
		canvas?.CloseOptions();
		canvas?.CloseLeaderboard();
		canvas?.CancelTransition();
		canvas?.ClearRestartHold();
		canvas?.StopTitleAudio();
		canvas?.TitleEffects.Clear();
		canvas?.TitleDye.Clear();
		Mouse.Visibility = previousMouseVisibility;
		Input.EnableVirtualCursor = true;
		DiamondGamepad.Clear();
		DiamondHaptics.StopAll();
		feedback.Stop();
		music?.Stop();
		debris.Clear();
		scoreEffects?.Clear();
		atmosphere.Clear();
	}

	protected override void OnUpdate()
	{
		Mouse.Visibility = MouseVisibility.Visible;
		atmosphere.Enabled = true;
		atmosphere.DisableFluid = DiamondSettings.Current.DisableFluid;
		// Prepare a changed background even while Options has paused gameplay.
		if ( gameStarted ) atmosphere.Update( board, 0 );
		// PanelComponent recreates its root when disabled/enabled or hot reloaded.
		if ( Panel is null ) return;
		scoreEffects ??= new DiamondScoreEffects();
		if ( canvas is null || !canvas.IsValid() || canvas.Parent != Panel )
		{
			// Hotload can preserve the child while dropping our field reference.
			canvas = Panel.Children.OfType<DiamondPanel>().FirstOrDefault() ?? new DiamondPanel { Parent = Panel };
			canvas.Board = board;
			canvas.Debris = debris;
			canvas.Atmosphere = atmosphere;
			canvas.ScoreEffects = scoreEffects;
			foreach ( var stale in Panel.Children.OfType<DiamondPanel>().Where( p => p != canvas ).ToArray() ) stale.Delete();
		}
		canvas.ScoreEffects ??= scoreEffects;
		canvas.Atmosphere ??= atmosphere;
		canvas.TitleScreen = !gameStarted;
		canvas.PlayRequested = StartGame;
		canvas.QuitRequested = Game.Close;
		canvas.LeaveRequested = LeaveGame;
		// Every gameplay, effect, title and fade clock advances from here, so skipping them freezes the whole screen.
		bool escapeMenuOpen = engineOverlay.IsPauseMenuOpen;
		// The engine's virtual cursor moves the mouse with the left stick and clicks with A, which would fight
		// gameplay (and could click Leave) and the title menu's stick/A selection, so it only runs on overlays and
		// game over. Wait for A (soft drop / confirm) to be released first, so the press that got here can't click.
		bool menuCursor = canvas.ModalOpen || escapeMenuOpen || gameStarted && board.GameOver;
		Input.EnableVirtualCursor = menuCursor && (Input.EnableVirtualCursor || !Input.Down( "SoftDropAlt" ) && !Input.Down( "MenuConfirm" ));
		if ( !escapeMenuOpen )
		{
			canvas.UpdateTransition( Time.Delta );
			// Holding restart never pauses play; a completed hold fades through PlayRequested, and a zero delay restarts now.
			if ( canvas.UpdateRestartHold( Time.Delta, Input.Down( "Restart" ), DiamondSettings.Current.RestartDelay ) ) StartGame();
		}
		canvas.IsMuted = feedback.IsMuted;
		canvas.LandingPointScale = LandingIndicatorScale;
		canvas.OnlyDye = Game.IsEditor && ThumbnailOnlyDye;
		canvas.HideDye = Game.IsEditor && ThumbnailHideDye;
		canvas.DyeVibrancy = Game.IsEditor && ThumbnailVibrantDye ? ThumbnailVibrancy : 0;
		var music = Music;
		music.Clips = MusicClips;
		music.Volume = MusicVolume * DiamondSettings.MusicGain;
		music.TierOverride = MusicTierOverride;
		music.MenuTier = MusicMenuTier;
		music.SwapChance = MusicSwapChance;
		music.SwapFalloff = MusicSwapFalloff;
		music.TierPieces = (MusicTier1Pieces, MusicTier2Pieces, MusicTier3Pieces);
		// Options pauses play but leaves the music unducked, so its volume sliders are heard as set.
		// Pauses only muffle; the escape menu also lowers the volume.
		// A title/game switch starts the next screen's music as the old screen begins fading out.
		bool switching = canvas.TransitionPending;
		music.Update( board, switching ? canvas.TransitionToGame : gameStarted, feedback.IsMuted,
			escapeMenuOpen || canvas.TutorialOpen || board.Paused && !canvas.OptionsOpen, escapeMenuOpen, switching );
		canvas.MusicDebugText = Game.IsEditor && MusicDebug ? music.DebugText( board ) : null;
		canvas.LeaderboardTestPlayers = Game.IsEditor ? LeaderboardTestPlayers : 0;
		if ( escapeMenuOpen )
		{
			feedback.Suspend();
			canvas.StopTitleAudio();
			canvas.StopTutorialAudio();
			DiamondHaptics.StopAll();
			return;
		}
		canvas.UpdateTitleScreen( Time.Delta );
		DiamondGamepad.Update();
		DiamondHaptics.UpdateSource();
		// Pulses fired later this frame start on the next tick, a frame behind their sound at most.
		DiamondHaptics.Tick( Time.Delta );
		canvas.UpdateButtonHover();
		// Up/down arrows, W/S, D-pad and left stick step the title selection; Space, Enter and A confirm it.
		canvas.UpdateTitleMenu(
			Input.Pressed( "MenuUp" ) || Input.Pressed( "MenuUpAlt" ) || DiamondGamepad.UpPressed,
			Input.Pressed( "MenuDown" ) || Input.Pressed( "MenuDownAlt" ) || DiamondGamepad.DownPressed,
			Input.Pressed( "MenuConfirm" ) || Input.Pressed( "MenuConfirmAlt" ) );
		if ( canvas.ModalOpen ) closeMenuHeld = Input.Down( "MoveRightAlt" );
		else if ( !Input.Down( "MoveRightAlt" ) ) closeMenuHeld = false;
		var pad = HeldPad();
		// Out of play (title, game over, fades) everything held carries over; in play, each release clears its own.
		carriedPad = !gameStarted || board.GameOver || canvas.TransitionPending ? pad : carriedPad & pad;
		// The modal owns input and its own clock. No gameplay/effect clock advances behind it.
		if ( canvas.TutorialOpen )
		{
			feedback.Suspend();
			canvas.UpdateTutorial( Time.Delta, Input.Pressed( "CloseMenu" ) );
			return;
		}
		if ( canvas.OptionsOpen )
		{
			feedback.Suspend();
			canvas.UpdateOptions( Time.Delta, Input.Pressed( "CloseMenu" ) );
			return;
		}
		if ( canvas.LeaderboardOpen )
		{
			feedback.Suspend();
			canvas.UpdateLeaderboard( Time.Delta, Input.Pressed( "CloseMenu" ) );
			return;
		}
		// Nothing advances behind a fade-out; a new game runs once fading in.
		if ( canvas.TransitionPending )
		{
			feedback.Suspend();
			return;
		}
		if ( !gameStarted )
		{
			feedback.Stop();
			return;
		}

		// Each control has a primary (arrow / D-pad) and an alternate (WASD / face button) action, both rebindable,
		// plus either controller stick.
		var playPad = pad & ~carriedPad;
		bool rotate = Input.Down( "Rotate" ) || Input.Down( "RotateAlt" ) || playPad.HasFlag( PadInput.Up );
		int direction = (Input.Down( "MoveRight" ) || Input.Down( "MoveRightAlt" ) && !closeMenuHeld || playPad.HasFlag( PadInput.Right ) ? 1 : 0)
			- (Input.Down( "MoveLeft" ) || Input.Down( "MoveLeftAlt" ) || playPad.HasFlag( PadInput.Left ) ? 1 : 0);
		bool softDrop = Input.Down( "SoftDrop" ) || playPad.HasFlag( PadInput.SoftDrop ) || playPad.HasFlag( PadInput.Down );
#if STANDALONE
#endif
		board.Update( Time.Delta, direction, softDrop, rotate );
		if ( DebugGameReason() is null ) DiamondAchievements.CheckGame( board );
		SubmitGameOverScore();
		if ( sinceAchievementBank > AchievementBankInterval ) BankAchievementProgress();
		canvas.UpdateNextPreview( Time.Delta );
		canvas.UpdateGameOver( Time.Delta );
		atmosphere.Update( board, Time.Delta );
		debris.Update( board, Time.Delta );
		scoreEffects.Update( board, debris.CrushAwards, Time.Delta );
		feedback.Update( board, Time.Delta, debris.CrushedThisFrame );
		feedback.ScoreArrivals( scoreEffects.Arrivals );
		if ( board.RotatedThisUpdate )
		{
			feedback.Rotate( board );
			if ( !board.IsResolving && !board.GameOver ) debris.EmitRotationSparks( board.Active );
		}
		if ( board.RotationRefusedThisUpdate ) feedback.RotateBump();
		canvas.ShakeOffset = feedback.ShakeOffset;
		canvas.PieceShakeOffset = feedback.PieceShakeOffset;
		canvas.IsMuted = feedback.IsMuted;
		canvas.UpdateLandingPointGlows( Time.Delta );
	}

	static PadInput HeldPad() =>
		(Input.Down( "SoftDropAlt" ) ? PadInput.SoftDrop : 0) | (DiamondGamepad.Left ? PadInput.Left : 0)
		| (DiamondGamepad.Right ? PadInput.Right : 0) | (DiamondGamepad.Up ? PadInput.Up : 0) | (DiamondGamepad.Down ? PadInput.Down : 0);

	/// <summary>Submit the total score and survival time once, on the frame the game ends, and bank its achievement progress.
	/// Debug games stay off the board.</summary>
	void SubmitGameOverScore()
	{
		if ( !board.GameOver || submittedResetVersion == board.ResetVersion ) return;
		submittedResetVersion = board.ResetVersion;
		string debugGame = DebugGameReason();
		if ( debugGame is not null ) Log.Info( $"Diamonds: skipped leaderboard submission for {debugGame}." );
		else DiamondLeaderboard.Submit( board.Score, (long)board.ElapsedSeconds );
		BankAchievementProgress();
	}

	/// <summary>Why the current game stays off the leaderboard and achievements, or null for a normal game. Checked every
	/// frame, so it returns constant strings.</summary>
	string DebugGameReason()
	{
#if STANDALONE
#endif
		if ( debugFilledResetVersion == board.ResetVersion ) return "a diamonds_fill debug game";
		if ( board.StartingScore > 0 ) return "a game started at a debug Starting Score";
		return null;
	}

	/// <summary>Add the running game's progress since the last bank to the lifetime achievement stats: at game over, every
	/// <see cref="AchievementBankInterval"/> seconds of a long game, and when a game is restarted, left or shut down before
	/// then. Only the new part of each counter is added, so banking twice in one game never counts anything twice.</summary>
	void BankAchievementProgress()
	{
		sinceAchievementBank = 0;
		if ( !gameStarted ) return;
		if ( bankedResetVersion != board.ResetVersion )
		{
			bankedResetVersion = board.ResetVersion;
			bankedBroken = bankedCrushed = bankedScore = 0;
		}
		if ( DebugGameReason() is null )
			DiamondAchievements.Bank( board.DiamondsBroken - bankedBroken, board.ShardsCrushed - bankedCrushed, board.Score - bankedScore );
		bankedBroken = board.DiamondsBroken;
		bankedCrushed = board.ShardsCrushed;
		bankedScore = board.Score;
	}

	/// <summary>Editor only: start a new game over a stack filled to near the top, for testing game over.</summary>
	[ConCmd( "diamonds_fill" )]
	static void DebugFillCommand( float freeHeights = 2 )
	{
		if ( !Game.IsEditor )
		{
			Log.Warning( "diamonds_fill only works in the editor." );
			return;
		}
		var game = Game.ActiveScene?.GetAllComponents<DiamondGame>().FirstOrDefault();
		if ( game?.canvas is null )
		{
			Log.Warning( "diamonds_fill: no running Diamonds game." );
			return;
		}
		game.canvas.CloseTutorial();
		game.canvas.CloseOptions();
		game.canvas.CloseLeaderboard();
		game.canvas.CancelTransition();
		game.canvas.ClearRestartHold();
		// Keep a running game's score; from the title, start at the usual starting score.
		long score = game.board.Score;
		bool keepScore = game.gameStarted;
		game.StartGame();
		game.board.FillToNearTop( freeHeights, keepScore ? score : game.board.Score );
		Log.Info( $"diamonds_fill: {game.board.Settled.Count} gems, score {game.board.Score:N0}." );
		game.debugFilledResetVersion = game.board.ResetVersion;
	}

	/// <summary>Abandon the current game and return to the title screen.</summary>
	void LeaveGame()
	{
		BankAchievementProgress();
		gameStarted = false;
		board.Reset();
		debris.Clear();
		scoreEffects.Clear();
		atmosphere.Clear();
		feedback.Stop();
		canvas.ShakeOffset = Vector2.Zero;
		canvas.PieceShakeOffset = Vector2.Zero;
		canvas.TitleScreen = true;
	}

	void StartGame()
	{
		// A restart abandons the running game; whatever it achieved still counts.
		BankAchievementProgress();
		board.Reset();
		debris.Clear();
		scoreEffects.Clear();
		atmosphere.Clear();
		feedback.Stop();
		canvas.ShakeOffset = Vector2.Zero;
		canvas.PieceShakeOffset = Vector2.Zero;
		gameStarted = true;
		canvas.TitleScreen = false;
	}
}