Pythonで「マインスイーパー」を開発

マインスイーパーアプリ開発解説ページのアイキャッチ

このページにはプロモーションが含まれています

最近マインスイーパーアプリを Python で作成してみました!

ルールは単純、さらに必要なウィジェットやイベントの種類も少ないので特にゲーム開発や Python や tkinter の入門者にとっては力試しに開発してみるのにオススメのテーマだと思います!

このページではこのマインスイーパーの Python での作り方を紹介していきたいと思います。

MEMO

このページで紹介するスクリプトは下記環境で動作を確認しています

  • OS:macOS Big Sur
  • Python:3.9.4
  • Tkinter:8.6

マインスイーパーとは

今回作るマインスイーパーについてまず解説しておきます。

どんなゲームか知ってるよーという方は、次のマインスイーパーの作り方にスキップしてください。

マインスイーパーのゲーム画面は下のようなものになります。この2次元的に配置されたマスの中に、地雷が埋められています。

マインスイーパーアプリの画面

この中から地雷が埋まっている箇所以外のマスを左クリックして開き、地雷以外のマスを全て開けられたらゲームクリアとなるゲームです。

マスを開くと、そのマスに地雷が埋められていない場合は、そのマスに隣接するマス(8マス)のうち何箇所に地雷が埋められているかを表す数字が表示されます。この数字を頼りに、地雷が埋まっていない箇所のマスを開いていきます。

マスを開いた様子

また、マスを右クリックすることで旗を立てることができます。今回作成させるアプリでは、この旗を「F」で表現しています。

旗を立てた様子

さらに、開けたマスの隣接するマスに爆弾が1つもない場合は、その隣接するマスも自動的に開けられます。さらに自動的に開けられたマスの隣接するマスにも爆弾がない場合も、自動的に開けられます。

隣接するマスが開かれる様子

前述の通り、地雷以外のマスを全て開けられたらゲームクリアになります。地雷以外のマスの数は下記により計算できます。

縦方向のマス数 × 横方向のマス数 - 地雷の数

また、爆弾が埋められているマスを開いてしまうとゲームオーバーになります。 

地雷を開いた様子

マインスイーパーアプリの作り方

ではこのマインスイーパーをどのようにして作れば良いかについて解説していきたいと思います。

スポンサーリンク

マインスイーパークラスを作る

まずは必要なクラスを作ります。

といっても今回作成するのは MineSweeper クラスの一つだけです。

MEMO

一つだけなので別にクラスを作る必要もないのですが、各変数を global 変数としてではなくクラスのデータ属性として持たせる方がプログラミングしやすかったのでクラスを作っています

今回作成する MineSweeper クラスには下記のデータ属性を用意しています。

  • app:メインウィンドウ(tkinter.Tk クラスのインスタンス)
  • cells:各マスの状態を管理するリスト
  • labels:各マスに対応するラベルを管理するリスト
  • width:横方向のマスの数
  • height:縦方向のマスの数
  • mine_num:埋められている地雷の数
  • clear_num:ゲームクリアに必要な開くマスの数
  • open_num:既に開いたマスの数
  • open_mine:地雷のマスを開けてしまったかどうかのフラグ
  • play_game:ゲーム中かどうかのフラグ

さらに MineSweeper クラスには下記のメソッド(+α)を用意しています。

  • init_cells:各マスの状態を管理するリストを作成する
  • place_mines:マスに地雷を配置する
  • set_mine_num:マスに隣接する地雷の数を設定する
  • create_widgets:ウィジェットを作成して配置する
  • set_events:イベントを設定する
  • raise_flag:マスに旗を立てる
  • open_cell:マスを開く
  • open_neighbor:隣接マスを開く
  • game_over:ゲームオーバー画面を表示する
  • game_clear:ゲームクリア画面を表示する

init_cells 〜 set_events までは MineSweeper のコンストラクタ(__init__())の中で実行し、残りはイベントハンドラとして、もしくはイベントハンドラの中で実行するメソッドになります。

これらのメソッドを上手く実装してやることで、マインスイーパーアプリを作ることができます。

ここからはこれらの各メソッドで何を行うのか、どのように実装するのかについて解説していきます。

各マスの状態を管理するリストを作成する

マインスイーパーを作成するにあたって、最初に各マスの状態を管理するリストの作成(初期化)を行います。

まずはこの管理リストについて説明しておきます。

マインスイーパーは前述の通り、マスの中には地雷や隣接するマスに存在する地雷の数が隠されています。

地雷が埋められている様子

ではどのマスにどんな情報が隠れているのか?この情報を管理するのがこのリストになります。

マスの状態がリストによって管理される様子

マインスイーパーでは、マスが二次元的に配置されていますので、今回作成するリストも二次元リストにしたいと思います。

二次元リストのサイズは「横方向のマスの数 x 縦方向のマスの数」になります。

この二次元リストを cells とすれば、cells[y][x] が (x, y) 座標のマスの状態を管理することになります。

各マスの状態がリストの要素によって管理される様子

