PythonでMinesweeperアルゴリズムを考える

PythonでMinesweeperアルゴリズムを考える

情報系に進んでいる大学生なら一度は組んだことがあるであろう(組ませたことがある)プログラムの1つ、マインスイーパーについて、今回はPythonで組んでゆきたいと思います。

私も大学1年のとき(? だったと思う)講義で課題として出されたので、C言語で組んだのですが、「ちょっとアルゴリズムが違くないか?」と思っていました(実際に講義で言われた手法は厳密な意味でのマインスイーパーではなかった)

なので今回は、独自に組んでゆきたいと思います。

アルゴリズム

マインスイーパーのアルゴリズムといえば以下のようになっています。

  1. マインスイーパーのオブジェクトを生成する
  2. ゲームボード(地雷と周辺情報が埋めこまれているもの)とプレイボード(開いているか、閉じているか、フラグが立っているか)を構築する
  3. インデックスを指定する
    1. ゲームボードで指定したインデックスの値がMINEならばゲーム終了
    2. ゲームボードで指定したインデックスの値が1以上ならばプレイボードのインデックスの値をOPENにする
    3. ゲームボードで指定したインデックスの値がSPACEならば周辺インデックスについても調べる。全ての周辺が1以上になるまでプレイボードの周辺インデックスの値をOPENにし続ける
  4. ゲームが終わるまで3に戻る
  5. プレイボードで開ける部分を全部開いたら終了

さらに、「フラグを立てる」や「すでに開いている部分を指定すると適宜開く」などを含めて改良した場合はこのようになります。

  1. マインスイーパーのオブジェクトを生成する
  2. ゲームボード(地雷と周辺情報が埋めこまれているもの)とプレイボード(開いているか、閉じているか、フラグが立っているか)を構築する
  3. インデックスを指定する
  4. フラグを立てることにした場合は以下のようにする
    1. プレイボードで指定したインデックスの値がFLAGの場合は、CLOSE
    2. プレイボードで指定したインデックスの値がCLOSEの場合は、FLAGにする
  5. フラグを立てないことにした場合は以下のようにする
    1. ゲームボードで指定したインデックスの値がMINEならばゲーム終了
    2. ゲームボードで指定したインデックスの値が1以上ならばプレイボードのインデックスの値をOPENにする
    3. ゲームボードで指定したインデックスの値がSPACEならば周辺インデックスについても調べる。FLAGとなっていない全ての周辺が1以上になるまでプレイボードの周辺インデックスの値をOPENにし続ける
    4. プレイボードで指定したインデックスの値がFLAGならばプレイボードの値はそのままにする
    5. プレイボードで指定したインデックスの値がOPENで周辺インデックスのFLAGの数がゲームボードで指定したインデックスの値以上になっているなら周辺インデックスについても調べる。FLAGとなっていない全ての周辺が1以上になるまでプレイボードの周辺インデックスの値をOPENにし続ける
  6. ゲームが終わるまで3に戻る
  7. プレイボードで開ける部分を全部開いたら終了

プログラムの組む順番

  1. 表示するボードとゲームで見せるボード
  2. 地雷を埋め込む部分
  3. ボードを開く部分
  4. 終了条件
  5. (おまけ)フラグを立てる
  6. (おまけ)すでに開いている部分を指定すると適宜開くように改良

数値を指定しなきゃいけないのに文字を指定するなどの例外処理を適宜書くのはちょっと面倒だったので、そこら辺は省いています。

環境

  • numpy 1.18.1
  • Python 3.8.0

1. 表示するボードとゲームで見せるボード

ここはウェイト軽めです笑

とりあえずプログラムを組んでみましょう!

import numpy as np
import itertools


class Minesweeper(object):

    def __init__(self, shape: (int, int)=(9, 9)):
        self.set_board(shape)


    def set_board(self, shape: (int, int)):

        self.shape  = shape          # あとで使う
        self.size   = np.prod(shape) # あとで使う
        self.gboard = np.zeros(shape, dtype=np.int8)
        self.pboard = np.zeros(shape, dtype=np.int8)



if __name__ == '__main__':

    g = Minesweeper()

