Transposer/Scenes/GameScene.cs
namespace Sandbox.Transposer;
/// <summary>
/// The main gameplay scene. Contains the player, enemies, coins, background,
/// and all particle/text effects. Handles the core loop:
/// collect coins → swap grid → spawn enemy → score → die → return to menu.
/// </summary>
public class GameScene : TransposerScene
{
private Player _player;
private List<Enemy> _enemies = new();
private List<Coin> _coins = new();
private List<Trail> _trails = new();
private List<BloodParticle> _bloodParticles = new();
private List<Shine> _shines = new();
private List<Shine> _shinesToRemove = new();
private List<BonusText> _bonusTexts = new();
private const int MAX_BLOOD_PARTICLES = 120;
private int _numSwaps;
private int _score;
private bool _isPaused;
private bool _wasPaused;
private ScoreText _pauseText;
private ScoreText _deathCoinsText;
private ScoreText _deathScoreText;
private float _deathStatsTimer = -1f;
private bool _showDeathStats;
private Game.Overlay _overlay;
public override void Activate()
{
base.Activate();
PixelPoint center = new( Screen.PixelWidth / 2, Screen.PixelHeight / 2 );
_player = new Player( center.X - 6, center.Y - 6, this );
AddEntity( _player );
// Spawn initial coin at least 60px from center.
const int MIN_DISTANCE = 60;
int newX, newY;
do
{
newX = Game.Random.Next( 2, Screen.PixelWidth - 12 );
newY = Game.Random.Next( 2, Screen.PixelHeight - 12 );
}
while ( Math.Abs( newX - center.X ) < MIN_DISTANCE && Math.Abs( newY - center.Y ) < MIN_DISTANCE );
SpawnCoin( newX, newY );
AddEntity( new Background( 0, 0, this ) );
_numSwaps = 0;
_score = 0;
_isPaused = false;
_deathStatsTimer = -1f;
_showDeathStats = false;
_deathCoinsText = null;
_deathScoreText = null;
int pauseTextWidth = "paused".Length * 14; // 6 chars × (6+1)×2 px at scale 2
_pauseText = new ScoreText( "paused", this,
Screen.PixelWidth / 2 - pauseTextWidth / 2,
Screen.PixelHeight / 2 - 9, "font", 2 );
FadeIn( 0.25f );
AudioManager.MusicPitch = 1f;
AudioManager.FadeInMusic( 1f );
_overlay = new Game.Overlay();
}
public override void Deactivate()
{
base.Deactivate();
AudioManager.StopMusic();
AudioManager.SetGamePaused( false );
_enemies.Clear();
_coins.Clear();
_trails.Clear();
_bloodParticles.Clear();
_shines.Clear();
_shinesToRemove.Clear();
_bonusTexts.Clear();
_player = null;
}
public override void UpdateScene( float deltaTime )
{
Screen.Clear( new Color32( 0, 0, 0, 255 ) );
// Toggle mute with M.
if ( Input.Pressed( "Mute" ) )
AudioManager.ToggleMute();
_isPaused = _overlay.IsPauseMenuOpen;
if( _isPaused != _wasPaused )
{
if( _isPaused )
AudioManager.PausePitchMult = Game.Random.Float( AudioManager.PausePitchMin, AudioManager.PausePitchMax );
AudioManager.SetGamePaused( _isPaused );
}
_wasPaused = _isPaused;
if ( _isPaused )
{
// Draw everything frozen — no UpdateEntity calls so no input, logic,
// random rolls, or animation advances happen while paused.
DrawEntitiesOnly();
foreach ( Enemy e in _enemies ) e.Draw();
foreach ( Coin c in _coins ) c.Draw();
foreach ( Shine s in _shines ) s.Draw();
HandleTransposing( 0f );
HandleFading( 0f );
}
else
{
base.UpdateScene( deltaTime );
if ( _sceneSwitched ) return;
// Enemies, coins, and shines are managed in their own lists rather than
// the base entity list. This preserves the original game's draw ordering:
// entity list (background, player, particles, trails) draws first, then
// enemies, then coins, then shines — ensuring they always appear on top.
foreach ( Enemy e in _enemies )
e.UpdateEntity( deltaTime );
foreach ( Enemy e in _enemies )
e.Draw();
// Update and draw coins.
foreach ( Coin c in _coins )
c.UpdateEntity( deltaTime );
foreach ( Coin c in _coins )
c.Draw();
// Shines remove themselves via RemoveShine() when their animation finishes.
// We defer the actual list removal to avoid modifying _shines mid-iteration.
foreach ( Shine s in _shinesToRemove )
_shines.Remove( s );
_shinesToRemove.Clear();
foreach ( Shine s in _shines )
s.UpdateEntity( deltaTime );
foreach ( Shine s in _shines )
s.Draw();
HandleTransposing( deltaTime );
// Death stats — drawn after grid warp so they're never scrambled,
// but before HandleFading so FadeToBlack fades them out naturally.
if ( _deathStatsTimer >= 0f )
{
_deathStatsTimer -= deltaTime;
if ( _deathStatsTimer <= 0f )
_showDeathStats = true;
}
if ( _showDeathStats )
{
_deathCoinsText.Draw();
_deathScoreText.Draw();
}
HandleFading( deltaTime );
}
// Pause overlay and text — drawn on top of everything else.
if ( _isPaused )
{
Screen.AddPixels( new Color32( 0, 0, 0, 140 ) );
_pauseText.Draw();
}
// Mute icon — always drawn last, top-right corner.
if ( AudioManager.IsMuted )
DrawMuteIcon();
}
// ── Factory methods ──────────────────────────────────────────────────
private void SpawnCoin( int x, int y )
{
Coin coin = new( x, y, this );
_coins.Add( coin );
}
private void SpawnEnemy()
{
Enemy enemy = new(
Game.Random.Next( 0, Screen.PixelWidth - 10 ),
Game.Random.Next( 0, Screen.PixelHeight - 10 ),
this );
_enemies.Add( enemy );
}
public void RemoveBloodParticle( BloodParticle bp )
{
_bloodParticles.Remove( bp );
}
public void CreateTrail( int x, int y, float opacity )
{
Trail trail = new( x, y, opacity, this );
_trails.Add( trail );
AddEntity( trail );
}
public void CreateBloodParticle( int x, int y, Vector2 velocity,
float distanceToFall, float gravity, bool playNoise = false, bool force = false )
{
if ( !force && _bloodParticles.Count >= MAX_BLOOD_PARTICLES )
return;
BloodParticle bp = new( x, y, velocity, distanceToFall, gravity, this, playNoise );
_bloodParticles.Add( bp );
AddEntity( bp );
}
public void CreateShine( int x, int y )
{
Shine shine = new( x, y, this );
_shines.Add( shine );
}
public void RemoveShine( Shine shine )
{
_shinesToRemove.Add( shine );
}
// ── Game events ──────────────────────────────────────────────────────
public void PlayerTouchedCoin( Coin coin )
{
if ( _player.IsDead )
return;
SwapGridSquares();
coin.Relocate();
SpawnEnemy();
_player.BecomeInvincible();
AudioManager.PlaySfx( Sfx.CollectCoin );
// White particle explosion.
int numParticles = Game.Random.Next( 25, 40 );
for ( int i = 0; i < numParticles; i++ )
{
float angle = Game.Random.Float( 0f, 360f );
float rad = angle * (MathF.PI / 180f);
Vector2 dir = new( MathF.Sin( -rad ), MathF.Cos( rad ) );
PixelParticle p = new( Color.White, _player.CenterPixelPos.X, _player.CenterPixelPos.Y,
this, dir, Game.Random.Float( 25f, 80f ), Game.Random.Float( 1.2f, 2.7f ) );
AddEntity( p );
}
// Bonus text.
//int bonus = _currentGridSize;
int bonus = _numSwaps + 1; // Bonus is one more than the number of swaps already performed, so first coin is +1, second is +2, etc.
string bonusStr = "+" + bonus;
int width = bonusStr.Length * 6;
BonusText bt = new( bonusStr, this,
_player.CenterPixelPos.X - (int)MathF.Round( width * 0.5f ),
_player.CenterPixelPos.Y, "font" );
AddEntity( bt );
_numSwaps++;
_score += bonus;
if ( _numSwaps == 16 ) Sandbox.Services.Achievements.Unlock( "16_coins" );
else if ( _numSwaps == 24 ) Sandbox.Services.Achievements.Unlock( "24_coins" );
else if ( _numSwaps == 32 ) Sandbox.Services.Achievements.Unlock( "32_coins" );
AudioManager.MusicPitch += 0.0075f;
}
public void PlayerTouchedEnemy( Enemy enemy )
{
Globals.HasPlayedOnce = true;
Globals.LastNumSwaps = _numSwaps;
Globals.LastScore = _score;
if ( _score > Globals.HighScore )
Globals.HighScore = _score;
//Log.Info( $"[Transposer] PlayerDied score={_score} stat='{Globals.LEADERBOARD_STAT}'" );
//if ( !Game.IsEditor )
Sandbox.Services.Stats.SetValue( Globals.LEADERBOARD_STAT, _score );
// Build death-stats texts. Drawn directly (not in entity list) so grid warp never touches them.
// coins: upper third of screen; score: lower third — evenly spaced.
string coinsStr = "coins: " + _numSwaps;
string scoreStr = "score: " + _score;
int coinsX = Screen.PixelWidth / 2 - (coinsStr.Length * 7 - 1) / 2;
int scoreX = Screen.PixelWidth / 2 - (scoreStr.Length * 7 - 1) / 2;
int coinsY = Screen.PixelHeight * 2 / 3;
int scoreY = Screen.PixelHeight / 6;
_deathCoinsText = new ScoreText( coinsStr, this, coinsX, coinsY, "font" );
_deathScoreText = new ScoreText( scoreStr, this, scoreX, scoreY, "font" );
_deathStatsTimer = 1f;
_showDeathStats = false;
_player.Die();
AudioManager.SwitchMusic( 2f );
}
}