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

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

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

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

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

マインスイーパーとは

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

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

マインスイーパーのゲーム画面は下のようなものになります。この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)

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

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

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

第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 により下図のような画面を表示することで、ユーザーにゲームクリアしたことを伝えることができます。

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

スポンサーリンク

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

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

マインスイーパーアプリ
# -*- 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)

				# 右クリック時のイベント設定
				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 を使ったことのある方であれば、割と基本的な使い方でマインスイーパーアプリを作ることができます。

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

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

またゲームを作ったら作り方を解説したいと思います!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です