ここでは地雷と周辺情報を埋め込んであるゲームボード用にgboard、フラグを立てたとか開いたとかを保存するプレイボード用にpboardという変数を用意しました。

また、shapeはそのまま、ボードの行数と列数を表し、sizeはその積です。あとで使うので書いておきます。

でも、このままだと「何がどうなってるのかわからない!」と思うので、実際に表示しながら確認してゆきましょう!

...
if __name__ == '__main__':

    g = Minesweeper()
    print(g.gboard)
    print(p.board)

んで、実際に実行!

[[0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0]
...
 [0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0]]
[[0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0]
...
 [0 0 0 0 0 0 0 0 0]
 [0 0 0 0 0 0 0 0 0]]

こんな感じでゼロ行列が2つ表示されたら正解です。

2. 地雷を埋め込む部分

次は地雷を埋め込む部分を書いてゆきます。

ここでは主に

  1. 地雷を埋め込む
  2. 周辺情報を作り出す
  3. ゲームボードをもっと見やすくするように改良する

ということをやってゆきます。

1. 地雷を埋め込む

ここは地雷を埋め込むだけなのでウェイト軽め。

import numpy as np
import itertools


MINE  = -1


class Minesweeper(object):
    def __init__(self, shape: (int, int)=(9, 9), mines: int=10):

        self.set_board(shape)
        self.set_mines(mines)


    def set_board(self, shape: (int, int)):

        self.shape  = shape
        self.size   = np.prod(shape) 
        self.gboard = np.zeros(shape, dtype=np.int8)
        self.pboard = np.zeros(shape, dtype=np.int8)


    def set_mines(self, mines: int):

        r, c = shape = self.shape
        if mines >= self.size:
            raise Exception(f'mines must be less than product of shape.<{mines=}, {shape=}>')

        self.mines = mines
        for ix in np.random.choice(range(self.size), mines, replace=False):
            self.gboard[divmod(ix, shape[1])] = MINE
        

def main():

    g = Minesweeper()
    print(g.gboard)


if __name__ == '__main__':

    main()

とりあえず、MINE = -1と無難に設定しました。

また、sizeよりも大きな値で地雷は設定できないのでこの部分の例外を規定しておきました。

numpyはとても便利ですね! choicerange、さらにdivmodを組み合わせればこのように簡単に「被らない形で地雷を埋め込むこと」ができるのですから笑

実行してみるとこんな感じになります(なお、randomを使っているので若干表示は異なります)

[[ 0  0  0  0 -1  0  0  0  0]
 [ 0  0  0  0  0  0  0  0  0]
 [ 0  0  0  0  0  0  0  0  0]
 [ 0  0  0  0  0  0  0  0 -1]
 [ 0 -1  0  0  0  0  0  0 -1]
 [ 0  0 -1  0 -1  0 -1 -1  0]
 [ 0  0  0  0  0  0  0  0  0]
 [ 0  0  0  0 -1  0  0  0  0]
 [ 0  0  0  0  0  0  0  0 -1]]

2. 周辺情報を作り出す

次に、周辺情報を作り出します。具体的なフローは以下のようになります。

  1. gboardに類似したゼロ行列tmpを宣言
  2. tmpに\(\displaystyle \left[ \begin{array}{ccc}1 & 1 & 1\\1 & 0 & 1\\1 & 1 & 1 \end{array} \right] \)を畳み込み演算する
  3. gboard == MINEtmpにブロードキャスト
  4. gboard = tmpとする

とりあえず、プログラムを組んでみましょう!

...
    def set_mines(self, mines: int):

        r, c = shape = self.shape
        if mines >= self.size:
            raise Exception(f'mines must be less than product of shape.<{mines=}, {shape=}>')

        self.mines = mines
        for ix in np.random.choice(range(self.size), mines, replace=False):
            self.gboard[divmod(ix, shape[1])] = MINE
        
        tmp = np.zeros(shape, dtype=np.int8)
        # scipyはノロいので、畳み込み部分は勝手に高速化
        for r_, c_ in itertools.product(range(-1, 2), range(-1, 2)):
            if r_ == c_ == 0:
                continue

            tmp[max(0, r_):min(r, r+r_), max(0, c_):min(c, c+c_)] -= self.gboard[max(0, -r_):min(r, r-r_), max(0, -c_):min(c, c-c_)]

        tmp[self.gboard == MINE] = MINE
        self.gboard = tmp
        del tmp
