I. Generation of map data
In the previous article we completed the basic interface interaction: in this section we will complete the core game logic. Below each cell are either numbers or mines, where the numbers indicate the number of mines in the eight cells surrounding the cell. Everything below the cell here is map data:
1. Data enumeration
Below each cell is a resource image, and there are a limited number of them. So it is maintained by the CellType enumeration, which consists of nine numbers from 0 to 8 and a mine. The enumeration can carry the corresponding resource image:
enum CellType {
value0('images/sweeper/type0.svg'),
value1('images/sweeper/type1.svg'),
value2('images/sweeper/type2.svg'),
value3('images/sweeper/type3.svg'),
value4('images/sweeper/type4.svg'),
value5('images/sweeper/type5.svg'),
value6('images/sweeper/type6.svg'),
value7('images/sweeper/type7.svg'),
value8('images/sweeper/type8.svg'),
mine('images/sweeper/mine.svg');
final String src;
const CellType(this.src);
String get key => path.basename(src);
}
2. Mined areas: map data mapping relationships
Each cell can be positioned by coordinates as a unique identifier. Each cell corresponds to a CellType
:
If the coordinates are defined as of type XY
, define the (int, int) tuple alias by typedef as shown below:
typedef XY = (int, int);
In this way the map data can be seen as a mapping relationship between XY
and CellType
. Maintained via Map<XY, CellType>
:
Map<XY, CellType> cells = {};
The map data is how the mapping relationships are created. The mapping data for the mines is initialized here through the _createMine
method with two considerations:
[1]
. The map data is not generated at the beginning, but after the first click. This is to avoid the probability of hitting a mine on the first click. The following code passes in the coordinates of the first click atpos
.
[2]
. The mine traversal generation process does not take random points at each coordinate. This way there is a probability that the random numbers will repeat and result in an insufficient number of mines.
Here we use the point pool posPool
to collect all possible points, where the pos of the entry parameter is removed to indicate that a mine will not be generated on the first click. Out of the traversed mineCount
numbers, a random point from the posPool is taken as a key and added to the cells
map as the value of CellType.mine
to represent the mine data. After inserting mines at the changed points, remove them from posPool
. This ensures that mine locations are not duplicated in the map:
void _createMine(XY pos, int row, int column,int mineCount) {
List<XY> posPool = [];
for (int i = 0; i < row; i++) {
for (int j = 0; j < column; j++) {
if (pos != (j, i)) {
posPool.add((j, i));
}
}
}
while (cells.length < mineCount) {
int index = _random.nextInt(posPool.length);
XY target = posPool[index];
cells[target] = CellType.mine;
posPool.remove(target);
}
}
3. Numerical area: map data mapping relationships
Once the mine data has been generated, the values corresponding to the non-mined areas need to be calculated. This is done by the _createCellValue
method, which iterates through the row and column column numbers, accessing each cell coordinate. When a non-mined area is present, it is necessary to calculate how many mines are around the current coordinates. The calculation is handled by the _calculate
method, which adds the coordinates to the mapping relationship and corresponds to the number associated with the CellType:
void _createCellValue(int row, int column) {
for (int y = 0; y < row; y++) {
for (int x = 0; x < column; x++) {
if (cells[(x, y)] != CellType.mine) {
int count = _calculate(x, y);
cells[(x, y)] = CellType.values[count];
}
}
}
}
Calculating how many mines are around a certain point is very simple. It is easy to 3*3
record the number of mines on a nine-grid scale. For example, calculate the number of mines around the point (1,8) (center of the red box in the picture). Convenience: y at [0,2] and x at [7~9]. The code is shown below:
int _calculate(int x, int y) {
int count = 0;
for (int i = y - 1; i <= y + 1; i++) {
for (int j = x - 1; j <= x + 1; j++) {
if (cells[(j, i)] == CellType.mine) count++;
}
}
return count;
}
Here, we have completed the most central step: generating map data. The next step in the process is to flip the cell during the interaction and display the corresponding map content in it.
Game State Logic: GameStateLogic
Games have a lot of data to change during interaction. For example, in the minesweeper game
- Configuration data such as number of rows and columns, number of mines, etc;
The list of cells that have been clicked open, game map data, the list of marked cells, game status, and other data during the game.
LED screen displaying data on the number of mines and time remaining in the top bar.
This data is maintained here through the GameStateLogic
class, along with their change logic.
1. Game configuration data GameMode
The Minesweeper game includes four modes, Beginner, Intermediate, Advanced and Custom:
enum Mode{
primary,
middle,
advanced,
diy,
}
For each mode, the number of rows and columns size
and the number of mines mineCount
are required. In addition, for the three modes primary
, middle
and advanced
, the configuration can be finalized through a naming construct. For example, the primary pattern is the 9*9
grid with 10 mines:
class GameMode {
final XY size;
final int mineCount;
final Mode mode;
int get column => size.$1;
int get row => size.$2;
const GameMode(this.size, this.mineCount):mode=Mode.diy;
const GameMode.primary()
: size = (9, 9),
mineCount = 10,mode=Mode.primary;
const GameMode.middle()
: size = (16, 16),
mineCount = 40,mode=Mode.middle;
const GameMode.advanced({bool portrait=false})
: size = portrait?(16, 30):(30, 16),
mineCount = 99,mode=Mode.advanced;
}
2. Members of GameStateLogic
The game state can be categorized into four enumerated types during interaction, denoted by GameStatus
:
The game starts in the ready state, indicating that it is ready and waiting for the cell to be flipped.
After flipping is a cell, the game enters the playing state, indicating that the game is in progress.- The died state is the end of the game after clicking on a mine.
- The win status indicates a successful game when all non-mined areas are turned on.
enum GameStatus {
ready,
died,
playing,
win,
}
GameStateLogic
Being a mixin
provides additional capabilities to the main game class. It contains some of the following data that is relied upon during gameplay:
---->[lib/sweeper/game/logic/game_state_logic.dart]----
mixin GameStateLogic {
GameMode mode = const GameMode.middle();
GameStatus _status = GameStatus.ready;
Map<XY, CellType> cells = {};
final List<XY> _openPos = [];
final List<XY> _markPos = [];
final Random _random = Random();
}
The game logic class provides the initMapOrNot
method to trigger the previously written _createMine
and _createCellValue
methods to initialize the attached map data. One of them only needs to be triggered before the first click, i.e. _openPos
opens the list of coordinates to be empty:
void initMapOrNot(XY pos) {
if (_openPos.isEmpty) {
status = GameStatus.playing;
int row = mode.row;
int column = mode.column;
_createMine(pos, row, column,mode.mineCount);
_createCellValue( row, column);
}
}
3. Opening and marking point maintenance
The list of open points is recorded by _openPos
, when a cell is opened, the open method is triggered and the incoming coordinates are added to _openPos
. Every time a cell is opened, the checkWinGame
method is used to check whether the game is successful or not. The check condition for the success of the game is:
Open all non-mine cells. That is, when the length of the open point list is equal to the total number of cells – the total number of mines:
void open(XY pos) {
_openPos.add(pos);
checkWinGame();
}
bool get isWin {
return _openPos.length == mode.row * mode.column - mode.mineCount;
}
void checkWinGame() {
if (isWin) {
Toast.success('1');
status = GameStatus.win;
}
}
bool isOpened(XY pos) => _openPos.contains(pos);
During inference, when a cell is determined to be a mine, the flag can be flagged for demining through gesture interaction. The coordinates of the cell corresponding to the flag being marked is the _markPos
list. In GameStateLogic, we provide mark method to add a flag, unMark method to remove the flag, and isMarked method to check if the flag is marked:
void mark(XY pos) => _markPos.add(pos);
void unMark(XY pos) => _markPos.remove(pos);
bool isMarked(XY pos) => _markPos.contains(pos);
III. Gesture or mouse interaction events
The previous section completed the maintenance of the main data during the game. Next we will call the relevant methods to modify the data based on the gesture interaction event to realize the game function. In the previous article, we realized the drag and drop event to show the effect of cell pressing. The code is maintained in GameCellLogic
, and the following needs to call the open
method to open the cell when the mouse is lifted:
---->[lib/sweeper/game/logic/game_cell_logic.dart]----
@override
void onDragEnd(DragEndEvent event) {
open();
super.onDragEnd(event);
}
@override
void onTapUp(TapUpEvent event) {
open();
super.onTapUp(event);
}
1. Gesture lift open logic
To open a cell you need to do the following:
[1]
. When the game is won or lost, disable is true. will disable further clicks to open the cell.
[2]
. During a press_pressedCells
records the pressed cell. Verify that the cell is marked by_handelMark
before opening.
[3]
. Triggers theinitMapOrNot
method to initialize the map data before opening it for the first time.
[4]
._handleOpenCell
method handles the logic of the specific open cell.
---->[lib/sweeper/game/logic/game_cell_logic.dart]----
void open() {
if (game.disable) return;
if (_pressedCells.isNotEmpty) {
Cell cell = _pressedCells.first;
if (_handelMark(cell)) return;
game.initMapOrNot(cell.pos);
_handleOpenCell(cell);
_pressedCells.clear();
}
unpressed();
}
When a marked cell is clicked, the mark needs to be canceled. cell’s unMark
method will cancel the mark and show the unmarked cell; then call game’s unMark method to remove the corresponding marked point:
bool _handelMark(Cell cell) {
if (game.isMarked(cell.pos)) {
cell.unMark();
game.unMark(cell.pos);
return true;
}
return false;
}
2. Open cells and automatic opening
Orient its type in the cells
map data by using the cell’s point coordinates. If it is a mine, trigger the gameOver
method to end the game. Otherwise, trigger cell.open()
to open the cell.
void _handleOpenCell(Cell cell) {
CellType? type = game.cells[cell.pos];
if (type == CellType.mine) {
gameOver(cell);
} else {
cell.open();
handleAutoOpen(type, cell.pos);
}
}
To open a cell is to replace the Cell component coordinates, which correspond to the digital image in the map data. Once opened, the open method of GameStateLogic
is called to maintain the opened coordinates:
---->[lib/sweeper/game/heroes/cell/cell.dart]----
void open() {
CellType? type = game.cells[pos];
if (type != null) {
svg = game.loader.findSvg(type.key);
game.open(pos);
}
}
The 0 number cell is displayed as blank, if the cell is 0
number, you need to open the surrounding 0 cell automatically, as shown below.
Here the logic of automatic opening is handled by the handleAutoOpen
method: check the surrounding cells, and when a space is found, trigger the autoOpenAt
method to open the cell:
void handleAutoOpen(CellType? type, XY pos) {
if (type != CellType.value0) return;
int x = pos.$2;
int y = pos.$1;
for (int i = x - 1; i <= x + 1; i++) {
for (int j = y - 1; j <= y + 1; j++) {
autoOpenAt((j, i));
}
}
}
Automatically open a coordinate, first through the allowAutoOpen
calibration automatically open the conditions are: need to be non-open, non-marked points. Then according to the coordinates query the corresponding activated cell, non-mine when the open cell, continue to trigger handleAutoOpen
in addition to the need to automatically open the cell.
void autoOpenAt(XY pos) {
if(!game.allowAutoOpen(pos)) return;
Cell? cell = activeCell(pos);
if (cell != null) {
CellType? type = game.cells[pos];
if (type != CellType.mine) {
cell.open();
handleAutoOpen(type, pos);
}
}
}
3. Game over and restart
When the cell is opened, if it is a mine, the gameOver method is triggered, ending the game:
The gameOver first triggers the lose
method to set the current state of the game to dead, then it needs to traverse all the minefields and turn on the mines. Then the current mines are set to red background mines by died
.
void gameOver(Cell cell) {
game.lose();
Iterable<Cell> cells = children.whereType<Cell>();
for (Cell cell in cells) {
cell.openMine();
}
cell.died();
}
Clicking on the header emoticon restarts the game. Provide restart method in SweeperGame, first reset the data by reset
; then rebuild SweeperWorld
:
---->[lib/sweeper/game/sweeper_game.dart]----
void restart() {
reset();
world = SweeperWorld();
}
The reset method is placed in GameStateLogic
. A game reset is required to update the state, clear the map data, open and marked point lists:
---->[lib/sweeper/game/logic/game_state_logic.dart]----
void reset() {
status = GameStatus.ready;
_openPos.clear();
_markPos.clear();
cells.clear();
}
Once the gesture interaction logic is processed, the overall functionality of the Minesweeper game is realized. Finally, let’s take a look at the logic for handling the two numbers in the HUD.
IV. Logical processing of changes in HUD values
After the first turn on, the LED display on the right will show the number of seconds the game has been in progress; the display on the left is the total number of mines, minus the number of markers.
1. Notification of changes in numbers and listening
The problem we face now is similar to the header bar emoji changes, where gesture interactions in the palace grid generate data changes. Both displays need to be notified of the update. Again, we can implement a notification listening mechanism based on Stream, and treat the main game class as a big broadcast to send the message:
As shown below, the definition GameHudLogic
maintains two data sources for the displays. Changes in the time of one of them are triggered every second via Timer.periodic
, which sends a notification after updating the number of seconds. Changes in the number of mines occur through the changeMineCount
method, where notification occurs:
---->[lib/sweeper/game/logic/game_hud_logic.dart]----
mixin GameHudLogic{
final StreamController<int> _mineCountCtrl = StreamController.broadcast();
final StreamController<int> _timeCountCtrl = StreamController.broadcast();
Stream<int> get mineCountStream => _mineCountCtrl.stream;
Stream<int> get timeCtrlStream => _timeCountCtrl.stream;
void changeMineCount(int value) {
_mineCountCtrl.add(value);
}
Timer? _timer;
int _timeCount = 0;
void startTimer() {
closeTimer();
_timer = Timer.periodic(const Duration(seconds: 1), _updateTime);
}
void _updateTime(Timer timer) {
_timeCount++;
_timeCountCtrl.add(_timeCount);
}
void closeTimer() {
_timer?.cancel();
_timeCount = 0;
_timer = null;
}
}
2. Marking and unmarking
Marking and unmarking is the logic in GameStateLogic
. After the operation, we need to trigger changeMineCount
to notify the update, and the method is in GameHudLogic
, how to call it directly in GameStateLogic? GameHudLogic acts as a mixin, GameStateLogic can rely on it through the on keyword to use the methods in it:
---->[lib/sweeper/game/logic/game_state_logic.dart]----
void mark(XY pos) {
_markPos.add(pos);
changeMineCount(ledMineCount);
}
void unMark(XY pos) {
_markPos.remove(pos);
changeMineCount(ledMineCount);
}
int get ledMineCount => mode.mineCount - _markPos.length;
Separating out the mixin is equivalent to splitting the functional logic and then integrating it by mixing it in. This ensures that the logic is independent and clear, rather than all the logic being stuffed all in one piece, which affects reading and maintenance.
3. Listening for changes and updates
In SweeperHud, when onMount is loaded, listen to two streams corresponding to the data. Trigger the _onMineCountChange
function to modify the number of mines; trigger the _onMineCountChange
function to modify the seconds of time;
---->[lib/sweeper/game/heroes/hud/sweeper_hud.dart]----
class SweeperHud extends PositionComponent with HasGameRef<SweeperGame> {
StreamSubscription<int>? _mineSubscription;
StreamSubscription<int>? _timerSubscription;
@override
void onMount() {
super.onMount();
_mineSubscription = game.mineCountStream.listen(_onMineCountChange);
_timerSubscription = game.timeCtrlStream.listen(_onTimerChange);
}
void _onMineCountChange(int event) {
leftScreen.value = event;
}
void _onTimerChange(int event) {
rightScreen.value = event;
}