Flutter

[코드분석] Minesweeper - 지뢰찾기 만들기

모리선생 2023. 4. 20. 07:01
728x90

목표

지뢰찾기 게임을 만들어 보면서 Flutter의 기능과 활용성을 알아본다. 클린코드란 무엇인지 생각해보면서 효율적인 코드를 만들 수 있는 방법을 생각해본다.


Flutter에 입문한지 얼마되지 않았지만, 공부를 하면서 느끼는것은 본인이 직접 코드를 짜보면서 라이브러리를 사용해보고 이해하는 것도 중요하거니와, 다른 사람이 작성한 코드를 보고 해석할 줄 아는 것도 중요하다는 생각이 든다. 특히 코드를 해석하는 관점에서 나보다 더 나은 클린코드를 작성하는 개발자의 코드가 있다면 곰곰히 생각하고 곱씹어보면서 어떻게 하면 내것으로 흡수할 수 있을지도 생각을 해보아야 한다. 이렇게 함으로써 최소한의 리소스로 다양한 기능을 구현하면서 쉽게 이해가능한 코드를 언젠간 구현할 수 있기 때문이다. 

 

그래서 이번에는 흥미로운 게임을 하나 찾아서 가지고 와봤다. 코드를 내 나름대로 분석하고 comment등을 참고하면서 어떤식으로 게임 어플리케이션을 만들었는지 확인해볼 것이다.

 

지뢰찾기 (Minesweeper)

* 본 코드는 medium.com의 Deven Joshi의 Creating Minesweepr in Flutter의 기사 내용을 참조하였다. Github 주소는 (https://github.com/deven98/FlutterMinesweeper)이며 전체코드를 열람할 수 있다.

실제 구동화면

코드 해석

파일 트리 (총 3가지 파일이 필요하다)

 

lib

ㄴ board_square.dart

ㄴ game_activity.dart

ㄴ main.dart 

 

각 파일별 목적은 다음과 같다.

board_square.dart : 각각의 square의 리스트를 만들기 위한 클래스를 선언한다.

game_activity.dart: 게임화면과 각각의 bomb의 생성, 배치, 게임 승리 조건등을 생성한다.

main.dart: MaterialApp()을 선언하여 하위 페이지를 담기 위한 그릇을 만든다.

 

지뢰찾기 게임을 만들기 위한 조건

(1) 그리드 (square를 포함) 생성

(2) square 상에 폭탄의 생성

(3) 폭탄이 각 square에 몇개 있는 확인할 수 있는 계산식

(4) 열린 상태의 square의 저장과 flagged된 square의 저장

(5) 폭탄이 없는 상태의 square를 눌렀을때, 주변의 square도 함께 열릴 수 있도록 하는 계산

(6) 승리조건의 확립

(7) 게임을 이기거나 졌을때, pop up widget을 통해 게임 user에게 알릴 수 있는 기능

 

등이 있을 것이다.

 

그럼 순서대로 한번 확인해보자.

 

1. main.dart

보통 flutter를 실행해서 가장 먼저 하는 작업이다. 모든 widget을 생성하기 전에 가장 먼저 큰 그릇을 생성하여서 담을 준비를 하는 것이다.

import 'package:flutter/material.dart';
import 'package:mine_sweeper_game/game_activity.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        
        primarySwatch: Colors.blue,
      ),
      home: GameActivity(),
    );
  }
}

 

2. board_square.dart

해당 코드는 square가 가득한 그리드에서 square 내에 폭탄을 가지고 있는지 혹은 폭탄이 주변에 몇개가 있는지 등을 먼저 선언하는 클래스이다. 가장 먼저 초기값은 hasBomb는 false 그리고 bombsAround는 0로 설정한다.

class BoardSquare {
  bool hasBomb;
  int bombsAround;

  BoardSquare({this.hasBomb = false, this.bombsAround = 0});
}

 

3. game_activity.dart