...

本当はscipyのconvolve2dを使っても悪くはないんだけど、私は個人的にノロいのは使いたくないので勝手に改良しています。

んで、実際に実行してみるこうなります。

[[ 1  1  0  0  1  1  1  1 -1]
 [-1  2  1  1  2 -1  2  1  1]
 [ 1  2 -1  1  2 -1  2  1  1]
 [ 0  1  1  1  1  1  1  1 -1]
 [ 1  1  0  0  0  0  0  2  2]
 [-1  1  0  0  0  0  0  1 -1]
 [ 1  1  0  0  0  0  0  1  1]
 [ 0  0  1  1  2  1  1  0  0]
 [ 0  0  1 -1  2 -1  1  0  0]]

3. ゲームボードをもっと見やすくするように改良する

このままだとゲームボードが読みにくいので、改良してデバッグのときに見やすい状態にしたいと思います。

とりあえず、こんな感じにしました。

import numpy as np
import itertools


MINE  = -1
SPACE =  0


class Minesweeper(object):
...
    def get_gboard(self):

        r, c = self.shape
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)

        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                tmp = self.gboard[r_, c_]
                if tmp == SPACE:
                    tmp = ' '
                elif tmp == MINE:
                    tmp = 'X'

                print(tmp, end='')
            print()

        return self.gboard



def main():

    g = Minesweeper()
    g.get_gboard()


if __name__ == '__main__':

    main()

んで、出力。

  |012345678
--+---------
 0|XX1   1X1
 1|3431  111
 2|1XX211   
 3|2322X1   
 4|X1 111   
 5|1211     
 6|12X1     
 7|X321     
 8|2X1      

おお、見やすい……。

ここでは、何もないところをSPACE、地雷をMINEとして、SPACEの場所ではが出力されるように、MINEの場所ではXが出力されるようにしています。

3. ボードを開く部分

次にプレイボードを開く改良をしてゆきます。

import numpy as np
import itertools


OPEN  =  1
CLOSE =  0
SPACE =  0
MINE  = -1


class Minesweeper(object):
...
    def open_pboard(self, index: (int, int)):

        r, c = self.shape
        r_, c_ = index
        if 0 <= r_ < r and 0 <= c_ < c:
            pboard, gboard = self.pboard, self.gboard

            if gboard[index] == MINE:
                pass # ゲームが終わる部分をあとで書く
    
            elif pboard[index] == CLOSE:
                open_index_li = [index]
                while len(open_index_li) > 0:
                    # index周辺8セルにSPACEがないか調べる
                    r_, c_ = index = open_index_li[0]
                    if gboard[index] == SPACE:
                        for index_ in itertools.product(range(r_-1, r_+2), range(c_-1, c_+2)):
                            if 0 <= index_[0] < r and 0 <= index_[1] < c and pboard[index_] == CLOSE:
                                if gboard[index_] != MINE:
                                    pboard[index_] = OPEN
    
                                if gboard[index_] == SPACE:
                                    open_index_li.append(index_)

                    elif gboard[index] != MINE and pboard[index] == CLOSE:
                        pboard[index] = OPEN
    
                    # 最後に調べたindexを削除
                    open_index_li.remove(index)



def main():

    g = Minesweeper()
    g.get_gboard()
    print()
    index = tuple(map(int, input('input index("row col [mode]"): ').split(' ')))
    g.open_pboard(index)
    print(g.pboard)


if __name__ == '__main__':

    main()

これを実行すればようやくちゃんと開くプレイボードが実現できます!

  |012345678
--+---------
 0|1221   1X
 1|1XX1   11
 2|1221     
 3| 111     
 4| 2X31    
 5| 2XX1    
 6|12332    
 7|X12X2  11
 8|112X2  1X

