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;
}
}