초기 세팅은 완료했다. 이제 실제 화면의 구성과 폭탄의 적용 혹은 폭탄이 존재하고 있는 indicator 숫자들을 만드는데 어떤 계산 방식이 삽입이 되었는지 확인해보자.

 

3-1. 게임 square의 열,행 개수 선언

지뢰찾기는 게임을 실행하면 square들이 일정크기로 나열되어 화면에 나타나는 것이 있다는 것을 경험해봤을 것이다. 몇개 정도의 사이즈로 만들지 생각을 해봐야하지 않겠는가? 여기서는 행 18개 그리고 열은 10개로 설정하였다.

  int rowCount = 18;
  int columnCount = 10;

 3-2. Square의 Grid 설정

square들이 생성되었을때 2D 형태의 board 형태로 존재를 할 것이기 때문에 BoardSquare의 클래스가 List들의 형태로 board를 생성한다고 선언해준다.

late List<List<BoardSquare>> board;

3-3. board squares의 initialization

일단 board squares의 initialization을 통해서 행과 열을 각각 18개와 10개로 생성하도록 하고 이를 Boardsqure 형태로 반환하도록 하였다.

board = List.generate(rowCount, (i) {
      return List.generate(columnCount, (j) {
        return BoardSquare();
      });
    });

3-4. bomb probability와 maxProbability의 설정

bomb probability는 bomb가 각 square에서 폭탄이 될 확률을 의미한다. 게임을 하다 보면, 폭탄의 확률을 높여서 게임의 난이도를 조절하는데 여기서는 폭탄이 있는 사각형이 33%의 확률로 나타나게 되는 것이다. 그리고 maxProbability가 15로 설정해 각 사각형이 폭탄이 될 확률을 0부터 15까지 가지도록 설정하였다.

int bombProbability = 3;
int maxProbability = 15;

Random random = new Random();
    for (int i = 0; i < rowCount; i++) {
      for (int j = 0; j < columnCount; j++) {
        int randomNumber = random.nextInt(maxProbability);
        if (randomNumber < bombProbability) {
          board[i][j].hasBomb = true;
          bombCount++;
        }
      }
    }

여기서 보면 maxProbability에서 랜덤한 정수를 생성하고 randomNumber보다 bombProbability가 크다면 해당 위치에 폭탄을 놓는다. 그리고 난 후에 bombCount를 1 증가 시킨다.

 

3-5. bombs가 각각의 square에 어떻게 존재하고 있는지 확인하기

bomb를 설치를 해놓았다면 어디에 몇개가 있는지 확인을 할 수 있어야 한다. for 루프를 사용하면서 체크를 한다.

일단 먼저 하나의 예시를 가져와서 보면,

   if (i > 0 && j > 0) {
      if (board[i - 1][j - 1].hasBomb) {
        board[i][j].bombsAround++;
      }
    }

여기서는 왼쪽 대각선의 square가 bomb라면 bombsAround의 값을 1 증가시킨다.

   if (i > 0) {
      if (board[i - 1][j].hasBomb) {
        board[i][j].bombsAround++;
      }
    }

해당 코드의 경우 현재 square의 왼쪽에 bomb가 위치하는 경우 bombsAround의 값을 1 증가시킨다는 것이다.

 

이렇게 해서 현재 square의 주변에 있는 sqaure (총 8개)의 bomb의 존재를 체크하는 코드를 작성하면 다음과 같다.