input index("row col [mode]"): 0 4
[[0 0 0 1 1 1 1 1 0]
 [0 0 0 1 1 1 1 1 1]
 [0 0 0 1 1 1 1 1 1]
 [0 0 0 1 1 1 1 1 1]
 [0 0 0 1 1 1 1 1 1]
 [0 0 0 0 1 1 1 1 1]
 [0 0 0 0 1 1 1 1 1]
 [0 0 0 0 1 1 1 1 1]
 [0 0 0 0 1 1 1 1 0]]

こんな感じで出力されれば成功です。

ただ、これではプレイボードが非常に見づらいので改良します。

というか、プレイボードがちゃんと表示されなかったらゲームにならないですよね笑

...
    def display_board(self):

        r, c = self.shape
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)

        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                tmp = self.pboard[r_, c_]
                if tmp == OPEN:
                    tmp = self.gboard[r_, c_]
                    if tmp == SPACE:
                        tmp = ' '
                    elif tmp == MINE:
                        tmp = 'X'
                else:
                    tmp = '+'

                print(tmp, end='')

            print()

...

これを実行すればようやくマインスイーパーらしくなります。

  |012345678
--+---------
 0|1X223X1  
 1|112XX2111
 2|  1221 1X
 3|1221   11
 4|1XX1     
 5|1221     
 6|   11211 
 7|   1X3X1 
 8|   12X21 

  |012345678
--+---------
 0|+++++++++
 1|+++++++++
 2|+++++++++
 3|+++++++++
 4|+++++++++
 5|+++++++++
 6|+++++++++
 7|+++++++++
 8|+++++++++
input index("row col [mode]"): 8 0
  |012345678
--+---------
 0|+++++++++
 1|+++++++++
 2|+++++++++
 3|+++++++++
 4|+++++++++
 5|1221+++++
 6|   1+++++
 7|   1+++++
 8|   1+++++

だいたいはgboardを表示するところと似たように書いています。

まだ開いていない部分は+にしました(別に何の文字でも良い)

4.終了条件

ついに終了条件です。

終了条件がなければ、上のゲームでif __name__ =='__main__'のなかでwhile Trueとした場合に自分でKeyboardInterruptを発生させたりしなければなりません。

これではちゃんとしたマインスイーパーにはならないので、終了条件は必須ですね笑

...
class Minesweeper(object):
...
    def set_board(self, shape: (int, int)):

        self.shape  = shape
        self.size   = np.prod(shape) 
        self.gboard = np.zeros(shape, dtype=np.int8)
        self.pboard = np.zeros(shape, dtype=np.int8)
        self.yet    = True
...
    def open_pboard(self, index: (int, int)):

        r, c = self.shape
        r_, c_ = index
        if 0 <= r_ < r and 0 <= c_ < c:
            pboard, gboard = self.pboard, self.gboard

            if gboard[index] == MINE:
                self.yet = False
    
            elif pboard[index] == CLOSE:
                open_index_li = [index]
                while len(open_index_li) > 0:
                    # index周辺8セルにSPACEがないか調べる
                    r_, c_ = index = open_index_li[0]
                    if gboard[index] == SPACE:
                        for index_ in itertools.product(range(r_-1, r_+2), range(c_-1, c_+2)):
                            if 0 <= index_[0] < r and 0 <= index_[1] < c and pboard[index_] == CLOSE:
                                if gboard[index_] != MINE:
                                    pboard[index_] = OPEN
    
                                if gboard[index_] == SPACE:
                                    open_index_li.append(index_)

                    elif gboard[index] != MINE and pboard[index] == CLOSE:
                        pboard[index] = OPEN
    
                    # 最後に調べたindexを削除
                    open_index_li.remove(index)

                self.yet = self.mines != np.sum(self.pboard != OPEN)


    def end(self):

        r, c = self.shape
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)

        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                ptmp, gtmp = self.pboard[r_, c_], self.gboard[r_, c_]
                if gtmp == MINE:
                    tmp = 'X'
                else:
                    if ptmp == CLOSE:
                        tmp = '+'
                    elif ptmp == OPEN and gtmp == SPACE:
                        tmp = ' '
                    else:
                        tmp = gtmp

                print(tmp, end='')

            print()
        print('END')
