Minesweeper with Flutter (practice project)
Minesweeper is a puzzle game loved by many, basic playing is similar to the game that can be played in the google search result Minesweeper, so try searching Minesweeper in google with your smartphone
- write better readme
first create the logic of what happens when the player tries to dig up a cell
- number => open
- mine => lose
- blank => open self and open surrounding cells
used simple bfs to open surroundings, its implemented in mine_bloc.dart
// mine not in range (open surrounding)
else {
var checkQueue = [Point(targetX, targetY)];
var visited = [
for (var i = 0; i < sizeY; i++) [for (var j = 0; j < sizeX; j++) false]
];
while (checkQueue.isNotEmpty) {
var next = checkQueue.removeAt(0);
// if visited continue
if (visited[next.y][next.x]) continue;
var nextCell = mineBoard[next.y][next.x];
var nextCellState = (cell) {
if (cell == 0) {
return CellState.blank;
} else if (0 < cell && cell < 9) {
return CellState.number;
} else {
// logically, there shouldn't be any mines
throw Error();
}
}(nextCell);
// open next cell
cellStateMap[next.y][next.x] = nextCellState;
// set true to visited
visited[next.y][next.x] = true;
// if current cell is blank,
if (mineBoard[next.y][next.x] == 0) {
// check cells around you
for (var dx = -1; dx < 2; dx++) {
for (var dy = -1; dy < 2; dy++) {
var checkX = next.x + dx;
var checkY = next.y + dy;
if (!checkBounds(checkX, checkY)) continue;
if (visited[checkY][checkX]) continue;
checkQueue.add(Point(checkX, checkY));
}
}
}
}
}Used the Point object....just because.
if enough flags are in place, we can open closed cells around number cells, but if there is a mine in one of the targets, then its game over.
so check ther surroundings with simple double loops, check bounds, and add them to a queue if its not a flag, open cell, or mine.
for (var dx = -1; dx < 2; dx++) {
for (var dy = -1; dy < 2; dy++) {
var checkX = targetX + dx;
var checkY = targetY + dy;
// if in bounds
if (checkBounds(checkX, checkY)) {
// if flag
if (cellStateMap[checkY][checkX] == CellState.flag) {
// if flag is wrong
if (mineBoard[checkY][checkX] != 9) {
cellStateMap[checkY][checkX] = CellState.flagWrong;
}
}
// record mine
else if (mineBoard[checkY][checkX] == 9) {
mines.add(Point(checkX, checkY));
}
// record open
else if (cellStateMap[checkY][checkX] == CellState.closed) {
checkQueue.add(Point(checkX, checkY));
}
}
}
}
then call openCell for all cells in queue...if there is no mines. if there is a single mine in the surroundings, set them to mines.
// if any mines...
if (mines.isNotEmpty) {
// TODO lose
for (var mine in mines) {
cellStateMap[mine.y][mine.x] = CellState.mine;
}
}
// if no mines
else {
// open all surrounding closed cells
for (var check in checkQueue) {
// cell may open in the progress, continue already open
if (cellStateMap[check.y][check.x] != CellState.closed) continue;
openCell(check.x, check.y);
}
}now I have no idea how BLOC is used in real life, so just used my imaginations...
a State is like the State of a stateful widget, so defined a MineState State object to hold the state of the game
enum GameStatus { playing, win, lose }
enum CellState { closed, number, blank, flag, mine, flagWrong }
enum ControlStatus { none, all, shovel, flag }
class MineState {
final List<List<int>> mineBoard;
final List<List<CellState>> cellStateMap;
final int mineCount;
final int sizeX;
final int sizeY;
final ControlStatus controlStatus;
final int controlX;
final int controlY;
final int startTime;
GameStatus status;
MineState({
required this.mineBoard,
required this.cellStateMap,
required this.mineCount,
required this.sizeX,
required this.sizeY,
required this.startTime,
this.controlStatus = ControlStatus.none,
this.controlX = 0,
this.controlY = 0,
this.status = GameStatus.playing,
});
...mineBoard contains the raw data of a minesweeper game, its value correspond to
0: blank, no mine in the surrounding 8 cells1 ~ 8: number, which indicates the count of mines in surrounding 8 cells9: mine.
thought about making a enum for this as well...but it would mean enum containing 0 ~ 9 so just used int
the cellStateMap is for actual display of the board, which cell is opened and which cell is flagged. which is required, as a puzzle game there is an answer to this puzzle, and the player's answer sheet in this game is the cellStateMap.
since its a state for use in BLOC, also created a method for shallow copy (with change if needed)
...
MineState copyWith({
ControlStatus? controlStatus,
int? controlX,
int? controlY,
GameStatus? status,
}) {
return MineState(
mineBoard: mineBoard,
cellStateMap: cellStateMap,
mineCount: mineCount,
sizeX: sizeX,
sizeY: sizeY,
startTime: startTime,
controlStatus: controlStatus ?? this.controlStatus,
controlX: controlX ?? this.controlX,
controlY: controlY ?? this.controlY,
status: status ?? this.status,
);
}
...MineBloc reacts to MineEvents
abstract class MineEvent {}
...
class MineBloc extends Bloc<MineEvent, MineState> {some events (like opening cells) require which cell was hit, so the MineEvent class is extended to CellEvent
class CellEvent extends MineEvent {
final int x;
final int y;
CellEvent(this.x, this.y);
}actual event handling is done within the state methods
class MineBloc extends Bloc<MineEvent, MineState> {
MineBloc(MineState mineState) : super(mineState) {
on<TapCellEvent>((event, emit) {
emit(state.openControl(event.x, event.y));
});
on<ToggleFlagEvent>((event, emit) {
emit(state.flagCell(event.x, event.y));
});
on<OpenCellMulitEvent>((event, emit) {
emit(state.openCellMulti(event.x, event.y));
});
on<OpenCellEvent>((event, emit) {
emit(state.openCell(event.x, event.y));
});
on<CloseControlEvent>((event, emit) {
emit(state.closeControl());
});
}
}
...
class MineState {
...
MineState flagCell(int x, int y) {
var targetState = cellStateMap[y][x];
if (targetState == CellState.closed || targetState == CellState.flag) {
cellStateMap[y][x] =
targetState == CellState.closed ? CellState.flag : CellState.closed;
return copyWith(
controlStatus: ControlStatus.none,
);
} else {
return this;
}
}
...
}hopefully the names will suffice for their functions
used a LayoutBuilder to draw the screen
body: Container(
color: Colors.lightBlue,
child: SafeArea(
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
var sizeFromWidth = constraints.maxHeight * 0.9 / sizeY;
var sizeFromHeight = constraints.maxWidth * 0.9 / sizeX;
var panelSize = sizeFromHeight > sizeFromWidth
? sizeFromWidth
: sizeFromHeight;
var padddingSize = sizeFromHeight > sizeFromWidth
? constraints.maxHeight * 0.1
: constraints.maxWidth * 0.1;
return MineBoard(
padddingSize: padddingSize,
cellSize: panelSize,
countHorizontal: sizeX,
countVertical: sizeY,
);
},
),
),
),
),for the cellSize(panelSize), divide each 90% of width and height of the constraint by count of horizontal cells and vertical cells, then use the one which is smaller. so the min padding will always be 10% of the constraint of the game screen.
the actual padding size is delivered to the child MineBoard, which does the actual drawing, which is required...
to keep the MineBoard widget a stateless widget, any variables that may change depending on state was calculated in the BlocBuilder's builder argument
@override
Widget build(BuildContext context) {
return BlocBuilder<MineBloc, MineState>(
builder: (context, state) {
var controlPadding = (65 - cellSize > 0 ? 65 - cellSize : 0).toDouble();
var tapBefore = Timeline.now;
var controlPosition = _controlPosition(
state.controlX,
state.controlY,
countHorizontal,
countVertical,
);start with Stack to draw both the board and the controls
the board is actually a child of a SizedBox which is a little bigger than all the drawn cells
return Stack(
clipBehavior: Clip.none,
children: [
SizedBox(
width: cellSize * countHorizontal + controlPadding,
height: cellSize * countVertical + controlPadding,
...which was used because the controls can get off the board when the cell size is smaller than the control buttons, and once it get off the Stack the GestureDetector of the controls doesn't work
the tapBefore var is also included, because defining both the onDoubleTap and onTap of the GestureDetector (of cells) results in heavy delays
just draw the cells with double collection-for loops
child: Column(
children: [
for (var i = 0; i < countVertical; i++)
Row(
children: [
for (var j = 0; j < countHorizontal; j++)
GestureDetector(
onTap: () {
if (!(state.cellStateMap[i][j] ==
CellState.blank)) {
if (Timeline.now - tapBefore < 300000) {
context
.read<MineBloc>()
.add(OpenCellEvent(j, i));
} else {
tapBefore = Timeline.now;
context
.read<MineBloc>()
.add(TapCellEvent(j, i));
}
} else {
context
.read<MineBloc>()
.add(CloseControlEvent());
}
},
behavior: HitTestBehavior.translucent,
child: _drawCell(
state.cellStateMap[i][j],
state.mineBoard[i][j],
Point(j, i),
state.controlStatus != ControlStatus.none &&
state.controlX == j &&
state.controlY == i,
),
),
],
),
],
),
the several color variations are a pain in the ass, all implemented in dirty if - else statements
Widget? _drawCell(CellState cellState, int cellValue, Point cell, bool controlOpen) {
if (cellState == CellState.closed) {
return Container(
width: cellSize,
height: cellSize,
decoration: BoxDecoration(
color: (cell.x + cell.y) % 2 == 0
? Colors.blueGrey[100]
: Colors.blueGrey[200],
border: _setBorder(controlOpen),
),
);
} else if (cellState == CellState.flag) {
...at the top of the stack there are the controls...
whether the controls are actually drawn depends, so ternary operation is used
state.controlStatus != ControlStatus.none
? Positioned(
top: state.controlY * cellSize +
_controlOffsetY(state.controlY, controlPosition,
cellSize, controlPadding),
left: state.controlX * cellSize +
_controlOffsetX(state.controlX, controlPosition,
cellSize, controlPadding),
child: Controls(
position: controlPosition,
controlStatus: state.controlStatus,
cellSize: cellSize,
),
)
: const SizedBox(),Positioned are used...because translated widgets' GestureDetector doesn't work....or I couldn't find how.
because the control's display(positioning of buttons) differs based on the cell the controls are targeting, controlPosition argument is calculated in advance in the builder function, then passed to the Controls widget. theoretically it can be determined in the state (thus in the Contorls), but the board still has to calculate Positioned's top and left, so its passed on to the controls
because Transform could not be used, Column with two Rows are used. it has to be reversed depending on the position, so the children of each Column and Row are calculated in methods, then reveresed based on position
...
@override
Widget build(BuildContext context) {
return BlocBuilder<MineBloc, MineState>(
builder: (context, state) {
return Column(
children: reverseColOn.contains(position)
? columnChildren(context, state).reversed.toList()
: columnChildren(context, state),
);
},
);
}
List<Widget> columnChildren(BuildContext context, MineState state) {
return [
Row(
children: reverseRowOn.contains(position)
? _topControls(context, state).reversed.toList()
: _topControls(context, state),
),
Row(
children: reverseRowOn.contains(position)
? _botControls(context, state).reversed.toList()
: _botControls(context, state),
),
];
}
...and which buttons are displayed...are complete jumble of gibberish
static const reverseColOn = {
ControlPosition.botLeft,
ControlPosition.botRight,
};
static const reverseRowOn = {
ControlPosition.topRight,
ControlPosition.botRight,
};
static const showFlagOn = {
ControlStatus.all,
ControlStatus.flag,
};
static const showShovelOn = {
ControlStatus.all,
ControlStatus.shovel,
};
...
List<Widget> _botControls(BuildContext context, MineState state) {
return [
GestureDetector(
onTap: () {
switch (controlStatus) {
case ControlStatus.shovel:
context
.read<MineBloc>()
.add(OpenCellMulitEvent(state.controlX, state.controlY));
break;
case ControlStatus.all:
context
.read<MineBloc>()
.add(OpenCellEvent(state.controlX, state.controlY));
break;
default:
break;
}
},
child: showShovelOn.contains(controlStatus)
? Container(
width: 65,
height: 65,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
border: Border.all(width: 4, color: Colors.blue),
borderRadius: BorderRadius.circular(32.5),
color: Colors.lightBlueAccent,
),
child: Image.asset(
"assets/flaticon-shovel.png",
color: Colors.blue[900],
),
)
: const SizedBox(
width: 65,
height: 65,
),
),
...
even more confusing, the shovel acts differently depending on whether the cell is closed or number