for (int i = 0; i < rowCount; i++) {
      for (int j = 0; j < columnCount; j++) {
        if (i > 0 && j > 0) {
          if (board[i - 1][j - 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (i > 0) {
          if (board[i - 1][j].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (i > 0 && j < columnCount - 1) {
          if (board[i - 1][j + 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (j > 0) {
          if (board[i][j - 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (j < columnCount - 1) {
          if (board[i][j + 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (i < rowCount - 1 && j > 0) {
          if (board[i + 1][j - 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (i < rowCount - 1) {
          if (board[i + 1][j].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (i < rowCount - 1 && j < columnCount - 1) {
          if (board[i + 1][j + 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }
      }
    }

    setState(() {});
  }

 

3-6. Opened와 flagged된 square를 저장하기

우리가 찾은 bomb의 위치에 flagged를 하든 위험을 감수하고 open을 하든 그 상태가 남아있어야 할 것이다. 그렇다면 결국 해당 정보를 저장할 수 있는 객체를 선언하여야 한다. 이는 다음과 같은 방식으로 진행한다. rowCount * columnCount에서 open 혹은 flagged된 square가 있는지 훑으면서 확인한다.

//store the opened and flagged ones
late List<bool> openedSquares;

late List<bool> flaggedSquares;

//initialise the opened and flagged ones
openedSquares = List.generate(rowCount * columnCount, (i) {
      return false;
    });
    
flaggedSquares = List.generate(rowCount * columnCount, (i) {
      return false;
    });

 

3-7. Recursive 기능을 통해 bombs가 존재하지 않는 square까지 열리게 하는 기능

해당 기능은 square를 눌렀을때 동,서,남,북의 square에 bomb가 존재하지 않을시 자동으로 열리도록 해주는 기능이다. 예를 들어 하나 설명을 해보자.

void _handleTap(int i, int j) {
    int position = (i * columnCount) + j;
    openedSquares[position] = true;
    squaresLeft = squaresLeft - 1;

    if (i > 0) {
      if (!board[i - 1][j].hasBomb &&
          openedSquares[((i - 1) * columnCount) + j] != true) {
        if (board[i][j].bombsAround == 0) {
          _handleTap(i - 1, j);
        }
      }
    }

여기서 보면 현재 클릭한 위치의 위쪽이 있는지 확인하고 bomb가 없다면 그리고 이미 연릭적이 없다면 해당위치에 있는 _handleTap 함수를 재귀적으로 호출을 하여 결국 모든 빈 타일들이 자동으로 열리게 된다. 이 열리는 코드의 경우는 다음과 같은 코드로 생성하여 반영할 것이다.

if (board[rowNumber][columnNumber].bombsAround == 0) {
                    _handleTap(rowNumber, columnNumber);
                  } else {
                    setState(() {
                      openedSquares[position] = true;
                      squaresLeft = squaresLeft - 1;
                    });
                  }

해당 코드는 나중에 화면 구성할때 사용한다고 보면되고, bomb가 주변에 있는지 없는지 혹은 이미 opened이 되었던 square인지 확인하여 주변의 square까지 모두 열리게 하는 코드를 종합해서 만든다면 다음과 같다.

void _handleTap(int i, int j) {
    int position = (i * columnCount) + j;
    openedSquares[position] = true;
    squaresLeft = squaresLeft - 1;

    if (i > 0) {
      if (!board[i - 1][j].hasBomb &&
          openedSquares[((i - 1) * columnCount) + j] != true) {
        if (board[i][j].bombsAround == 0) {
          _handleTap(i - 1, j);
        }
      }
    }

    if (j > 0) {
      if (!board[i][j - 1].hasBomb &&
          openedSquares[(i * columnCount) + j - 1] != true) {
        if (board[i][j].bombsAround == 0) {
          _handleTap(i, j - 1);
        }
      }
    }

    if (j < columnCount - 1) {
      if (!board[i][j + 1].hasBomb &&
          openedSquares[(i * columnCount) + j + 1] != true) {
        if (board[i][j].bombsAround == 0) {
          _handleTap(i, j + 1);
        }
      }
    }

    if (i < rowCount - 1) {
      if (!board[i + 1][j].hasBomb &&
          openedSquares[((i + 1) * columnCount) + j] != true) {
        if (board[i][j].bombsAround == 0) {
          _handleTap(i + 1, j);
        }
      }
    }

    setState(() {});
  }

3-8. 승리조건 만들기

squaresLeft가 bombCount보다 작거나 같다면 승리다. 

if (squaresLeft <= bombCount) {
                    _handleWin();
                  }
void _handleWin() {
    showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: Text("Congratulations!"),
          content: Text("You Win!"),
          actions: <Widget>[
            ElevatedButton(
              onPressed: () {
                _initialiseGame();
                Navigator.pop(context);
              },
              child: Text("Play again"),
            ),
          ],
        );
      },
    );
  }

3-9. 실제 board의 구현 (UI)

조건들에 대한 부분을 어느정도 설명을 하였으니 이제 UI적인 측면에서 사용자에게 보여주어야 한다. 이때는 GridView.builder를 사용할 것이다. 지정한 범위내에서 필요한 만큼의 그리드 타일을 만드는데 좋다. 그리고 flagged일때, opened일때, bomb가 주변에 몇개있는지 알려줄때 등을 각각 표현할때 이미지를 불러와야한다. 이를 종합하면 다음과 같다.

GridView.builder(
            shrinkWrap: true,
            physics: NeverScrollableScrollPhysics(),
            gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: columnCount,
            ),
            itemBuilder: (context, position) {
              // Get row and column number of square
              int rowNumber = (position / columnCount).floor();
              int columnNumber = (position % columnCount);

              Image image;

              if (openedSquares[position] == false) {
                if (flaggedSquares[position] == true) {
                  image = getImage(ImageType.flagged);
                } else {
                  image = getImage(ImageType.facingDown);
                }
              } else {
                if (board[rowNumber][columnNumber].hasBomb) {
                  image = getImage(ImageType.bomb);
                } else {
                  image = getImage(
                    getImageTypeFromNumber(
                        board[rowNumber][columnNumber].bombsAround),
                  );
                }
              }

              return InkWell(
                // Opens square
                onTap: () {
                  if (board[rowNumber][columnNumber].hasBomb) {
                    _handleGameOver();
                  }
                  if (board[rowNumber][columnNumber].bombsAround == 0) {
                    _handleTap(rowNumber, columnNumber);
                  } else {
                    setState(() {
                      openedSquares[position] = true;
                      squaresLeft = squaresLeft - 1;
                    });
                  }

                  if (squaresLeft <= bombCount) {
                    _handleWin();
                  }
                },
                // Flags square
                onLongPress: () {
                  if (openedSquares[position] == false) {
                    setState(() {
                      flaggedSquares[position] = true;
                    });
                  }
                },
                splashColor: Colors.grey,
                child: Container(
                  color: Colors.grey,
                  child: image,
                ),
              );
            },
            itemCount: rowCount * columnCount,
          ),
        ],
      ),
    );
  }

3. 전체 코드 (game_activity.dart)

여기서 enum이라는 개념이 존재하는데 이는 열거형 데이터로써, 상수의 이름을 정의하고 해당하는 값을 지정할 수 있다. 게임에서 플레이어의 상태를 나타내는 상수를 정의할때 다음과 같이 쓴다. 

enum PlayerState {
  Idle,
  Running,
  Jumping,
  Attacking
}

자 그럼 이제 지금까지 작성한 game_activity.dart의 전체코드를 확인해보자.

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:mine_sweeper_game/board_square.dart';

// Types of images available
enum ImageType {
  zero,
  one,
  two,
  three,
  four,
  five,
  six,
  seven,
  eight,
  bomb,
  facingDown,
  flagged,
}

class GameActivity extends StatefulWidget {
  @override
  _GameActivityState createState() => _GameActivityState();
}

class _GameActivityState extends State<GameActivity> {
  // Row and column count of the board
  int rowCount = 18;
  int columnCount = 10;

  // The grid of squares
  late List<List<BoardSquare>> board;

  // "Opened" refers to being clicked already
  late List<bool> openedSquares;

  // A flagged square is a square a user has added a flag on by long pressing
  late List<bool> flaggedSquares;

  // Probability that a square will be a bomb
  int bombProbability = 3;
  int maxProbability = 15;

  int bombCount = 0;
  late int squaresLeft;

  @override
  void initState() {
    super.initState();
    _initialiseGame();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: <Widget>[
          Container(
            color: Colors.grey,
            height: 60.0,
            width: double.infinity,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                InkWell(
                  onTap: () {
                    _initialiseGame();
                  },
                  child: CircleAvatar(
                    child: Icon(
                      Icons.face,
                      color: Colors.black,
                      size: 40.0,
                    ),
                    backgroundColor: Colors.greenAccent,
                  ),
                )
              ],
            ),
          ),
          // The grid of squares
          GridView.builder(
            shrinkWrap: true,
            physics: NeverScrollableScrollPhysics(),
            gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
              crossAxisCount: columnCount,
            ),
            itemBuilder: (context, position) {
              // Get row and column number of square
              int rowNumber = (position / columnCount).floor();
              int columnNumber = (position % columnCount);

              Image image;

              if (openedSquares[position] == false) {
                if (flaggedSquares[position] == true) {
                  image = getImage(ImageType.flagged);
                } else {
                  image = getImage(ImageType.facingDown);
                }
              } else {
                if (board[rowNumber][columnNumber].hasBomb) {
                  image = getImage(ImageType.bomb);
                } else {
                  image = getImage(
                    getImageTypeFromNumber(
                        board[rowNumber][columnNumber].bombsAround),
                  );
                }
              }

              return InkWell(
                // Opens square
                onTap: () {
                  if (board[rowNumber][columnNumber].hasBomb) {
                    _handleGameOver();
                  }
                  if (board[rowNumber][columnNumber].bombsAround == 0) {
                    _handleTap(rowNumber, columnNumber);
                  } else {
                    setState(() {
                      openedSquares[position] = true;
                      squaresLeft = squaresLeft - 1;
                    });
                  }

                  if (squaresLeft <= bombCount) {
                    _handleWin();
                  }
                },
                // Flags square
                onLongPress: () {
                  if (openedSquares[position] == false) {
                    setState(() {
                      flaggedSquares[position] = true;
                    });
                  }
                },
                splashColor: Colors.grey,
                child: Container(
                  color: Colors.grey,
                  child: image,
                ),
              );
            },
            itemCount: rowCount * columnCount,
          ),
        ],
      ),
    );
  }

  // Initialises all lists
  void _initialiseGame() {
    // Initialise all squares to having no bombs
    board = List.generate(rowCount, (i) {
      return List.generate(columnCount, (j) {
        return BoardSquare();
      });
    });

    // Initialise list to store which squares have been opened
    openedSquares = List.generate(rowCount * columnCount, (i) {
      return false;
    });

    flaggedSquares = List.generate(rowCount * columnCount, (i) {
      return false;
    });

    // Resets bomb count
    bombCount = 0;
    squaresLeft = rowCount * columnCount;

    // Randomly generate bombs
    Random random = new Random();
    for (int i = 0; i < rowCount; i++) {
      for (int j = 0; j < columnCount; j++) {
        int randomNumber = random.nextInt(maxProbability);
        if (randomNumber < bombProbability) {
          board[i][j].hasBomb = true;
          bombCount++;
        }
      }
    }

    // Check bombs around and assign numbers
    for (int i = 0; i < rowCount; i++) {
      for (int j = 0; j < columnCount; j++) {
        if (i > 0 && j > 0) {
          if (board[i - 1][j - 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (i > 0) {
          if (board[i - 1][j].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (i > 0 && j < columnCount - 1) {
          if (board[i - 1][j + 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (j > 0) {
          if (board[i][j - 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (j < columnCount - 1) {
          if (board[i][j + 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (i < rowCount - 1 && j > 0) {
          if (board[i + 1][j - 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (i < rowCount - 1) {
          if (board[i + 1][j].hasBomb) {
            board[i][j].bombsAround++;
          }
        }

        if (i < rowCount - 1 && j < columnCount - 1) {
          if (board[i + 1][j + 1].hasBomb) {
            board[i][j].bombsAround++;
          }
        }
      }
    }

    setState(() {});
  }

  // This function opens other squares around the target square which don't have any bombs around them.
  // We use a recursive function which stops at squares which have a non zero number of bombs around them.
  void _handleTap(int i, int j) {
    int position = (i * columnCount) + j;
    openedSquares[position] = true;
    squaresLeft = squaresLeft - 1;

    if (i > 0) {
      if (!board[i - 1][j].hasBomb &&
          openedSquares[((i - 1) * columnCount) + j] != true) {
        if (board[i][j].bombsAround == 0) {
          _handleTap(i - 1, j);
        }
      }
    }

    if (j > 0) {
      if (!board[i][j - 1].hasBomb &&
          openedSquares[(i * columnCount) + j - 1] != true) {
        if (board[i][j].bombsAround == 0) {
          _handleTap(i, j - 1);
        }
      }
    }

    if (j < columnCount - 1) {
      if (!board[i][j + 1].hasBomb &&
          openedSquares[(i * columnCount) + j + 1] != true) {
        if (board[i][j].bombsAround == 0) {
          _handleTap(i, j + 1);
        }
      }
    }

    if (i < rowCount - 1) {
      if (!board[i + 1][j].hasBomb &&
          openedSquares[((i + 1) * columnCount) + j] != true) {
        if (board[i][j].bombsAround == 0) {
          _handleTap(i + 1, j);
        }
      }
    }

    setState(() {});
  }

  // Function to handle when a bomb is clicked.
  void _handleGameOver() {
    showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: Text("Game Over!"),
          content: Text("You stepped on a mine!"),
          actions: <Widget>[
            ElevatedButton(
              onPressed: () {
                _initialiseGame();
                Navigator.pop(context);
              },
              child: Text("Play again"),
            ),
          ],
        );
      },
    );
  }

  void _handleWin() {
    showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: Text("Congratulations!"),
          content: Text("You Win!"),
          actions: <Widget>[
            ElevatedButton(
              onPressed: () {
                _initialiseGame();
                Navigator.pop(context);
              },
              child: Text("Play again"),
            ),
          ],
        );
      },
    );
  }

  Image getImage(ImageType type) {
    switch (type) {
      case ImageType.zero:
        return Image.asset('images/0.png');
      case ImageType.one:
        return Image.asset('images/1.png');
      case ImageType.two:
        return Image.asset('images/2.png');
      case ImageType.three:
        return Image.asset('images/3.png');
      case ImageType.four:
        return Image.asset('images/4.png');
      case ImageType.five:
        return Image.asset('images/5.png');
      case ImageType.six:
        return Image.asset('images/6.png');
      case ImageType.seven:
        return Image.asset('images/7.png');
      case ImageType.eight:
        return Image.asset('images/8.png');
      case ImageType.bomb:
        return Image.asset('images/bomb.png');
      case ImageType.facingDown:
        return Image.asset('images/facingDown.png');
      case ImageType.flagged:
        return Image.asset('images/flagged.png');
      default:
        return Image.asset('images/defalut.png');
    }
  }

  ImageType getImageTypeFromNumber(int number) {
    switch (number) {
      case 0:
        return ImageType.zero;
      case 1:
        return ImageType.one;
      case 2:
        return ImageType.two;
      case 3:
        return ImageType.three;
      case 4:
        return ImageType.four;
      case 5:
        return ImageType.five;
      case 6:
        return ImageType.six;
      case 7:
        return ImageType.seven;
      case 8:
        return ImageType.eight;
      default:
        return ImageType.zero;
    }
  }
}

이렇게 하면 지뢰찾기 게임의 완성이다. getImageTypeFromNumber 함수를 사용하면 각 숫자에 맞는 이미지를 나타낼 수 있어 지정을 해두었다.

 

다음에도 flutter로 만들어볼 수 있는 게임의 코드를 가져와보고자 한다.

728x90