Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified fastlane/metadata/android/en-US/images/phoneScreenshots/3_en-US.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 15 additions & 2 deletions lib/game/game_logic.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ class GameLogic extends ChangeNotifier {
Duration _remainingLockDelay = Duration.zero;
DateTime? _lockDelayDeadline;

/// True when a pending lock delay was interrupted by [pauseGame].
bool _lockDelayPaused = false;

/// Delay between a piece touching the stack and becoming fixed in place.
final Duration lockDelay;

Expand Down Expand Up @@ -300,10 +303,13 @@ class GameLogic extends ChangeNotifier {
if (isGameRunning && !isGameOver && !isPaused) {
isPaused = true;
gameTimer?.cancel();

gameTimer = null;
clearAnimationTimer?.cancel();
clearAnimationTimer = null;
trailAnimationTimer?.cancel();
trailAnimationTimer = null;
gracePeriodTimer?.cancel();
gracePeriodTimer = null;
_pauseLockDelay();

notifyListeners();
Expand Down Expand Up @@ -797,20 +803,27 @@ class GameLogic extends ChangeNotifier {
if (_remainingLockDelay.isNegative) {
_remainingLockDelay = Duration.zero;
}
_lockDelayPaused = true;
_lockDelayTimer?.cancel();
_lockDelayTimer = null;
_lockDelayDeadline = null;
}

void _resumeLockDelay() {
if (_remainingLockDelay == Duration.zero || currentPiece == null) return;
if (!_lockDelayPaused) return;
_lockDelayPaused = false;
if (currentPiece == null) {
_remainingLockDelay = Duration.zero;
return;
}

final remaining = _remainingLockDelay;
_lockDelayDeadline = DateTime.now().add(remaining);
_lockDelayTimer = Timer(remaining, _lockCurrentPiece);
}

void _cancelLockDelay() {
_lockDelayPaused = false;
_lockDelayTimer?.cancel();
_lockDelayTimer = null;
_lockDelayDeadline = null;
Expand Down
2 changes: 1 addition & 1 deletion lib/screens/tetris_game_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -508,12 +508,12 @@ class _TetrisGameScreenState extends State<TetrisGameScreen>
break;
case AppLifecycleState.resumed:
// App is coming back to foreground - resume the game
if (widget.settings.musicEnabled) _audioService.resumeMusic();
if (!_isSettingsOpen &&
gameLogic.isGameRunning &&
!gameLogic.isGameOver &&
gameLogic.isPaused) {
gameLogic.resumeGame();
_audioService.resumeMusic();
}
break;
case AppLifecycleState.hidden:
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.57+54
version: 1.0.58+55

environment:
sdk: ^3.5.0
Expand Down
172 changes: 172 additions & 0 deletions test/lifecycle_timer_restart_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Regression tests for "the piece stops falling after I go back to the app".
//
// On Android, backgrounding drives GameLogic.pauseGame() and returning drives
// GameLogic.resumeGame(). pauseGame() used to cancel the grace-period, line
// clear and trail timers without clearing their fields, so resumeGame()'s
// `timer == null` guards never fired: isNewPieceGracePeriod / isAnimatingClear
// stayed true forever and movePieceDown() early-returned on every gravity tick.
//
// These tests fail before the fix and pass after it.

import 'package:block_drop/constants/game_constants.dart';
import 'package:block_drop/game/game_logic.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
late GameLogic game;

setUp(() {
game = GameLogic(lockDelay: const Duration(milliseconds: 60));
});

tearDown(() {
game.gameTimer?.cancel();
game.pauseGame();
game.dispose();
});

group('pauseGame timer bookkeeping', () {
test('clears every timer handle it cancels', () {
game.startGame();
expect(game.gameTimer, isNotNull);
expect(game.gracePeriodTimer, isNotNull);

game.pauseGame();

expect(game.gameTimer, isNull);
expect(game.gracePeriodTimer, isNull);
expect(game.clearAnimationTimer, isNull);
expect(game.trailAnimationTimer, isNull);
});
});

group('spawn grace period across background/foreground', () {
test('grace period ends after resume so the piece can fall', () async {
game.startGame();
expect(game.isNewPieceGracePeriod, isTrue);

// AppLifecycleState.hidden -> AppLifecycleState.resumed
game.pauseGame();
game.resumeGame();
expect(game.gracePeriodTimer, isNotNull,
reason: 'resumeGame must restart the grace-period timer');

await Future<void>.delayed(const Duration(milliseconds: 400));

expect(game.isNewPieceGracePeriod, isFalse);

final before = game.currentY;
game.movePieceDown();
expect(game.currentY, greaterThan(before));
});

test('hard drop is not permanently blocked after resume', () async {
game.startGame();
game.pauseGame();
game.resumeGame();

await Future<void>.delayed(const Duration(milliseconds: 400));

// dropPiece() locks the piece and immediately spawns a new one at the
// same spawn row, so currentY is not a useful before/after signal here.
// Check that the piece actually landed on the board instead.
game.dropPiece();
final anythingSettled =
game.board.any((row) => row.any((cell) => cell != null));
expect(anythingSettled, isTrue,
reason: 'a blocked hard drop leaves the board untouched');
});

test('gravity keeps moving the piece after resume', () async {
game.startGame();
game.pauseGame();
game.resumeGame();

final before = game.currentY;
await Future<void>.delayed(
Duration(milliseconds: game.dropSpeed * 2 + 400),
);

expect(game.currentY, greaterThan(before),
reason: 'the gravity timer ticks but movePieceDown() was a no-op');
});
});

group('line clear animation across background/foreground', () {
test('clear completes and the board is compacted', () async {
game.startGame();

final lastRow = GameConstants.boardHeight + GameConstants.previewRows - 1;
for (var col = 0; col < GameConstants.boardWidth; col++) {
game.board[lastRow][col] = Colors.red;
}

game.clearLines();
expect(game.isAnimatingClear, isTrue);
expect(game.clearAnimationTimer, isNotNull);

game.pauseGame();
expect(game.clearAnimationTimer, isNull);

game.resumeGame();
expect(game.clearAnimationTimer, isNotNull,
reason: 'resumeGame must restart the clear animation timer');

await Future<void>.delayed(const Duration(milliseconds: 600));

expect(game.isAnimatingClear, isFalse);
expect(game.linesCleared, 1);
expect(game.board[lastRow].every((cell) => cell == null), isTrue);
});
});

group('hard drop trail across background/foreground', () {
test('trail animation finishes after resume', () async {
game.startGame();
await Future<void>.delayed(const Duration(milliseconds: 300));

game.dropPiece();
expect(game.isAnimatingTrail, isTrue);

game.pauseGame();
game.resumeGame();

await Future<void>.delayed(const Duration(milliseconds: 400));

expect(game.isAnimatingTrail, isFalse);
expect(game.trailBlocks, isEmpty);
});
});

group('lock delay across background/foreground', () {
test(
'a grounded piece still locks if the deadline passed in the '
'background', () async {
game.startGame();
await Future<void>.delayed(const Duration(milliseconds: 300));

while (game.canPlacePiece(
game.currentX,
game.currentY + 1,
game.currentPiece!,
)) {
game.movePieceDown();
}
game.movePieceDown(); // grounded: arms the lock delay
expect(game.isLockDelayActive, isTrue);

game.pauseGame();
// The lock deadline expires while the app is in the background.
await Future<void>.delayed(const Duration(milliseconds: 150));
game.resumeGame();

await Future<void>.delayed(const Duration(milliseconds: 100));

final anythingSettled =
game.board.any((row) => row.any((cell) => cell != null));
expect(anythingSettled, isTrue,
reason: 'the piece should lock immediately on resume');
});
});
}