diff --git a/lib/src/game/field.dart b/lib/src/game/field.dart index fa5095f6..5f29be48 100644 --- a/lib/src/game/field.dart +++ b/lib/src/game/field.dart @@ -32,6 +32,42 @@ class Field extends Array2d { return Field._internal(bombCount, cols, squares); } + /// Creates a field with a guaranteed safe position at (safeX, safeY) + /// This ensures the first click in Minesweeper is never a bomb + factory Field.withSafePosition( + int bombCount, + int cols, + int rows, + int safeX, + int safeY, [ + int? seed, + ]) { + final squares = List.filled(rows * cols, false); + assert(bombCount < squares.length); + assert(bombCount > 0); + assert(safeX >= 0 && safeX < cols); + assert(safeY >= 0 && safeY < rows); + + final rnd = Random(seed); + final safeIndex = safeY * cols + safeX; + + // create a list of all valid positions (excluding safe position) + final availablePositions = []; + for (int i = 0; i < squares.length; i++) { + if (i != safeIndex) { + availablePositions.add(i); + } + } + + // Shuffle the available positions and place bombs in first X positions + availablePositions.shuffle(rnd); + for (int i = 0; i < bombCount; i++) { + squares[availablePositions[i]] = true; + } + + return Field._internal(bombCount, cols, squares); + } + factory Field.fromSquares(int cols, int rows, List squares) { assert(cols > 0); assert(rows > 0); diff --git a/lib/src/game/game_core.dart b/lib/src/game/game_core.dart index f5510efb..b7b7ef12 100644 --- a/lib/src/game/game_core.dart +++ b/lib/src/game/game_core.dart @@ -13,7 +13,8 @@ enum SquareState { hidden, revealed, flagged, bomb, safe } enum GameState { reset, started, won, lost } class Game { - final Field field; + Field? _field; // Now nullable - will be created on first click + final int width, height, bombCount; // Store dimensions instead final Array2d _states; final _updatedEvent = StreamController(); final _gameStateEvent = StreamController(); @@ -23,8 +24,20 @@ class Game { int _bombsLeft; int _revealsLeft; - Game(this.field) + // Constructor takes dimensions instead of Field + Game(this.width, this.height, this.bombCount) : _state = GameState.reset, + _states = Array2d(width, height, (i) => SquareState.hidden), + _bombsLeft = bombCount, + _revealsLeft = width * height - bombCount; + + // Legacy constructor for backward compatibility with existing Field + Game.fromField(Field field) + : _field = field, + width = field.width, + height = field.height, + bombCount = field.bombCount, + _state = GameState.reset, _states = Array2d( field.width, field.height, @@ -33,6 +46,16 @@ class Game { _bombsLeft = field.bombCount, _revealsLeft = field.length - field.bombCount; + // Getter: Access field safely - will be generated on first reveal + Field get field { + if (_field == null) { + // For UI that needs field before first click, create a temporary empty field + // This won't be used for actual gameplay logic + return Field(bombCount, width, height); + } + return _field!; + } + int get bombsLeft => _bombsLeft; int get revealsLeft => _revealsLeft; @@ -56,7 +79,7 @@ class Game { } void setFlag(int x, int y, bool value) { - _ensureStarted(); + _ensureStarted(); // Don't pass coordinates for flag - use fallback field generation final currentSS = _states.get(x, y); if (value) { @@ -82,7 +105,7 @@ class Game { } List>? reveal(int x, int y) { - _ensureStarted(); + _ensureStarted(x, y); // Pass coordinates for safe first click assert(canReveal(x, y), 'Item cannot be revealed.'); final currentSS = _states.get(x, y); @@ -271,8 +294,25 @@ class Game { } } - void _ensureStarted() { + void _ensureStarted([int? firstClickX, int? firstClickY]) { if (state == GameState.reset) { + // LAZY FIELD GENERATION: Create field on first click + if (_field == null) { + if (firstClickX != null && firstClickY != null) { + // Generate field with the first click position guaranteed safe + _field = Field.withSafePosition( + bombCount, + width, + height, + firstClickX, + firstClickY, + ); + } else { + // Fallback: generate normal field (shouldn't happen in normal gameplay) + _field = Field(bombCount, width, height); + } + } + assert(!_watch.isRunning); _setState(GameState.started); } diff --git a/lib/src/game_manager.dart b/lib/src/game_manager.dart index 72624749..98951820 100644 --- a/lib/src/game_manager.dart +++ b/lib/src/game_manager.dart @@ -37,8 +37,8 @@ abstract class GameManager { } void _newGame() { - final f = Field(_bombCount, _width, _height); - _game = Game(f); + // Pass dimensions - let Game handle field creation lazily + _game = Game(_width, _height, _bombCount); _gameStateChangedSub = _game.stateChanged.listen(_gameStateChanged); } diff --git a/lib/src/game_storage.dart b/lib/src/game_storage.dart index 1817b18e..c1b28459 100644 --- a/lib/src/game_storage.dart +++ b/lib/src/game_storage.dart @@ -19,9 +19,9 @@ class GameStorage { bool updateBestTime(Game game) { assert(game.state == GameState.won); - final w = game.field.width; - final h = game.field.height; - final m = game.field.bombCount; + final w = game.width; + final h = game.height; + final m = game.bombCount; final duration = game.duration!.inMilliseconds; final key = _getKey(w, h, m); diff --git a/lib/src/stage/board_element.dart b/lib/src/stage/board_element.dart index f7438f47..110639db 100644 --- a/lib/src/stage/board_element.dart +++ b/lib/src/stage/board_element.dart @@ -16,11 +16,9 @@ class BoardElement extends Sprite { addTo(gameElement); final scaledSize = SquareElement.size * _boardScale; - _elements = Array2d(game.field.width, game.field.height, ( - i, - ) { - final x = i % game.field.width; - final y = i ~/ game.field.height; + _elements = Array2d(game.width, game.height, (i) { + final x = i % game.width; + final y = i ~/ game.width; return SquareElement(x, y) ..x = x * scaledSize ..y = y * scaledSize diff --git a/lib/src/stage/game_background_element.dart b/lib/src/stage/game_background_element.dart index 8f6556cd..6c8707ac 100644 --- a/lib/src/stage/game_background_element.dart +++ b/lib/src/stage/game_background_element.dart @@ -82,7 +82,7 @@ class GameBackgroundElement extends Sprite { ); final tbr = Rectangle(0, 0, 80, 112); final lrr = Rectangle(0, 0, 112, 80); - for (var i = 0; i < _game.field.width - 2; i++) { + for (var i = 0; i < _game.width - 2; i++) { boardData ..drawPixels( op.getBitmapData('game_board_side_top'), diff --git a/lib/src/stage/game_element.dart b/lib/src/stage/game_element.dart index 60370c41..b081b98d 100644 --- a/lib/src/stage/game_element.dart +++ b/lib/src/stage/game_element.dart @@ -56,7 +56,7 @@ class GameElement extends Sprite { final sta = resourceManager.getTextureAtlas('static'); _animations = resourceManager.getTextureAtlas('animated'); - _boardSize = game.field.width * SquareElement.size + 2 * _edgeOffset; + _boardSize = game.width * SquareElement.size + 2 * _edgeOffset; _boardScale = _backgroundHoleSize / _boardSize; GameBackgroundElement(this, opa); diff --git a/test/game_test.dart b/test/game_test.dart index 2edfa312..24d473bb 100644 --- a/test/game_test.dart +++ b/test/game_test.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'package:pop_pop_win/src/game.dart'; +import 'package:pop_pop_win/src/game/game_core.dart'; import 'package:test/test.dart'; import 'test_util.dart'; @@ -24,11 +25,13 @@ void main() { test('canReveal', _testCanReveal); test('canFlag', _testCanFlag); test('cannot re-reveal', _testCannotReReveal); + + _testSafeFirstClick(); } void _testCannotReReveal() { final f = getSampleField(); - final g = Game(f); + final g = Game.fromField(f); expect(g.canReveal(5, 3), isTrue); g @@ -43,7 +46,7 @@ void _testCannotReReveal() { void _testCanFlag() { final f = getSampleField(); - final g = Game(f); + final g = Game.fromField(f); expect(g.canToggleFlag(0, 0), isTrue); expect(g.state, GameState.reset); @@ -60,7 +63,7 @@ void _testCanFlag() { void _testCanReveal() { final f = getSampleField(); - final g = Game(f); + final g = Game.fromField(f); expect(g.canReveal(0, 0), isTrue); expect(g.state, GameState.reset); @@ -86,7 +89,7 @@ void _testCanReveal() { void _testBadChord() { final f = getSampleField(); - final g = Game(f); + final g = Game.fromField(f); expect(g.bombsLeft, equals(13)); final startReveals = f.length - 13; @@ -110,7 +113,7 @@ void _testBadChord() { // so nothing happens void _testNoopChord() { final f = getSampleField(); - final g = Game(f); + final g = Game.fromField(f); expect(g.bombsLeft, equals(13)); final startReveals = f.length - 13; @@ -130,7 +133,7 @@ void _testNoopChord() { void _testGoodChord() { final f = getSampleField(); - final g = Game(f); + final g = Game.fromField(f); expect(g.bombsLeft, equals(13)); final startReveals = f.length - 13; @@ -158,7 +161,7 @@ void _testRandomField() { final f = Field(); for (var j = 0; j < 5; j++) { - final g = Game(f); + final g = Game.fromField(f); while (g.revealsLeft > 0) { final x = rnd.nextInt(f.width); final y = rnd.nextInt(f.height); @@ -177,7 +180,7 @@ void _testRandomField() { void _testRevealZero() { final f = getSampleField(); - final g = Game(f); + final g = Game.fromField(f); expect(g.bombsLeft, equals(13)); final startReveals = f.length - 13; @@ -190,7 +193,7 @@ void _testRevealZero() { void _testInitial() { final f = getSampleField(); - final g = Game(f); + final g = Game.fromField(f); expect(g.bombsLeft, equals(13)); expect(g.revealsLeft, equals(f.length - 13)); @@ -205,7 +208,7 @@ void _testInitial() { } void _testSetFlag() { - final g = Game(getSampleField()); + final g = Game.fromField(getSampleField()); expect(g.getSquareState(0, 0), equals(SquareState.hidden)); g.setFlag(0, 0, true); @@ -215,7 +218,7 @@ void _testSetFlag() { } void _testCannotRevealFlagged() { - final g = Game(getSampleField()); + final g = Game.fromField(getSampleField()); expect(g.getSquareState(0, 0), equals(SquareState.hidden)); g.setFlag(0, 0, true); @@ -227,7 +230,7 @@ void _testCannotRevealFlagged() { } void _testCannotFlagRevealed() { - final g = Game(getSampleField()); + final g = Game.fromField(getSampleField()); expect(g.getSquareState(1, 1), equals(SquareState.hidden)); g.reveal(1, 1); @@ -238,7 +241,7 @@ void _testCannotFlagRevealed() { } void _testLoss() { - final g = Game(getSampleField()); + final g = Game.fromField(getSampleField()); expect(g.getSquareState(0, 0), equals(SquareState.hidden)); final revealed = g.reveal(0, 0); @@ -249,7 +252,7 @@ void _testLoss() { void _testWin() { final f = getSampleField(); - final g = Game(f); + final g = Game.fromField(f); var bombsLeft = f.bombCount; expect(g.revealsLeft, equals(f.length - 13)); @@ -273,3 +276,88 @@ void _testWin() { expect(g.state, equals(GameState.won)); } + +// Test to verify that the first click is never a bomb +void _testSafeFirstClick() { + group('Safe First Click Tests', () { + test('first click is never a bomb - multiple positions', () { + for (int testRun = 0; testRun < 10; testRun++) { + for (int x = 0; x < 8; x++) { + for (int y = 0; y < 8; y++) { + final game = Game(8, 8, 10); // 8x8 grid with 10 bombs + + // First click at position (x, y) + final reveals = game.reveal(x, y); + + // Should never be null (which indicates hitting a bomb) + expect( + reveals, + isNotNull, + reason: 'First click at ($x, $y) hit a bomb on test run $testRun', + ); + + // Game should be started, not lost + expect( + game.state, + equals(GameState.started), + reason: 'Game should be started after first click at ($x, $y)', + ); + } + } + } + }); + + test('field generation is lazy', () { + final game = Game(8, 8, 10); + + // Before any click, field returns a temporary field for UI purposes + final tempField = game.field; + expect(tempField.bombCount, equals(10)); + expect(tempField.width, equals(8)); + expect(tempField.height, equals(8)); + + // After first click, field should be the actual generated field + game.reveal(4, 4); + final gameField = game.field; + + // The clicked position should be safe + expect(gameField.get(4, 4), isFalse); + + // And it should be a different field instance (the real one) + expect(identical(tempField, gameField), isFalse); + }); + + test('bomb count is correct after lazy generation', () { + final game = Game(8, 8, 10); + + // Make first click + game.reveal(3, 3); + + // Count actual bombs in field + var actualBombs = 0; + for (int x = 0; x < 8; x++) { + for (int y = 0; y < 8; y++) { + if (game.field.get(x, y)) { + actualBombs++; + } + } + } + + expect(actualBombs, equals(10)); + expect(game.bombsLeft, equals(10)); + }); + + test('legacy Game.fromField constructor still works', () { + // This ensures backward compatibility + final game = Game(4, 4, 3); + game.reveal(2, 2); // Generate the field + final field = game.field; // Get a field from lazy generation + final game2 = Game.fromField(field); + + expect(game2.field, equals(field)); + expect(game2.bombCount, equals(field.bombCount)); + expect(game2.width, equals(field.width)); + expect(game2.height, equals(field.height)); + }); + }); +}