状態を管理するリストと言うと難しく感じるかもしれませんが、要は下記のように状態に応じた数値を格納するだけです。

  • そのマスに地雷が埋められている場合:-1
  • そのマスに地雷が埋められていない場合:隣接するマスに埋められている地雷の数(08

こんな感じで各マスの状態を二次元リストに格納しておけば、下記のように座標を指定して参照するだけで、そのマスがどんな状態かを取得することができるようになります。

(x, y)の位置のマスの状態取得
cells[y][x]

まずはここまで解説したリストの作成(全要素を 0 に初期化)のみを行います。

今回はこの「各マスの状態を管理するリストを作成する」機能を MineSweeper クラスの init_cells メソッドとして用意します。

この init_cells メソッドの実装例は下記のようになります。

各マスの状態を管理するリストを作成するメソッド
# ボードの初期化
def init_cells(self):

	# ボードのサイズ分の2次元リストを作成
	self.cells = [[0] * self.width for _ in range(self.height)]

何やってるかよく分からない!と言う方は「内包表記」なんかでググると解説見つかると思います。

要は上記により、全要素が 0 の、横方向のサイズが self.width、縦方向のサイズが self.heiht の二次元リストを作成しています。

マスに地雷を配置する

「各マスの状態を管理するリストを作成する」でリストを作成しましたが、全要素は 0 で初期化されたままですので、マスに1つも地雷がない状態です。

ですので、次はこのリストに -1 を格納して地雷を配置していきます。

毎回同じ位置に地雷があるとゲーム性が低いので、ランダムに配置する位置を決定するのが良いと思います。

マスに地雷を配置する MineSweeper クラスの place_mines の実装例は下記のようになります。

マスに地雷を配置するメソッド
MINE = -1

# 地雷を配置
def place_mines(self):

	mine_num = 0
	while mine_num < self.mine_num:

		# 地雷の位置をランダムに決定
		j = random.randint(0, self.height - 1)
		i = random.randint(0, self.width - 1)

		if self.cells[j][i] != MINE:

			# その位置に地雷があることを示すMINE(-1)を格納
			self.cells[j][i] = MINE
			mine_num += 1

randint 利用時は random モジュールを事前に import しておく必要があることに注意してください。

スポンサーリンク

マスに隣接する地雷の数を設定する

地雷の配置が完了したので、次は各マスの隣接マスの地雷の数をカウントし、その数をリストに設定していきます。

マスに地雷を配置する MineSweeper クラスの set_mine_num の実装例は下記のようになります。

マスに隣接する地雷の数を設定するメソッド
# 各マスに周りの地雷の数を設定
def set_mine_num(self):
	for j in range(self.height):
		for i in range(self.width):

			# そのマスが地雷の場合は何もしない
			if self.cells[j][i] == MINE:
				continue

			# 隣接する8方向のマスの地雷の数をカウント
			num_mine = 0

			# 方向を決める2重ループ
			for y in range(-1, 2):
				for x in range(-1, 2):
					if y != 0 or x != 0:
						# その方向に地雷があるかチェック
						is_mine = self.is_mine(i + x, j + y)

						if is_mine:
							# 地雷があればカウントアップ
							num_mine += 1

		# 周りの地雷の数をセット
		self.cells[j][i] = num_mine

set_mine_num で実行している is_mine は引数で指定した位置に地雷が埋められているかどうかを判断するメソッドになります。

その位置に地雷があるかどうかを判断するメソッド
# そのマスに地雷があるかどうかを判断する関数
def is_mine(self, i, j):

	# ボード内の座標かチェック
	if j >= 0 and i >= 0 and j < self.height and i < self.width:

		# その座標に地雷があるかどうかをチェック
		if self.cells[j][i] == MINE:

			# そのマスが地雷の場合はTrueを返却
			return True

	# ボード外 or 地雷でない場合はFalseを返却
	return False

マスに地雷を配置するで地雷の存在するマスに対応する self.cells の要素に MINE (-1)  を格納していますので、上記のように self.cells を参照して地雷があるかどうかを判断しています。

yx のループの中で is_mine を実行することで隣接する8方向のマスそれぞれに地雷があるかどうかを判断し、地雷がある場合は地雷の数をカウントアップし、結果を self.cells に格納しています。

これを全てのマスに対して行えば、各マスの状態を管理するリストの完成です!

これにより、マスが開けられた時にはこのリストを参照してプログラムの動作を決定すれば良いだけになります。

ウィジェットを作成して配置する

言ってしまえばここまではアプリを作るための地道な前準備になります。

ここからはマインスイーパーアプリの画面や機能を作っていきます。

まずはここではアプリの画面を作ります。

作るのは下図のようなマスが並べられただけの画面になります。

マインスイーパーアプリの画面

「スタートボタン」や「やり直しボタン」などもあっても良いのですが、ここではマインスイーパー を作るのに最低限必要なマスのみを用意することにします。

このマスは tkinter のラベルウィジェットにより作成します。

マスの数だけラベルを作成し、それを二次元的に配置します。

ラベルウィジェットとウィジェットの配置については下記の2つのページ解説していますので、これらをご存知ない方は事前に目を通しておいていただけると今後の解説やスクリプトが読みやすくなると思います。

ラベルウィジェット解説ページのアイキャッチ Tkinterの使い方:ラベルウィジェット(Label)の使い方 ウィジェット配置方法解説ページのアイキャッチ Tkinterの使い方:ウィジェットの配置(pack・grid・place)

ラベルウィジェットの作成と配置を行う MineSweeper クラスの create_widgets の実装例は下記のようになります。

ウィジェットの作成と配置を行うメソッド
# ウィジェットを作成
def create_widgets(self):
	# ラベルウィジェット管理用のリストを作成
	self.labels = [[None] * self.width for j in range(self.height)]

	for j in range(self.height):
		for i in range(self.width):

			# まずはテキストなしでラベルを作成
			label = tkinter.Label(
				self.app,
				width=2,
				height=1,
				bg=EMPTY_BG_COLOR,
				relief=tkinter.RAISED
			)
			# ラベルを配置
			label.grid(column=i, row=j)

			# その座標のラベルのインスタンスを覚えておく
			self.labels[j][i] = label

ji がメインウィンドウ上の位置を表しており、これらのループの中で tkinter.Label (Label クラスのコンストラクタ)を実行することで、「横方向のマスの数 x 縦方向のマスの数」個分のラベルを作成しています。

tkinter.Label() 実行時に下記を指定することで作成するラベルの設定を行なっています。

    • self.app:ラベルの作成先(メインウィンドウ)
    • width=2:ラベルの横幅(文字数)
    • height=1:ラベルの縦幅(文字数)
    • bg=EMPTY_BG_COLOR:ラベルの背景の色("lightgray"
    • relief=tkinter.RAISED:ラベルの見た目

widthheight を指定してマスのように見えるようにサイズを正方形っぽく設定しています。

ただしこれらはピクセル数ではなく文字数で指定する必要があるので注意が必要です。

今回は width2 を、height1 をそれぞれ設定して正方形っぽくしたつもりですが、環境によってはサイズが変わるかもしれませんので適宜調整してください。

また relief を指定することでラベルの見た目を設定することができます。例えば relief の指定により下の図のようにラベルの見た目が変わります。

ラベルのreliefによる見た目の違い

今回はラベルがボタンのように見えるように relief=tkinter.RAISED を指定しています。

後から説明しますが、マスが開かれた際にはボタンが既に押されているように見えるように relief=tkinter.SUNKEN に再設定するようにしています。

これにより、どのマスが開かれていないかがユーザーに直感的に伝えられるようにします。

また self.labels[j][i]label をセットしていますが、これは後からどのウィジェットがどの位置(i, j)に設置されているかを参照するためです。

この self.labels は主に後述する「マスを開く」「隣接マスを開く」で利用します。

イベントを設定する

続いてこのマインスイーパーアプリで扱うイベントの設定を行います。

イベントについては下記ページで解説していますので、ご存じない方はこちらのページを参考にしていただければと思います。

イベント処理解説ページのアイキャッチ Tkinterの使い方:イベント処理を行う

マインスイーパーでは、各マス(ラベルウィジェット)が左クリックされた時にそのマスを開け、各マスが右クリックされた時にそのマスに旗を立てるようにしたいので、下記のようにイベント処理を設定します。

  • ラベルウィジェットへの左クリックが発生した時にそのマスを開く
  • ラベルウィジェットへの右クリックが発生した時にそのマスに旗を立てる

これらのイベントの設定を全マス(全ラベルウィジェット)に対して行う MineSweeper クラスのset_events の実装例は下記のようになります。

イベントを設定するメソッド
# イベントを設定
def set_events(self):

	# 全ラベルに対してイベントを設定
	for j in range(self.height):
		for i in range(self.width):

			label = self.labels[j][i]

			# 左クリック時のイベント設定
			label.bind("<ButtonPress-1>", self.open_cell)

			# 右クリック時のイベント設定
			# 右クリックが反応しない場合は第1引数を"<ButtonPress-3>"に変更してみてください
			label.bind("<ButtonPress-2>", self.raise_flag)

bind メソッドの第1引数に "<ButtonPress-1>" を指定することで、ラベルが「左クリック」された時に第2引数に指定した関数・メソッドが実行されるように設定できます。

また、bind メソッドの第1引数に "<ButtonPress-2>" を指定することで、ラベルが「右クリック」された時に第2引数に指定した関数・メソッドが実行されるように設定できます。

MEMO

(2021/10/25 追記)

環境によっては、右クリックされたイベントの設定時、bind メソッドの第1引数に "<ButtonPress-3>" を指定する必要がある場合があるようです

右クリックが反応しない場合、bind メソッドの第1引数の部分を "<ButtonPress-2>" から "<ButtonPress-3>" に変更して試してみてください

第2引数で指定しているメソッドについては後述で解説しますが、open_cells はマスを開くメソッドで、raise_flag はマスに旗を立てるメソッドになります。

スポンサーリンク

マスに旗を立てる

ここまででアプリの画面やイベントの設定が完了しました。これらはアプリの枠組みみたいなもので、ここからはいよいよマインスイーパーとしての機能を作っていきます。

まずは簡単な「マスに旗を立てる」を実装していきましょう!

マインスイーパーでは右クリック時にそのマスに旗を立てることができます(旗が立っている状態で右クリックすると旗を消すこともできます)。

旗を立てる様子

イベントを設定するでマス(ラベル)が右クリックされた時に raise_flag メソッドが実行されるように設定しましたので、この raise_flag に「マスに旗を立てる」機能を実装していきます。

今回作成するアプリでは、簡単にアプリが作成できるように旗は「F」の文字で表現しようと思います(旗の画像を表示したりするとさらにマインスイーパー のゲーム画面っぽくなります)。

マスの実体はラベルウィジェットなので、旗を立てる際にはラベルウィジェットに表示する表示文字列(text)に設定してやれば良いだけになります。

マスに旗を立てる MineSweeper クラスの raise_flag の実装例は下記のようになります。

マスに旗を立てるメソッド
# 右クリック時に実行する関数
def raise_flag(self, event):

	# ゲーム中でなければ何もしない
	if not self.play_game:
		return

	# クリックされたラベルを取得
	label = event.widget

	# 既にそのマスを開いている場合は何もしない
	if label.cget("relief") != tkinter.RAISED:
		return

	# 既に旗が設定されている場合
	if label.cget("text") != "F":

		# ラベルの色を設定
		bg = FLAG_BG_COLOR

		# そのラベル上に旗(F)を立てる
		label.config(
			text="F",
			bg=bg
		)
	else:
		# ラベルの色を設定
		bg = EMPTY_BG_COLOR

		# そのラベル上の旗(F)を取り除く
		label.config(
			text="",
			bg=bg
		)

簡単に解説しておくと、引数の eventwidget 属性ではこのメソッド実行のトリガーとなったラベルウィジェット(つまり右クリックされたラベルウィジェット)が参照されています。

ですので、下記により label が右クリックされたラベルウィジェットを参照することになります。

クリックされたラベルの取得
# クリックされたラベルを取得
label = event.widget

したがって、下記により右クリックされたラベルウィジェットの表示文字列(text)を "F" に設定し、旗を立てることが出来ます。

旗を立てる時のラベルの設定
# そのラベル上に旗(F)を立てる
label.config(
	text="F",
	bg=bg
)

もともとそのラベルウィジェットの表示文字列が "F" の時は既に旗が立っているということですので、下記で表示文字列(text)を "" に設定しています。これにより 旗が消えることになります。

旗を消す時のラベルの設定
# そのラベル上の旗(F)を取り除く
label.config(
	text="",
	bg=bg
)

また、既に開けられているマスには旗を立てる必要はありません。

ウィジェットを作成して配置するでも説明したように、マスが開いている場合、そのマスを表すラベルの relief 設定は tkinter.RAISED、マスが開いていない場合は tkinter.SUNKEN を指定します。

ですので、下記のようにラベルが開いているかどうかは relief の値により判断しています。

マスが開いている時の処理
# 既にそのマスを開いている場合は何もしない
if label.cget("relief") != tkinter.RAISED:
	return

マスを開く

次はマスを開く処理を実装していきます。マインスイーパーアプリのメインの機能と言っても良いと思います!

マインスイーパーでは左クリック時に、そのマスを開くことができます。

マスを開ける様子

イベントを設定するでマス(ラベル)が左クリックされた時に open_cell メソッドが実行されるように設定しましたので、この open_cell に「マスを開く」機能を実装していきます。

「マスを開く」では、クリックされた位置のマスの状態に応じて下記のように処理を行います。

  • クリックされたマスが地雷の場合
    • ラベルの見た目(relieftkinter.SUNKEN に変更する)
    • クリックされたマスに「X」を表示する(地雷の画像を用意して表示してもオーケー)
    • ゲームオーバー処理を行う
  • クリックされたマスが地雷以外の場合
    • ラベルの見た目(relieftkinter.SUNKEN に変更する)
    • クリックされたマスの隣接するマスの地雷の数が 0 以外の場合
      • 隣接するマスの地雷の数を表示する
    • 隣接するマスの地雷の数が 0 の場合
      • 何も表示しない
      • 隣接するマスを開く
    • ゲームクリアに必要な数のマスが開けられた場合はゲームクリア処理を行う

マスを開く MineSweeper クラスの open_cell の実装例は下記のようになります。

マスを開くメソッド
# 左クリック時に実行する関数
def open_cell(self, event):

	# ゲーム中でなければ何もしない
	if not self.play_game:
		return

	# クリックされたラベルを取得
	label = event.widget

	# ラベルの座標を取得
	for y in range(self.height):
		for x in range(self.width):
			if self.labels[y][x] == label:
				j = y
				i = x

	cell = self.cells[j][i]

	# 既にそのマスを開いている場合は何もしない
	if label.cget("relief") != tkinter.RAISED:
		return

	# マスの状態に応じて表示するテキストと色を設定
	text, bg, fg = self.get_text_info(cell)

	# そこに地雷がある場合
	if cell == MINE:

		# ゲームオーバーフラグをTrueに設定
		self.open_mine = True

	# ラベルの設定変更
	label.config(
		text=text,
		bg=bg,
		fg=fg,
		relief=tkinter.SUNKEN
	)

	# 開いたマス数をカウントアップ
	self.open_num += 1

	# 周辺の座標も開けるかどうかを調べていく
	if cell == 0:
		self.open_neighbor(i - 1, j - 1)
		self.open_neighbor(i, j - 1)
		self.open_neighbor(i + 1, j - 1)
		self.open_neighbor(i - 1, j)
		self.open_neighbor(i + 1, j)
		self.open_neighbor(i - 1, j + 1)
		self.open_neighbor(i, j + 1)
		self.open_neighbor(i + 1, j + 1)

	# ゲームオーバーならゲームオーバー処理
	if self.open_mine:
		self.app.after_idle(self.game_over)

	# ゲームクリアならゲームクリア処理
	elif self.open_num == self.clear_num:
		self.app.after_idle(self.game_clear)

「マスを開く」を行うためには、クリックされたマスの状態(そのマスに地雷が埋められているかどうか?そのマスの隣接するマスの地雷の数は?)を知る必要があります。

そこでようやく各マスの状態を管理するリストを作成するで作成したリスト(self.cells)が活躍します。このリストではまさに、位置 (i, j) のマスの状態が self.cells[j][i] に格納されていますので、これを参照することで位置 (i, j) のマスの状態を知ることができます。

ただし、bind で設定したイベントハンドラの引数 event からは位置 (i, j) を取得することができません(どのラベルウィジェットがクリックされたかは取得できる)。

ここで活躍するのがウィジェットを作成して配置するで用意したリスト(self.labels)です。

self.labels[y][x] には位置 (x, y) のマスに対応するラベルウィジェットが参照させていますので、クリックされたマスのウィジェットを label とすれば、self.labels[y][x] == label が成立する位置 (x, y) のマスがクリックされた判断できます。

ですので、全位置  (x, y)  のループの中で上記が成立するかどうかを確認し、成立する位置 (x, y)  から self.cells によりクリックされたマスの状態を取得することが可能です。

この辺りのことを上記スクリプトの下記部分で行っています。

マスの状態の取得
# クリックされたラベルを取得
label = event.widget

# ラベルの座標を取得
for y in range(self.height):
	for x in range(self.width):
		if self.labels[y][x] == label:
			j = y
			i = x

cell = self.cells[j][i]

マスの状態が取得できれば、あとはそのマスの状態に応じて必要な処理を行えば良いだけです。

下記ではマスの状態に応じてラベルの表示に関する情報(表示文字列・文字の色・背景の色)を取得しています。

ラベルの表示に関する情報の取得
# マスの状態に応じて表示するテキストと色を設定
text, bg, fg = self.get_text_info(cell)

get_text_info がどのようなメソッドかは最後に紹介するスクリプトを参照していただければと思います。

また開けたマスが地雷の場合は「地雷のマスを開けてしまったかどうかのフラグ」である open_mine を True に設定しています。

地雷開けた時のフラグ処理
# そこに地雷がある場合
if cell == MINE:

	# ゲームオーバーフラグをTrueに設定
	self.open_mine = True

あとは、開けたマスの隣接マスに地雷が無い場合、マスを開けてゲームオーバーになった場合、マスを開けてゲームクリアになった場合には、それぞれ「隣接マスを開く(open_neighbor)」「ゲームオーバー画面を表示する(game_over)」「ゲームクリア画面を表示する(game_clear)」を実行しています。

特に game_over と game_clear は after_idle を利用して実行させていますが、これはマスを開けた直後にそれぞれの画面を表示するようにしたかったためです。

隣接マスを開く

マインスイーパーでは開いたマスの隣接マスに地雷が無い場合は、その隣接マスの中の地雷以外のマスを自動的に開くという機能があります。さらに、その自動的に開いたマスの隣接マスにまた地雷が無い場合は、そのマスも自動的に開かれます(地雷のマス以外)。

隣接マスを開く様子

要は地雷が無いと分かりきっているマスは自動的に開けてあげる機能になります。

基本的に行うことは「マスを開く」と同じです(ゲームオーバーやゲームクリアの画面表示は「マスを開く」で行いますので「隣接するマスを開く」ではこれらは行いません)。

隣接マスを開ける MineSweeper クラスの open_neighbor の実装例は下記のようになります。

隣接マスを開けるメソッド
# クリックされたマスをの周辺を開く処理
def open_neighbor(self, i, j):

	# 地雷を開けてしまっていたら何もしない
	if self.open_mine:
		return

	# ボード外の座標であれば何もしない
	if not (j >= 0 and i >= 0 and j < self.height and i < self.width):
		return

	# その座標のラベルを取得
	label = self.labels[j][i]

	# 既にそのマスを開いている場合は何もしない
	if label.cget("relief") != tkinter.RAISED:
		return

	# そのマスが地雷であれば何もしない
	if self.cells[j][i] == MINE:
		return

	# ↑ の条件に当てはまらないのであればそのマスは開ける

	# ラベルの座標に応じて表示するテキストと色を設定
	text, bg, fg = self.get_text_info(self.cells[j][i])

	# ラベルの設定変更
	label.config(
		text=text,
		bg=bg,
		fg=fg,
		relief=tkinter.SUNKEN
	)

	# 開いたマス数をカウントアップ
	self.open_num += 1

	# 周辺の座標も開けるかどうかを調べていく
	if self.cells[j][i] == 0:
		self.open_neighbor(i - 1, j - 1)
		self.open_neighbor(i, j - 1)
		self.open_neighbor(i + 1, j - 1)
		self.open_neighbor(i - 1, j)
		self.open_neighbor(i + 1, j)
		self.open_neighbor(i - 1, j + 1)
		self.open_neighbor(i, j + 1)
		self.open_neighbor(i + 1, j + 1)

ポイントは、この open_neighbor の中で再度 open_neighbor を実行するところです。

このように関数・メソッドの中で自身の関数・メソッドを実行することを再帰呼び出しや再帰処理と呼びます。

このように再帰呼び出しを行うことで終了条件を満たすまで隣接するマスをどんどん開くことができます。

この再帰呼び出しの終了条件は下記のようになります。

  • 存在しないマスを開けようとしている
  • そのマスが既に開けられている
  • そのマスの隣接するマスに地雷がある

これらの終了条件を満たした場合は、再帰呼び出しを終了するようにしています(もう open_neighbor は実行しない)。

スポンサーリンク

ゲームオーバー画面を表示する

地雷のマスが開けられた際にはゲームオーバー画面を表示します。

地雷を開いた様子

今回作成するアプリではゲームオーバー画面は下記のようなものにしています。

  • 全てのマスを開ける
  • ゲームオーバーであることをメッセージボックスで表示

ゲームオーバー画面を表示する MineSweeper クラスの game_over の実装例は下記のようになります。

ゲームオーバー画面を表示するメソッド
# ゲームオーバー時の処理
def game_over(self):

	# 全マスを開く
	self.open_all()

	# ゲーム中フラグをFalseに設定
	self.play_game = False

	# メッセージを表示
	messagebox.showerror(
		"ゲームオーバー",
		"地雷を開いてしまいました..."
	)

実行している open_all は全てのマスを表示するメソッドになります。このメソッドがどのようなものであるかは最後に紹介する全体のスクリプトを参照していただければと思います。

また messagebox.showerror はエラー時のメッセージボックスを表示する関数になります。

これを実行することで下図のような画面を表示し、ユーザーにゲームオーバーであることを伝えることができます。

ゲームオーバーを伝えるメッセージ

ゲームクリア画面を表示する

地雷以外のマスが全て開けられた際にはゲームクリア画面を表示します。

今回作成するアプリではゲームクリア画面は下記のようなものにしています。

  • 全てのマスを開ける
  • ゲームクリアであることをメッセージボックスで表示

ゲームクリア画面を表示する MineSweeper クラスの game_clear の実装例は下記のようになります。

ゲームクリア画面を表示するメソッド
# ゲームクリア時の処理
def game_clear(self):

	# 全マスを開く
	self.open_all()

	# ゲーム中フラグをFalseに設定
	self.play_game = False

	# メッセージを表示
	messagebox.showinfo(
		"ゲームクリア",
		"おめでとうございます!ゲームクリア!"
	)

game_over とほぼ同じですが、messagebox.showinfo により下図のような画面を表示することで、ユーザーにゲームクリアしたことを伝えることができます。

ゲームクリアを伝えるメッセージ

マインスイーパーアプリのスクリプト

ここまで解説してきた「マインスイーパーアプリの作り方」を踏まえたマインスイーパーアプリのスクリプトの全体は下記のようになります。

(2021/10/25 追記)

環境によっては、右クリックされたイベントの設定時、bind メソッドの第1引数に "<ButtonPress-3>" を指定する必要がある場合があるようです

set_events メソッドでイベントの設定を行なっていますので、右クリックが反応しない場合、右クリック時のイベント設定を行なっている bind メソッドの第1引数の部分を "<ButtonPress-2>" から "<ButtonPress-3>" に変更して試してみてください

マインスイーパーアプリ
# -*- coding:utf-8 -*-
import tkinter
from tkinter import messagebox
import random


# 各種設定
BOARD_WIDTH = 20
BOARD_HEIGHT = 10
MINE_NUM = 20

MINE_BG_COLOR = "pink"
FLAG_BG_COLOR = "gold"
EMPTY_BG_COLOR = "lightgray"

fg_color = {
	1: "blue",
	2: "green",
	3: "purple",
	4: "olive",
	5: "chocolate",
	6: "magenta",
	7: "darkorange",
	8: "red",
}

# 定数定義
MINE = -1


class MineSweeper():
	def __init__(self, app):

		# *** 各種メンバの初期化 *** #

		self.app = app
		self.cells = None 
		self.labels = None
		self.width = BOARD_WIDTH
		self.height = BOARD_HEIGHT
		self.mine_num = MINE_NUM
		self.clear_num = self.width * self.height - self.mine_num
		self.open_num = 0
		self.open_mine = False
		self.play_game = False

		# 地雷を管理するボード
		self.cells = None

		# 画面に表示するラベルウィジェットの管理リスト
		self.labels = None

		# *** 爆弾を管理するボード作成 *** #

		# ボードを初期化
		self.init_cells()

		# ボードに地雷を配置
		self.place_mines()

		# 各マスに周りの地雷の数を設定
		self.set_mine_num()

		# *** ウィジェットの表示とイベント設定 *** #

		# ウィジェットの作成と配置
		self.create_widgets()

		# イベントの設定
		self.set_events()

		# 最後にゲーム中フラグをTrueに設定
		self.play_game = True
		

	# ボードの初期化
	def init_cells(self):

		# ボードのサイズ分の2次元リストを作成
		self.cells = [[0] * self.width for _ in range(self.height)]

	# 地雷を配置
	def place_mines(self):

		mine_num = 0
		while mine_num < self.mine_num:

			# 地雷の位置をランダムに決定
			j = random.randint(0, self.height - 1)
			i = random.randint(0, self.width - 1)

			if self.cells[j][i] != MINE:

				# その位置に地雷があることを示すMINE(-1)を格納
				self.cells[j][i] = MINE
				mine_num += 1

	# 各マスに周りの地雷の数を設定
	def set_mine_num(self):
		for j in range(self.height):
			for i in range(self.width):

				# そのマスが地雷の場合は何もしない
				if self.cells[j][i] == MINE:
					continue

				# 隣接する8方向のマスの地雷の数をカウント
				num_mine = 0

				# 方向を決める2重ループ
				for y in range(-1, 2):
					for x in range(-1, 2):
						if y != 0 or x != 0:
							# その方向に地雷があるかチェック
							is_mine = self.is_mine(i + x, j + y)

							if is_mine:
								# 地雷があればカウントアップ
								num_mine += 1

				# 周りの地雷の数をセット
				self.cells[j][i] = num_mine

	# そのマスに地雷があるかどうかを判断する関数
	def is_mine(self, i, j):

		# ボード内の座標かチェック
		if j >= 0 and i >= 0 and j < self.height and i < self.width:

			# その座標に地雷があるかどうかをチェック
			if self.cells[j][i] == MINE:

				# そのマスが地雷の場合はTrueを返却
				return True

		# ボード外 or 地雷でない場合はFalseを返却
		return False

	# ウィジェットを作成
	def create_widgets(self):
		# ラベルウィジェット管理用のリストを作成
		self.labels = [[None] * self.width for j in range(self.height)]

		for j in range(self.height):
			for i in range(self.width):

				# まずはテキストなしでラベルを作成
				label = tkinter.Label(
					self.app,
					width=2,
					height=1,
					bg=EMPTY_BG_COLOR,
					relief=tkinter.RAISED
				)
				# ラベルを配置
				label.grid(column=i, row=j)

				# その座標のラベルのインスタンスを覚えておく
				self.labels[j][i] = label

	# イベントを設定
	def set_events(self):

		# 全ラベルに対してイベントを設定
		for j in range(self.height):
			for i in range(self.width):

				label = self.labels[j][i]

				# 左クリック時のイベント設定
				label.bind("<ButtonPress-1>", self.open_cell)

				# 右クリック時のイベント設定
				# 右クリックが反応しない場合は第1引数を"<ButtonPress-3>"に変更してみてください
				label.bind("<ButtonPress-2>", self.raise_flag)

	# 右クリック時に実行する関数
	def raise_flag(self, event):

		# ゲーム中でなければ何もしない
		if not self.play_game:
			return

		# クリックされたラベルを取得
		label = event.widget

		# 既にそのマスを開いている場合は何もしない
		if label.cget("relief") != tkinter.RAISED:
			return

		# 既に旗が設定されている場合
		if label.cget("text") != "F":

			# ラベルの色を設定
			bg = FLAG_BG_COLOR

			# そのラベル上に旗(F)を立てる
			label.config(
				text="F",
				bg=bg
			)
		else:
			# ラベルの色を設定
			bg = EMPTY_BG_COLOR

			# そのラベル上の旗(F)を取り除く
			label.config(
				text="",
				bg=bg
			)

	# 左クリック時に実行する関数
	def open_cell(self, event):

		# ゲーム中でなければ何もしない
		if not self.play_game:
			return

		# クリックされたラベルを取得
		label = event.widget

		# ラベルの座標を取得
		for y in range(self.height):
			for x in range(self.width):
				if self.labels[y][x] == label:
					j = y
					i = x

		cell = self.cells[j][i]

		# 既にそのマスを開いている場合は何もしない
		if label.cget("relief") != tkinter.RAISED:
			return

		# マスの状態に応じて表示するテキストと色を設定
		text, bg, fg = self.get_text_info(cell)

		# そこに地雷がある場合
		if cell == MINE:

			# ゲームオーバーフラグをTrueに設定
			self.open_mine = True

		# ラベルの設定変更
		label.config(
			text=text,
			bg=bg,
			fg=fg,
			relief=tkinter.SUNKEN
		)

		# 開いたマス数をカウントアップ
		self.open_num += 1

		# 周辺の座標も開けるかどうかを調べていく
		if cell == 0:
			self.open_neighbor(i - 1, j - 1)
			self.open_neighbor(i, j - 1)
			self.open_neighbor(i + 1, j - 1)
			self.open_neighbor(i - 1, j)
			self.open_neighbor(i + 1, j)
			self.open_neighbor(i - 1, j + 1)
			self.open_neighbor(i, j + 1)
			self.open_neighbor(i + 1, j + 1)
			

		# ゲームオーバーならゲームオーバー処理
		if self.open_mine:
			self.app.after_idle(self.game_over)

		# ゲームクリアならゲームクリア処理
		elif self.open_num == self.clear_num:
			self.app.after_idle(self.game_clear)

	# クリックされたマスをの周辺を開く処理
	def open_neighbor(self, i, j):

		# 地雷を開けてしまっていたら何もしない
		if self.open_mine:
			return

		# ボード外の座標であれば何もしない
		if not (j >= 0 and i >= 0 and j < self.height and i < self.width):
			return

		# その座標のラベルを取得
		label = self.labels[j][i]

		# 既にそのマスを開いている場合は何もしない
		if label.cget("relief") != tkinter.RAISED:
			return

		# そのマスが地雷であれば何もしない
		if self.cells[j][i] == MINE:
			return

		# ↑ の条件に当てはまらないのであればそのマスは開ける

		# ラベルの座標に応じて表示するテキストと色を設定
		text, bg, fg = self.get_text_info(self.cells[j][i])

		# ラベルの設定変更
		label.config(
			text=text,
			bg=bg,
			fg=fg,
			relief=tkinter.SUNKEN
		)

		# 開いたマス数をカウントアップ
		self.open_num += 1

		# 周辺の座標も開けるかどうかを調べていく
		if self.cells[j][i] == 0:
			self.open_neighbor(i - 1, j - 1)
			self.open_neighbor(i, j - 1)
			self.open_neighbor(i + 1, j - 1)
			self.open_neighbor(i - 1, j)
			self.open_neighbor(i + 1, j)
			self.open_neighbor(i - 1, j + 1)
			self.open_neighbor(i, j + 1)
			self.open_neighbor(i + 1, j + 1)

	# ゲームオーバー時の処理
	def game_over(self):

		# 全マスを開く
		self.open_all()

		# ゲーム中フラグをFalseに設定
		self.play_game = False

		# メッセージを表示
		messagebox.showerror(
			"ゲームオーバー",
			"地雷を開いてしまいました..."
		)

	# ゲームクリア時の処理
	def game_clear(self):

		# 全マスを開く
		self.open_all()

		# ゲーム中フラグをFalseに設定
		self.play_game = False

		# メッセージを表示
		messagebox.showinfo(
			"ゲームクリア",
			"おめでとうございます!ゲームクリア!"
		)

	# マスを全て開く
	def open_all(self):

		# 全マスに対するループ
		for j in range(self.height):
			for i in range(self.width):
				label = self.labels[j][i]

				# ラベルの座標に応じて表示するテキストと色を設定
				text, bg, fg = self.get_text_info(self.cells[j][i])

				# ラベルの設定変更
				label.config(
					text=text,
					bg=bg,
					fg=fg,
					relief=tkinter.SUNKEN
				)

	# テキストと文字と背景色を取得する関数
	def get_text_info(self, num):

		# 指定された数字を表示する色を決定
		if num == MINE:
			text = "X"
			bg = MINE_BG_COLOR
			fg = "darkred"
		elif num == 0:
			text = ""
			bg = EMPTY_BG_COLOR
			fg = "black"
		else:
			text = str(num)
			bg = EMPTY_BG_COLOR
			fg = fg_color[num]

		return (text, bg, fg)


# プログラムの開始
app = tkinter.Tk()
game = MineSweeper(app)
app.mainloop()

スポンサーリンク

まとめ

このページでは Python での「マインスイーパーアプリ」の作り方について解説しました。

Windows 使ってた方には馴染み深いゲームだと思いますし、ゲーム開発の入門として良いテーマだと思います!

記事としては長くなってしまいましたが、tkinter を使ったことのある方であれば、割と基本的な使い方でマインスイーパーアプリを作ることができます。

ちょっと難しいのは再帰呼び出しとイベントハンドラの中からラベルの位置を取得するあたりですね。

特に再帰呼び出しは使い方を誤ると無限ループに陥いったりメモリが足りなくなる(スタックオーバーフロー)ことも多いので注意も必要ですが、使いこなせるとめちゃめちゃ便利なので、この機会に是非利用して仕組みを理解してみてください!

オススメ参考書(PR)

簡単なアプリやゲームを作りながら Python について学びたいという方には、下記の Pythonでつくる ゲーム開発 入門講座 がオススメです!ちなみに私が Python を始めるときに最初に買った書籍です!

下記ようなゲームを作成しながら Python の基本が楽しく学べます!素材もダウンロードして利用できるため、作成したゲームの見た目にも満足できると思います。

  • すごろく
  • おみくじ
  • 迷路ゲーム
  • 落ち物パズル
  • RPG

また本書籍は下記のような構成になっているため、Python 初心者でも内容を理解しやすいです。

  • プログラミング・Python の基礎から解説
  • 絵を用いた解説が豊富
  • ライブラリの使い方から解説(tkitner と Pygame)
  • ソースコードの1行1行に注釈

ゲーム開発は楽しくプログラミングを学べるだけでなく、ゲームで学んだことは他の分野のプログラミングにも活かせるものが多いですし(キーボードの入力受付のイベントや定期的な処理・画像や座標を扱い方等)、逆に他の分野のプログラミングで学んだ知識を活かしやすいことも特徴だと思います(例えばコンピュータの動作に機械学習を取り入れるなど)。

プログラミングを学ぶのにゲーム開発は相性抜群だと思います。

Python の基礎や tkinter・Pygame の使い方をご存知なのであれば、下記の 実践編 をいきなり読むのもアリです。

実践編 では「シューティングゲーム」や「アクションゲーム」「3D カーレース」等のより難易度の高いゲームを作りながらプログラミングの力をつけていくことができます!

また、単にゲームを作るのではなく、対戦相手となるコンピュータの動作のアルゴリズムにも興味のある方は下記の「Pythonで作って学べるゲームのアルゴリズム入門」がオススメです。

この本はゲームのコンピュータ(AI)の動作アルゴリズム(思考ルーチン)に対する入門解説本になります。例えばオセロゲームにおけるコンピュータが、どのような思考によって石を置く場所を決めているか等の基本的な知識を得ることが出来ます。

プログラミングを挫折せずに続けていくためには楽しさを味わいながら学習することが大事ですので、特にゲームに興味のある方は、この辺りの参考書と一緒に Python を学んでいくのがオススメです!

同じカテゴリのページ一覧を表示

6 COMMENTS

初心者A

このサイトをマネしてマインスイーパを作ろうとしたがうまくいかない。
右クリックしても反応がない。
まるまるコピーしてきて動かしても症状変わらなかった。

daeu

初心者A さん

コメントありがとうございます。
そしてご不便をおかけして大変申し訳ございません。

記事中にも追記させていただきましたが、環境によっては右クリック時のイベント設定時、bind メソッドの第1引数には "<ButtonPress-3>" を指定する必要があるようです。

お手数をおかけしますが、スクリプトの中で "<ButtonPress-2>" と記載している部分を "<ButtonPress-3>" に変更して試してみていただけないでしょうか。

初心者A

こっちが一方的にまねしてるだけだから謝る必要はないと思うけど。
後一年も前の記事なのにすぐに返事くれてありがとう。
それはともかく変更したけどうまくいかなかった。
旗を立てられないこと以外は全部うまくいってるんだけど。
ジュピターノートブックがダメなんだろうか。
作者さんは何を使ってるの?

daeu

初心者Aさん

ご返信ありがとうございます!
そう言っていただけるとありがたいです…!

また、変更して試していただいてありがとうございます。
でもダメだったんですね…。

ちなみに私はいつも VSCode を使っているのですが、
ジュピターノートブックでも試してみても上手く動いたので
ジュピターノートブックがダメなわけではなさそうです。

もしお手数でなければの話ですが、下記のスクリプトを実行し、
表示されるウィンドウ上で右クリックを行なってみていただけないでしょうか?

おそらくウィンドウ上に数値が表示されると思います。
その数値を、"<ButtonPress-x>" のxの部分に指定するようにすれば、
右クリック時に旗が立てられるようになるのではないかと予想しています。
# 旗が立つといってもボタンの色が変わって F と表示されるだけですが…

ちなみに左クリックしても数値が表示されると思いますが、xに指定する必要があるのは右クリックした直後に表示される値になります。
もちろん暇な時&気が向いたらで結構ですので、試した結果をご返信いただけると幸いです。

import tkinter

def button_func(event):
    label.config(text=str(event.num))

app = tkinter.Tk()
app.geometry("400x300")

app.bind("<Button>", button_func)

label = tkinter.Label(
    app,
    text="",
)
label.pack()

app.mainloop()
初心者A

そのコードを実行した結果は”3”だった。
それで調べたらジュピターノートブックはカーネルリスタートなるものをしないとうまくいかないことがあるらしく、カーネルリスタートしたらうまくいった。
親身に答えてくれてありがとう。
初心者なりにチマチマ勉強してるけどプログラム言語ってそれそのものより他の事が原因で躓くことが多いね。
今後も参考にさせてもらおうと思うよ。ありがとう。

daeu

初心者Aさん

ご返信ありがとうございます!

無事動いたようで良かったです!
コメントしていただいたことで動かない環境があることが分かりましたので、
こちらとしても大変助かりました。ありがとうございます。

>初心者なりにチマチマ勉強してるけどプログラム言語ってそれそのものより他の事が原因で躓くことが多いね。
ここがプログラミングで挫折する原因の1つですよね…。
同じ現象で困った際の解決法等をまとめてくれている記事が見つかることも多いですので、
大変ではありますが、ネットで検索しながら分からないことを解決していくことが大事だと思います!

また何かありましたら気軽にコメントいただければ幸いです。

現在コメントは受け付けておりません。