作ったプログラムの備忘録
はじめに
1次元配列でgrid表示させる方法でマインスイーパーを作った場合に、2次元配列で実装するのに比べてどう変わるか確認する意味で作成してみました。
リンク先ですでに示していたように画面生成がかなり短くて済みますし、1次元配列だと各種判定がしやすい気がするので、個人的にこの書き方もありかなと思っています。
また隣接セル判定の関数を作ったので、全体的にそこそこすっきり書けた気がします。
実行環境
Windows 10 Pro 64bit
Python 3.9.13
実装した機能
- 左クリックでセルを開く
- 右クリックで爆弾フラグを立てる
- “0”マスは連鎖的に開く
- 全爆弾にフラグを立ててもクリア
- セーフセルに旗を立てるとアウト
- クリア/ゲームオーバー時に爆弾の場所を開示
- 初手爆発の可能性なし
- 爆弾数はセル数が同じなら一定数(Windows標準と同じ数となる)
コードの簡便さを優先して、実装しなかった機能は以下のとおりです。
(2023.2.16追記:以下の1~3も実装したコードを製作して記事中最後に掲載しました)
- リセットボタンなし
(ゲーム途中はウィンドウを閉じてプログラムを再実行が必要)
(ただし①ゲームクリア時、②爆弾踏んだ時、③セーフセルに旗を立てた時はリセットされる) - タイマー常時表示なし
(クリア時のクリアタイムは表示) - 残り爆弾数の表示なし
- 旗で爆弾が特定されている状況で、数字セルを左右同時クリックしたときに隣接セルを開放する
ラベル配置のコード
コメント抜いて7行で任意のセル数を生成可能です(grid_configureなければ5行)。
# grid表示の設定
self.master.grid_rowconfigure(tuple(range(self.n)), weight=1)
self.master.grid_columnconfigure(tuple(range(self.m)), weight=1)
# ラベル配置
self.mine = [tk.mine(self.master, text=' ', relief=tk.RAISED) for i in range(self.n*self.m)]
for i in range(self.n*self.m):
self.mine[i].grid(row=i//self.m, column=i%self.m, sticky=tk.NSEW)
self.mine[i].bind('<Button-1>', partial(self.click_left, i))
self.mine[i].bind('<Button-3>', partial(self.click_right, i))
可読性無視で頑張って圧縮すると3行になったりもします(grid_configureなしの場合)。
self.mine = [tk.Label(self.master, text=' ', relief=tk.RAISED) for i in range(self.n*self.m)]
[self.mine[i].grid(row=i//self.m, column=i%self.m, sticky=tk.NSEW) for i in range(self.n*self.m)]
[[self.mine[i].bind(f'<Button-{j}>', partial(self.click_label, i)) for j in [1, 3]] for i in range(self.n*self.m)]
爆弾数
Windows標準だったマインスイーパーは、
- 9x9セルで10個
- 16x16セルで40個
- 16x30セルで99個
の爆弾があるそうです。
この場合、セル数に対して、爆弾数は線形ではなく2次式で同じセル数のときの値は算出できますが、セル数が16x30以上となったときに、爆弾割合が増えすぎることになります。
そこでマインスイーパー2000なるものを作った人がいるらしいのでその仕様を参考にすることにしました。
- 超上級:48x24セルで爆弾256個
- マニアック:64x48セルで爆弾777個
という仕様だそうで、セル数480以上ではこの爆弾数を目安に線形で増やすことにしました。
以上の背景から、爆弾数の算出式は以下のように定義しました。
# 爆弾数(セル数が480以下はWindows標準と同じ割合、480以上は2000を参考とした数値)
self.bomb_num = int(0.00023*(self.n*self.m)**2+0.094*(self.n*self.m)+0.894) \
if self.n*self.m < 480 else int(0.2615*(self.n*self.m)-26)
各種判定用の配列
値が変更されない固定値となるのは以下の2つ
- 爆弾位置
- 近接爆弾数
あとはフラグ用配列として、以下の2つです。
- 旗を立てたかどうか(旗フラグ)
- 開閉したかどうか(開閉フラグ)
ゲームを開始しているかのフラグは開閉フラグの合計値==self.n*self.m
で判定できるので、追加していません。
また爆弾判定を無=0、有=-1とすれば爆弾位置と近接爆弾数は1つの配列にまとめることは可能ですが、ゲームクリア検出の配列比較が面倒になるので今回は分けています(うまいことやればまとめられる気もしますが)。
というわけで、爆弾位置 == 開閉フラグ
と爆弾位置 == 旗フラグ
で配列比較してゲームクリアを検出しています。
爆弾位置配列は初手クリック時に生成するので、ランダムに並び替えた配列に対して、クリックしたインデックスは爆弾ではなくすることで、初手爆発回避をしています。
# 1.爆弾位置の配列
self.list_mine = random.sample([0]*(self.n*self.m-self.bomb_num) + [1]*(self.bomb_num), self.n*self.m)
self.list_mine[i] = 0 # 初手爆発回避
# 2.近接爆弾数の配列
self.list_near = [sum([self.list_mine[x] for x in self.near(i)]) for i in range(self.n*self.m)]
# 3.旗フラグ管理の配列
self.list_flag = [0] * (self.n*self.m)
# 4.開閉フラグ管理の配列
self.list_open = [1] * (self.n*self.m)
隣接セルの判定
1次元配列にした場合、n行×m列に配置した際の隣接セル判定が少しやっかいです。
上辺、下辺は0 <= j < self.n*self.m
で簡単に判定できますが、左辺・右辺が問題となり、
左辺はi%self.m == 0
、右辺はi%self.m//(self.m-1)
で判定してから、個別にそれぞれの左右の範囲外3セル分を判定しています。
右辺のi%self.m//(self.m-1)
はi%self.m == self.m-1
とも書けます。
今回は「近接爆弾数の合計算出」と「セーフセル処理関数」という機能が異なる処理で隣接セルの判定が必要になったため、隣接セルのインデックス番号を配列で戻す関数として汎化しています。
# 隣接セルのインデックスを返す関数
def near(self, i):
# 隣接セルが存在するかの判定関数
def exist(j):
if 0 <= j < self.n*self.m and not (i%self.m == 0 and j in nears[:3] or i%self.m//(self.m-1) and j in nears[5:]):
return j
# 隣接セルのインデックスを配列で用意(順番はインデックス0-2がiより左側、3,4がiと同列、5-7がiより右側)
nears = [i-self.m-1, i-1, i+self.m-1, i-self.m, i+self.m, i-self.m+1, i+1, i+self.m+1]
# 隣接セルのインデックスを数値のみにして返す(0も返したいので、boolではなくisinstanceを使用)
return [exist(x) for x in nears if isinstance(exist(x), int)]
ソースコード
コメント入れて119行(コメントなし86行)なのでそこそこ短めに書けたでしょうか。
[ソースコード]
import time
import random
import tkinter as tk
from tkinter import messagebox as mb
from functools import partial
class Application(tk.Frame):
def __init__(self, master = None):
super().__init__(master)
self.n, self.m = 7, 7 # 縦, 横のセル数 Windows標準は初級n9xm9、中級n16xm16、上級n16xm30
# 爆弾数(セル数が480以下はWindows標準と同じ割合、480以上は2000を参考とした数値)
self.bomb_num = int(0.00023*(self.n*self.m)**2+0.094*(self.n*self.m)+0.894) \
if self.n*self.m < 480 else int(0.2615*(self.n*self.m)-26)
# 文字色設定
self.txc = {0: "gray95", 1: "blue", 2: "green", 3: "purple", 4: "olive", \
5: "chocolate", 6: "magenta", 7: "darkorange", 8: "red",}
# 画面サイズはセル数に応じて可変設定
self.master.title('mine sweeper')
self.master.geometry(f'{self.m*20}x{self.n*20}')
self.create_widget()
# 画面生成
def create_widget(self):
# grid表示の設定
self.master.grid_rowconfigure(tuple(range(self.n)), weight=1)
self.master.grid_columnconfigure(tuple(range(self.m)), weight=1)
# ラベル配置
self.mine = [tk.Label(self.master, text=' ', relief=tk.RAISED) for i in range(self.n*self.m)]
for i in range(self.n*self.m):
self.mine[i].grid(row=i//self.m, column=i%self.m, sticky=tk.NSEW)
self.mine[i].bind('<Button-1>', partial(self.click_label, i, 'left'))
self.mine[i].bind('<Button-3>', partial(self.click_label, i, 'right'))
# [初期値生成] 爆弾位置の配列
self.list_mine = random.sample([0]*(self.n*self.m-self.bomb_num) + [1]*(self.bomb_num), self.n*self.m)
# [初期値生成] 旗が立っているかのフラグ用配列
self.list_flag = [0] * (self.n*self.m)
# [初期値生成] セルが開いているかのフラグ用配列
self.list_open = [1] * (self.n*self.m)
# 左クリック時の挙動
def click_label(self, i, lr, event):
if lr == 'left':
# 1つもセルを開いていなければゲーム開始
if sum(self.list_open) == self.n*self.m:
# 初手爆発回避(押したラベルが爆弾だったら別の爆弾じゃない場所を爆弾にする)
if self.list_mine[i] == 1:
self.list_mine[random.choice([i for i, x in enumerate(self.list_mine) if x == 0])] = 1
self.list_mine[i] = 0
# [初期値生成] 近接爆弾数の配列
self.list_near = [sum([self.list_mine[x] for x in self.near(i)]) for i in range(self.n*self.m)]
# [初期値生成] クリアタイム計算用開始時間設定
self.start_time = time.perf_counter()
# 爆弾検出。爆弾なら終了処理。それ以外はセーフセル処理関数を呼び出し
if self.list_mine[i]:
self.end_process('bomb')
else:
self.open_safecell(i)
else:
# 1つ以上セルを開いていれば旗を立てる処理を実行
if sum(self.list_open) != self.n*self.m:
# 開いていなければ旗を立てる。
if self.list_open[i]:
# 旗フラグを変更
self.list_flag[i] = 1
# 旗を表示する
self.mine[i].config(text=' F ', bg='gold')
# 旗を間違えた場合ゲーム終了
if self.list_flag[i] != self.list_mine[i]:
self.end_process('wrong flag')
# 開閉フラグ=爆弾位置 or 旗を全部立てられてもゲームクリア
if self.list_open == self.list_mine or self.list_flag == self.list_mine:
self.end_process('CLEAR')
# 隣接セルのインデックスを返す関数
def near(self, i):
# 隣接セルが存在するかの判定関数
def exist(j):
if 0 <= j < self.n*self.m and not (i%self.m == 0 and j in nears[:3] or i%self.m//(self.m-1) and j in nears[5:]):
return j
# 隣接セルのインデックスを配列を用意
nears = [i-self.m-1, i-1, i+self.m-1, i-self.m, i+self.m, i-self.m+1, i+1, i+self.m+1]
# 隣接セルのインデックスを数値のみにして返す
return [exist(x) for x in nears if isinstance(exist(x), int)]
# セーフセル処理関数
def open_safecell(self, i):
# 開閉フラグを変更
self.list_open[i] = 0
# 近接爆弾数を表示(0はtextをスペースに変換)
self.mine[i].configure(relief=tk.GROOVE, fg=f'{self.txc[self.list_near[i]]}',
text=f'{self.list_near[i] if self.list_near[i] else " "}')
# 隣接セル数=0 -> 隣接セルが爆弾じゃない -> セルが開いていない -> 再帰呼び出し
if self.list_near[i] == 0:
for i in self.near(i):
if not self.list_mine[i]:
if self.list_open[i]:
self.open_safecell(i)
# 終了処理(クリアした場合は時間表示)
def end_process(self, msg):
if sum(self.list_open) != self.n*self.m:
if msg == 'CLEAR':
msg += f'\n{(time.perf_counter() - self.start_time):.3f} sec'
# 爆弾位置表示と全セルをオープン
for i in range(self.n*self.m):
if self.list_mine[i]:
self.mine[i].config(text=' x ', bg='pink')
else:
self.open_safecell(i)
# メッセージ表示
mb.showinfo(msg, msg)
# 画面リセット
self.create_widget()
if __name__ == "__main__":
root = tk.Tk()
app = Application(master = root)
app.mainloop()
機能は減らさず多少強引に行数を短くしてみるとコメントなしで63行までは行きました。
[ソースコード](行数削減版63行)
import time
import random
import tkinter as tk
from tkinter import messagebox
from functools import partial
class Application(tk.Frame):
def __init__(self, master = None):
super().__init__(master)
self.n, self.m = 7, 7
self.bomb_num = int(0.00023*(self.n*self.m)**2+0.094*(self.n*self.m)+0.894) if self.n*self.m < 480 else int(0.2615*(self.n*self.m)-26)
self.txc = {0: "gray95", 1: "blue", 2: "green", 3: "purple", 4: "olive", 5: "chocolate", 6: "magenta", 7: "darkorange", 8: "red",}
self.master.title('mine sweeper')
self.master.geometry(f'{self.m*20}x{self.n*20}')
self.create_widget()
def create_widget(self):
self.master.grid_rowconfigure(tuple(range(self.n)), weight=1)
self.master.grid_columnconfigure(tuple(range(self.m)), weight=1)
self.mine = [tk.Label(self.master, text=' ', relief=tk.RAISED) for i in range(self.n*self.m)]
[self.mine[i].grid(row=i//self.m, column=i%self.m, sticky=tk.NSEW) for i in range(self.n*self.m)]
[[self.mine[i].bind(f'<Button-{j}>', partial(self.click_label, i, j)) for j in [1, 3]] for i in range(self.n*self.m)]
self.list_mine, self.list_flag, self.list_open = random.sample([0]*(self.n*self.m-self.bomb_num) + [1]*(self.bomb_num), self.n*self.m), [0] * (self.n*self.m), [1] * (self.n*self.m)
def click_label(self, i, lr, event):
if lr == 1:
if sum(self.list_open) == self.n*self.m:
self.list_mine[random.choice([i for i, x in enumerate(self.list_mine) if x == 0])] = 1 if self.list_mine[i] == 1 else 0
self.list_mine[i], self.timer, self.list_near = 0, time.perf_counter(), [sum([self.list_mine[x] for x in self.near(i)]) for i in range(self.n*self.m)]
self.open_safecell(i) if self.list_mine[i] == 0 else self.end_process('bomb')
else:
if sum(self.list_open) != self.n*self.m:
if self.list_open[i]:
self.list_flag[i] = 1
self.mine[i].config(text=' F ', bg='gold')
if self.list_flag[i] != self.list_mine[i]:
self.end_process('wrong flag')
if self.list_open == self.list_mine or self.list_flag == self.list_mine:
self.end_process('CLEAR')
def open_safecell(self, i):
self.list_open[i] = 0
self.mine[i].configure(relief=tk.GROOVE, fg=f'{self.txc[self.list_near[i]]}', text=f'{self.list_near[i] if self.list_near[i] else " "}')
if self.list_near[i] == 0:
[self.open_safecell(i) for i in self.near(i) if not self.list_mine[i] if self.list_open[i]]
def near(self, i):
def exist(j):
if 0 <= j < self.n*self.m and not (i%self.m == 0 and j in nears[:3] or i%self.m//(self.m-1) and j in nears[5:]):
return j
nears = [i-self.m-1, i-1, i+self.m-1, i-self.m, i+self.m, i-self.m+1, i+1, i+self.m+1]
return [exist(x) for x in nears if isinstance(exist(x), int)]
def end_process(self, msg):
if sum(self.list_open) != self.n*self.m:
[self.mine[i].config(text=' x ', bg='pink') if self.list_mine[i] else self.open_safecell(i) for i in range(self.n*self.m)]
messagebox.showinfo(msg, msg + f'\n{(time.perf_counter() - self.timer):.3f} sec' if msg == 'CLEAR' else msg)
self.create_widget()
if __name__ == "__main__":
root = tk.Tk()
app = Application(master = root)
app.mainloop()
[2023.02.16追記]
リセットボタン、残り爆弾数表示、タイマー表示も追加してみました。
[ソースコード]
import time
import random
import tkinter as tk
from tkinter import messagebox as mb
from functools import partial
class Application(tk.Frame):
def __init__(self, master = None):
super().__init__(master)
# 縦, 横のセル数 Windows標準は初級n9xm9、中級n16xm16、上級n16xm30
self.n, self.m = 9, 9
# 爆弾数(セル数が480以下はWindows標準と同じ割合、上級以上は2000を参考とした数値)
self.bomb_num = int(0.00023*(self.n*self.m)**2+0.094*(self.n*self.m)+0.894) \
if self.n*self.m < 480 else int(0.2615*(self.n*self.m)-26)
# 文字色設定
self.txc = {0: "gray95", 1: "blue", 2: "green", 3: "purple", 4: "olive",
5: "chocolate", 6: "magenta", 7: "darkorange", 8: "red",}
# 画面サイズはセル数に応じて可変設定
self.master.title('mine sweeper')
self.master.geometry(f'{self.m*20}x{self.n*20+20}')
self.master.resizable(width=False, height=False)
# フレーム画面生成
self.create_master()
# ゲーム画面生成
self.create_widget()
# フレーム画面生成
def create_master(self):
self.master.grid_rowconfigure(1, weight=1)
self.master.grid_columnconfigure(0, weight=1)
# フレーム(残り爆弾数表示、リセットボタン、タイマー)
self.frame1 = tk.Frame(self.master, relief=tk.FLAT, bd=2)
self.frame1.grid(row=0, column=0, padx=5, pady=(5, 0), sticky=tk.NSEW)
self.frame1.grid_columnconfigure((0, 2), weight=1)
# リセットボタン
self.button = tk.Button(self.frame1, text='(^^)', relief=tk.GROOVE, font=('', 10, 'bold'),
command=lambda: self.end_process('reset'))
self.button.grid(row=0, column=1, sticky=tk.NSEW)
# フレーム(セル配置)
self.frame2 = tk.Frame(self.master, relief=tk.SUNKEN, bd=2)
self.frame2.grid(row=1, column=0, padx=5, pady=5, sticky=tk.NSEW)
self.frame2.grid_rowconfigure(tuple(range(self.n)), weight=1)
self.frame2.grid_columnconfigure(tuple(range(self.m)), weight=1)
# ゲーム画面生成
def create_widget(self):
# ラベル配置(残り爆弾数、タイマー表示)リセットボタン用にこちらで記述
self.label = [tk.Label(self.frame1, text='000', font=('', 12, 'bold')) for i in [0, 1]]
self.label[0].grid(row=0, column=0, sticky=tk.W)
self.label[1].grid(row=0, column=2, sticky=tk.E)
# ラベル配置
self.mine = [tk.Label(self.frame2, text=' ', relief=tk.RAISED) for i in range(self.n*self.m)]
for i in range(self.n*self.m):
self.mine[i].grid(row=i//self.m, column=i%self.m, sticky=tk.NSEW)
self.mine[i].bind('<Button-1>', partial(self.click_label, i, 'left'))
self.mine[i].bind('<Button-3>', partial(self.click_label, i, 'right'))
# [初期値生成] 爆弾位置の配列
self.list_mine = random.sample([0]*(self.n*self.m-self.bomb_num) + [1]*(self.bomb_num), self.n*self.m)
# [初期値生成] 旗フラグ管理の配列
self.list_flag = [0] * (self.n*self.m)
# [初期値生成] 開閉フラグ管理の配列
self.list_open = [1] * (self.n*self.m)
# クリック時の挙動
def click_label(self, i, lr, event):
if lr == 'left':
# 1つもセルを開いていなければゲーム開始
if sum(self.list_open) == self.n*self.m:
# 初手爆発回避(押したラベルが爆弾だったら別の爆弾じゃない場所を爆弾にする)
if self.list_mine[i] == 1:
self.list_mine[random.choice([i for i, x in enumerate(self.list_mine) if x == 0])] = 1
self.list_mine[i] = 0
# [初期値生成] 近接爆弾数の配列
self.list_near = [sum([self.list_mine[x] for x in self.near(i)]) for i in range(self.n*self.m)]
# タイマー処理
self.start_time = time.perf_counter()
self.time_count('on')
# 残り爆弾数表示
self.label[0].configure(text=f'{(sum(self.list_mine) - sum(self.list_flag)):03.0f}')
# 爆弾検出。爆弾でなければセーフセル処理関数を呼び出し
if self.list_mine[i]:
self.end_process('bomb')
else:
self.open_safecell(i)
else:
# スタートしていなければ何もしない
if sum(self.list_open) != self.n*self.m:
# 開いていなければ旗を立てる。
if self.list_open[i]:
self.list_flag[i] = 1
self.mine[i].configure(text=' F ', bg='gold')
# 残り爆弾数表示
self.label[0].configure(text=f'{(sum(self.list_mine) - sum(self.list_flag)):03.0f}')
# 旗を間違えた場合ゲーム終了
if self.list_flag[i] != self.list_mine[i]:
self.end_process('wrong flag')
# 開閉フラグ=爆弾位置 or 旗を全部立てられてもゲームクリア
if self.list_open == self.list_mine or self.list_flag == self.list_mine:
self.end_process('CLEAR')
# 隣接セルのインデックスを返す関数
def near(self, i):
# 隣接セルが存在するかの判定関数
def exist(j):
if 0 <= j < self.n*self.m and \
not (i%self.m == 0 and j in nears[:3] or i%self.m//(self.m-1) and j in nears[5:]):
return j
# 隣接セルのインデックスを配列を用意
nears = [i-self.m-1, i-1, i+self.m-1, i-self.m, i+self.m, i-self.m+1, i+1, i+self.m+1]
# 隣接セルのインデックスを数値のみにして返す(0も返す必要があるのでisinstance関数で処理)
return [exist(x) for x in nears if isinstance(exist(x), int)]
# セーフセル処理関数
def open_safecell(self, i):
# 開閉フラグ
self.list_open[i] = 0
# 近接爆弾数を表示(0はtextをスペースに変換)
self.mine[i].configure(relief=tk.GROOVE, fg=f'{self.txc[self.list_near[i]]}',
text=f'{self.list_near[i] if self.list_near[i] else " "}')
# 隣接セル数=0 -> 隣接セルが爆弾じゃない -> セルが開いていない -> 再帰呼び出し
if self.list_near[i] == 0:
for i in self.near(i):
if not self.list_mine[i]:
if self.list_open[i]:
self.open_safecell(i)
# プレイ時間表示
def time_count(self, onoff):
if onoff == 'on':
# time_count関数を再度100[ms]後に実行
self.timer = self.master.after(100, lambda: self.time_count('on'))
# 計測時間を表示
self.label[1].configure(text=f'{(time.perf_counter() - self.start_time):03.0f}')
else:
self.master.after_cancel(self.timer)
# 終了処理(クリアした場合は時間表示)
def end_process(self, msg):
# ゲーム開始中なら実行
if sum(self.list_open) != self.n*self.m:
# タイマーオフ
self.time_count('off')
# クリア時はクリアタイムを追加
if msg == 'CLEAR':
msg += f'\n{(time.perf_counter() - self.start_time):.3f} sec'
if not msg == 'reset':
# 爆弾位置表示と全セルをオープン
for i in range(self.n*self.m):
if self.list_mine[i]:
self.mine[i].config(text=' x ', bg='pink')
else:
self.open_safecell(i)
# メッセージ表示
mb.showinfo(msg, msg)
# 画面リセット
self.create_widget()
if __name__ == "__main__":
root = tk.Tk()
app = Application(master = root)
app.mainloop()