...


def main():

    g = Minesweeper()
    g.get_gboard()
    print()
    while g.yet:
        g.display_board()
        index = tuple(map(int, input('input index("row col [mode]"): ').split(' ')))
        g.open_pboard(index)

    g.end()


if __name__ == '__main__':

    main()

以上のようにyetを宣言することで、実際にゲームが終わったかどうかを判断させます。

このプログラムでは「勝ったか/負けたか」を判断せず、「終わったか/終わっていないか」というところで判断します。

end()関数では最終的なプレイボードの出力をします。「勝った」場合はちゃんとしたプレイボードが表示されますが、「負けた」場合は地雷がどこにあったか教えてくれるようにプログラムを書きました。

ここで実際に実行してみると、ちゃんとマインスイーパーができると思います。試してみましょう(ここでゲームしてみる時間が流れる)

5. (おまけ)フラグを立てる

ここからはおまけ編です!

多くのマインスイーパーではフラグを立てるが実装されています。

これがあるとマインスイーパーはさっきよりも遊びやすくなりますよね?

それでは実装してゆきます。

import numpy as np
import itertools


FLAG  =  2
OPEN  =  1
CLOSE =  0
SPACE =  0
MINE  = -1


class Minesweeper(object):

...
    def set_flag(self, index: (int, int)):

        if self.pboard[index] == FLAG:
            self.pboard[index] = CLOSE
        elif self.pboard[index] == CLOSE:
            self.pboard[index] = FLAG

...
    def end(self):

        r, c = self.shape
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)

        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                ptmp, gtmp = self.pboard[r_, c_], self.gboard[r_, c_]
                if gtmp == MINE:
                    if ptmp == FLAG:
                        tmp = 'F'
                    else:
                        tmp = 'X'
                else:
                    if ptmp == CLOSE:
                        tmp = '+'
                    elif ptmp == OPEN and gtmp == SPACE:
                        tmp = ' '
                    elif ptmp == FLAG:
                        tmp = 'L'
                    else:
                        tmp = gtmp

                print(tmp, end='')

            print()

        print('END')

...
    def display_board(self):

        r, c = self.shape
        print(f'flag(s): {self.mines - np.sum(self.pboard == FLAG)}')
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)

        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                tmp = self.pboard[r_, c_]
                if tmp == OPEN:
                    tmp = self.gboard[r_, c_]
                    if tmp == SPACE:
                        tmp = ' '
                    elif tmp == MINE:
                        tmp = 'X'
                elif tmp == FLAG:
                    tmp = 'F'
                else:
                    tmp = '+'

                print(tmp, end='')

            print()



def main():

    g = Minesweeper()
    g.get_gboard()
    print()
    while g.yet:
        g.display_board()
        index_li = input('input index("row col [mode]"): ').split(' ')
        index = tuple(map(int, index_li[:2]))
        if len(index_li) == 3 and (index_li[-1] == 'f' or index_li[-1] == 'F'):
            g.set_flag(index)
        else:
            g.open_pboard(index)

    g.end()


if __name__ == '__main__':

    main()

実際にプログラムを書き換えた部分はそんなにないです。

FLAGpboardで操作するように書くととても楽です。

実際にプログラムを動かすとこうなります。

  |012345678
--+---------
 0|12321  11
 1|1XXX1 12X
 2|12321 1X2
 3|  1121211
 4|  1X2X111
 5|  123211X
 6|   2X2 11
 7|   2X2   
 8|   111   

flag(s): 10
  |012345678
--+---------
 0|+++++++++
 1|+++++++++
 2|+++++++++
 3|+++++++++
 4|+++++++++
 5|+++++++++
 6|+++++++++
 7|+++++++++
 8|+++++++++
input index("row col [mode]"): 0 0 f
flag(s): 9
  |012345678
--+---------
 0|F++++++++
 1|+++++++++
 2|+++++++++
 3|+++++++++
 4|+++++++++
 5|+++++++++
 6|+++++++++
 7|+++++++++
 8|+++++++++
input index("row col [mode]"): 

フラグはフラグっぽくFが出力されるようにしました。

6. (おまけ)すでに開いている部分を指定すると適宜開くように改良

アプリとかウェブ上で遊べるマインスイーパーには実装されていないこともあるこの機能。

数字をタッチすると周辺のフラグの数に応じてセルが開いてくれたりするあの機能、あると便利ですよね!

それでは実装してゆきましょう!

...
    def open_pboard(self, index: (int, int)):

        r, c = self.shape
        r_, c_ = index
        if 0 <= r_ < r and 0 <= c_ < c:
            pboard, gboard = self.pboard, self.gboard

            def inr_fn(open_index_li: [(int,int), ...]):

                while len(open_index_li) > 0:
                    # index周辺8セルにSPACEがないか調べる
                    r_, c_ = index = open_index_li[0]
                    if gboard[index] == SPACE:
                        for index_ in itertools.product(range(r_-1, r_+2), range(c_-1, c_+2)):
                            if 0 <= index_[0] < r and 0 <= index_[1] < c and pboard[index_] == CLOSE:
                                if gboard[index_] != MINE:
                                    pboard[index_] = OPEN
    
                                if gboard[index_] == SPACE:
                                    open_index_li.append(index_)

                    elif gboard[index] != MINE and pboard[index] == CLOSE:
                        pboard[index] = OPEN
    
                    # 最後に調べたindexを削除
                    open_index_li.remove(index)

            if gboard[index] == MINE:
                self.yet = False
    
            elif pboard[index] == CLOSE:
                open_index_li = [index]
                inr_fn(open_index_li)
                self.yet = self.mines != np.sum(self.pboard != OPEN)

            elif pboard[index] == OPEN and gboard[index] <= np.sum(pboard[max(0, r_-1):min(r, r_+2), max(0, c_-1):min(c, c_+2)] == FLAG):
                open_index_li = [index for index in itertools.product(range(max(0, r_-1), min(r, r_+2)), range(max(0, c_-1), min(c, c_+2))) if pboard[index] != FLAG]
                _yet = True
                for index in open_index_li:
                    if gboard[index] == MINE:
                        pboard[index] = OPEN
                        _yet = False

                inr_fn(open_index_li)
                self.yet &= _yet
...

ここは結構書き換えました。

まず、セルを開く部分をinr_fn()関数として内部関数で再実現し、プレイボードの指定されたインデックスが開いている場合、閉じている場合で場合わけして開き方を改良しました。

これを実行すると、周辺のフラグの数によってセルが開くようになります。

さいごに

いかがでしたか?

このようにいろいろと新しい機能を追加するとそのぶんコードは複雑になります。

それでもしやすいゲームを実現したかったので、まあ良い経験になりました。

ソースコード全体

おまけなしバージョンのソースコードの全体としては、このようになります。

import numpy as np
import itertools


OPEN  =  1
CLOSE =  0
SPACE =  0
MINE  = -1


class Minesweeper(object):

    def __init__(self, shape: (int, int)=(9, 9), mines: int=10):

        self.set_board(shape)
        self.set_mines(mines)


    def set_board(self, shape: (int, int)):

        self.shape  = shape
        self.size   = np.prod(shape) 
        self.gboard = np.zeros(shape, dtype=np.int8)
        self.pboard = np.zeros(shape, dtype=np.int8)
        self.yet    = True


    def set_mines(self, mines: int):

        r, c = shape = self.shape
        if mines >= self.size:
            raise Exception(f'mines must be less than product of shape.<{mines=}, {shape=}>')

        self.mines = mines
        for ix in np.random.choice(range(self.size), mines, replace=False):
            self.gboard[divmod(ix, shape[1])] = MINE
        
        tmp = np.zeros(shape, dtype=np.int8)
        # scipyはノロいので、畳み込み部分は勝手に高速化
        for r_, c_ in itertools.product(range(-1, 2), range(-1, 2)):
            if r_ == c_ == 0:
                continue

            tmp[max(0, r_):min(r, r+r_), max(0, c_):min(c, c+c_)] -= self.gboard[max(0, -r_):min(r, r-r_), max(0, -c_):min(c, c-c_)]

        tmp[self.gboard == MINE] = MINE
        self.gboard = tmp
        del tmp


    def open_pboard(self, index: (int, int)):

        r, c = self.shape
        r_, c_ = index
        if 0 <= r_ < r and 0 <= c_ < c:
            pboard, gboard = self.pboard, self.gboard

            if gboard[index] == MINE:
                self.yet = False
    
            elif pboard[index] == CLOSE:
                open_index_li = [index]
                while len(open_index_li) > 0:
                    # index周辺8セルにSPACEがないか調べる
                    r_, c_ = index = open_index_li[0]
                    if gboard[index] == SPACE:
                        for index_ in itertools.product(range(r_-1, r_+2), range(c_-1, c_+2)):
                            if 0 <= index_[0] < r and 0 <= index_[1] < c and pboard[index_] == CLOSE:
                                if gboard[index_] != MINE:
                                    pboard[index_] = OPEN
    
                                if gboard[index_] == SPACE:
                                    open_index_li.append(index_)

                    elif gboard[index] != MINE and pboard[index] == CLOSE:
                        pboard[index] = OPEN
    
                    # 最後に調べたindexを削除
                    open_index_li.remove(index)

                self.yet = self.mines != np.sum(self.pboard != OPEN)


    def end(self):

        r, c = self.shape
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)

        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                ptmp, gtmp = self.pboard[r_, c_], self.gboard[r_, c_]
                if gtmp == MINE:
                    tmp = 'X'
                else:
                    if ptmp == CLOSE:
                        tmp = '+'
                    elif ptmp == OPEN and gtmp == SPACE:
                        tmp = ' '
                    else:
                        tmp = gtmp

                print(tmp, end='')

            print()

        print('END')


    def get_gboard(self):

        r, c = self.shape
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)

        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                tmp = self.gboard[r_, c_]
                if tmp == SPACE:
                    tmp = ' '
                elif tmp == MINE:
                    tmp = 'X'

                print(tmp, end='')

            print()

        return self.gboard


    def display_board(self):

        r, c = self.shape
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)

        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                tmp = self.pboard[r_, c_]
                if tmp == OPEN:
                    tmp = self.gboard[r_, c_]
                    if tmp == SPACE:
                        tmp = ' '
                    elif tmp == MINE:
                        tmp = 'X'
                else:
                    tmp = '+'

                print(tmp, end='')

            print()



def main():

    g = Minesweeper()
    g.get_gboard()
    print()
    while g.yet:
        g.display_board()
        index_li = input('input index("row col [mode]"): ').split(' ')
        g.open_pboard(tuple(map(int, index_li[:2])))

    g.end()


if __name__ == '__main__':

    main()

おまけありバージョンはこうなります。

import numpy as np
import itertools


FLAG  =  2
OPEN  =  1
CLOSE =  0
SPACE =  0
MINE  = -1


class Minesweeper(object):

    def __init__(self, shape: (int, int)=(9, 9), mines: int=10):

        self.set_board(shape)
        self.set_mines(mines)


    def set_board(self, shape: (int, int)):

        self.shape  = shape
        self.size   = np.prod(shape) 
        self.gboard = np.zeros(shape, dtype=np.int8)
        self.pboard = np.zeros(shape, dtype=np.int8)
        self.yet    = True


    def set_mines(self, mines: int):

        r, c = shape = self.shape
        if mines >= self.size:
            raise Exception(f'mines must be less than product of shape.<{mines=}, {shape=}>')

        self.mines = mines
        for ix in np.random.choice(range(self.size), mines, replace=False):
            self.gboard[divmod(ix, shape[1])] = MINE
        
        tmp = np.zeros(shape, dtype=np.int8)
        # scipyはノロいので、畳み込み部分は勝手に高速化
        for r_, c_ in itertools.product(range(-1, 2), range(-1, 2)):
            if r_ == c_ == 0:
                continue

            tmp[max(0, r_):min(r, r+r_), max(0, c_):min(c, c+c_)] -= self.gboard[max(0, -r_):min(r, r-r_), max(0, -c_):min(c, c-c_)]

        tmp[self.gboard == MINE] = MINE
        self.gboard = tmp
        del tmp


    def set_flag(self, index: (int, int)):

        if self.pboard[index] == FLAG:
            self.pboard[index] = CLOSE
        elif self.pboard[index] == CLOSE:
            self.pboard[index] = FLAG


    def open_pboard(self, index: (int, int)):

        r, c = self.shape
        r_, c_ = index
        if 0 <= r_ < r and 0 <= c_ < c:
            pboard, gboard = self.pboard, self.gboard

            def inr_fn(open_index_li: [(int,int), ...]):

                while len(open_index_li) > 0:
                    # index周辺8セルにSPACEがないか調べる
                    r_, c_ = index = open_index_li[0]
                    if gboard[index] == SPACE:
                        for index_ in itertools.product(range(r_-1, r_+2), range(c_-1, c_+2)):
                            if 0 <= index_[0] < r and 0 <= index_[1] < c and pboard[index_] == CLOSE:
                                if gboard[index_] != MINE:
                                    pboard[index_] = OPEN
    
                                if gboard[index_] == SPACE:
                                    open_index_li.append(index_)

                    elif gboard[index] != MINE and pboard[index] == CLOSE:
                        pboard[index] = OPEN
    
                    # 最後に調べたindexを削除
                    open_index_li.remove(index)

            if gboard[index] == MINE:
                self.yet = False

            elif pboard[index] == CLOSE:
                open_index_li = [index]
                inr_fn(open_index_li)
                self.yet = self.mines != np.sum(self.pboard != OPEN)

            elif pboard[index] == OPEN and gboard[index] <= np.sum(pboard[max(0, r_-1):min(r, r_+2), max(0, c_-1):min(c, c_+2)] == FLAG):
                open_index_li = [index for index in itertools.product(range(max(0, r_-1), min(r, r_+2)), range(max(0, c_-1), min(c, c_+2))) if pboard[index] != FLAG]
                _yet = True
                for index in open_index_li:
                    if gboard[index] == MINE:
                        pboard[index] = OPEN
                        _yet = False

                inr_fn(open_index_li)
                self.yet &= _yet


    def end(self):

        r, c = self.shape
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)

        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                ptmp, gtmp = self.pboard[r_, c_], self.gboard[r_, c_]
                if gtmp == MINE:
                    if ptmp == FLAG:
                        tmp = 'F'
                    else:
                        tmp = 'X'
                else:
                    if ptmp == CLOSE:
                        tmp = '+'
                    elif ptmp == OPEN and gtmp == SPACE:
                        tmp = ' '
                    elif ptmp == FLAG:
                        tmp = 'L'
                    else:
                        tmp = gtmp

                print(tmp, end='')

            print()

        print('END')


    def get_gboard(self):

        r, c = self.shape
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)
        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                tmp = self.gboard[r_, c_]
                if tmp == SPACE:
                    tmp = ' '
                elif tmp == MINE:
                    tmp = 'X'

                print(tmp, end='')

            print()

        return self.gboard


    def display_board(self):

        r, c = self.shape
        print(f'flag(s): {self.mines - np.sum(self.pboard == FLAG)}')
        print('  |', end='')
        for c_ in range(c):
            print(f'{c_%10:1}', end='')

        print('\n--+' + '-' * c)

        for r_ in range(r):
            print(f'{r_:2}|', end='')
            for c_ in range(c):
                tmp = self.pboard[r_, c_]
                if tmp == OPEN:
                    tmp = self.gboard[r_, c_]
                    if tmp == SPACE:
                        tmp = ' '
                    elif tmp == MINE:
                        tmp = 'X'
                elif tmp == FLAG:
                    tmp = 'F'
                else:
                    tmp = '+'

                print(tmp, end='')

            print()



def main():

    g = Minesweeper()
    g.get_gboard()
    print()
    while g.yet:
        g.display_board()
        index_li = input('input index("row col [mode]"): ').split(' ')
        index = tuple(map(int, index_li[:2]))
        if len(index_li) == 3 and (index_li[-1] == 'f' or index_li[-1] == 'F'):
            g.set_flag(index)
        else:
            g.open_pboard(index)

    g.end()


if __name__ == '__main__':

    main()