ひとりでのアプリ開発 - fineの備忘録 -

ひとりでアプリ開発をするなかで起こったことや学んだことを書き溜めていきます

Python - マインスイーパーを作ってみた

初めに

 本記事は、Pythontkinter を使い、マインスイーパーを作ってみます。

実装する機能

 マインスイーパとして、最低限の機能を実装します。




  • ウィンドウ上部に、やり直しボタンと地雷の数を表示するテキストを配置する。
  • ウィンドウ下部に、マスを指定した数だけ配置する。
  • 地雷の位置は開始時にランダムで決定される。
  • 左クリックでマスを選択。地雷がなければ隣接するマスにある地雷の個数を表示する。地雷があればゲームオーバー用のメッセージボックスを表示する。
  • 右クリックで地雷があることの目印である"F" と分からない部分の目印である"?"を表示させる。F → ? → 未記入の順でループさせる。また、"F"の場合は左クリックに反応しないようにしておく。
  • 地雷がないマスをすべて左クリックしたらゲームクリアメッセージを表示する。
  • やり直しボタンをクリックしたら、盤面をリセットする。

コード全文

# tkinter ライブラリをインポート
import tkinter as tk
from tkinter import messagebox
import random

# マインスイーパの盤面を生成する関数
def create_board(rows, cols, num_mines):
    # 初期化された盤面を作成
    board = [[0 for _ in range(cols)] for _ in range(rows)]
    
    # 地雷を配置, 地雷が配置されたマスは -1 
    for _ in range(num_mines):
        row, col = random.randint(0, rows-1), random.randint(0, cols-1)
        while board[row][col] == -1:
            row, col = random.randint(0, rows-1), random.randint(0, cols-1)
        board[row][col] = -1

    return board

# 特定のマスの周囲の地雷の数を数える関数
def count_adjacent_mines(board, row, col):
    if board[row][col] == -1:
        return -1
    
    count = 0
    for i in range(max(0, row-1), min(len(board), row+2)):
        for j in range(max(0, col-1), min(len(board[0]), col+2)):
            if board[i][j] == -1:
                count += 1

    return count

# 特定のマスをクリックした際の処理を行う関数
def click_cell(board, row, col):
    buttons[row][col]["bg"] = put_color
    if board[row][col] == -1:
        return False
    else:
        board[row][col] = count_adjacent_mines(board, row, col)
        return True

# ゲームクリアの条件を確認する関数
def check_clear(board):
    global game_over

    if all(board[i][j] == -1 or not button_state[i][j] for i in range(rows) for j in range(cols)):
        game_over = True

        # ゲームクリアメッセージを表示
        messagebox.showinfo("finish", "ゲームクリア")

# ボタンの状態を更新する関数
def update_buttons():
    for i in range(rows):
        for j in range(cols):
            if buttons[i][j]["text"] == "F":
                buttons[i][j].config(text="F", state=tk.DISABLED)
            elif not button_state[i][j]:
                buttons[i][j].config(text=str(board[i][j]), state=tk.DISABLED)
            else:
                buttons[i][j]["bg"] = buttons[i][j]["bg"]
                if buttons[i][j]["text"] == "?":
                    buttons[i][j].config(text="?", state=tk.NORMAL)
                else:
                    buttons[i][j].config(text="", state=tk.DISABLED)
    check_clear(board)

# 特定のマスにフラグを立てる関数
def flag_cell(row, col):
    global flags

    if button_state[row][col]:
        buttons[row][col].config(text="F")
        button_state[row][col] = False
        flags += 1

# 特定のマスのフラグを取り消す関数
def unflag_cell(row, col):
    global flags

    if not button_state[row][col] and buttons[row][col]["text"] == "F":
        buttons[row][col].config(text="?")
        button_state[row][col] = True
        flags -= 1

# 特定のマスのマークを取り消す関数
def unmark_cell(row, col):
    if not button_state[row][col]:
        return
    
    if buttons[row][col]["text"] == "?":
        buttons[row][col].config(text="")
        button_state[row][col] = True

# 左クリックが行われた際の処理を行う関数
def leftclick_button(row, col, event):    
    if not button_state[row][col]:
        return

    if not click_cell(board, row, col):
        game_over = True
        for i in range(rows):
            for j in range(cols):
                if buttons[i][j]["text"] != "":
                    buttons[i][j]["text"] = buttons[i][j]["text"]
                elif board[i][j] == -1:
                    buttons[i][j]["text"] = "Bomb"
        # ゲームオーバーメッセージを表示
        messagebox.showinfo("finish", "ゲームオーバー")
    else:
        button_state[row][col] = False
        update_buttons()

# 右クリックが行われた際の処理を行う関数
def rightclick_button(row, col, event):
    if button_state[row][col] and buttons[row][col]["text"] == "":
        flag_cell(row, col)
    elif buttons[row][col]["text"] == "F":
        unflag_cell(row, col)
    elif buttons[row][col]["text"] == "?":
        unmark_cell(row, col)

# ゲームを開始する関数
def start_game():
    global root, rows, cols, num_mines, board, buttons, button_state, game_over, put_color, restart_button, flags

    # 初期設定
    rows = 5
    cols = 5
    num_mines = 5
    game_over = False
    put_color = "#AAAAAA"
    flags = 0

    # 前回のフレームを削除
    for widget in root.winfo_children():
        widget.destroy()

    # ゲームの初期化
    board = create_board(rows, cols, num_mines)
    button_state = [[True for _ in range(cols)] for _ in range(rows)]

    # restart用ボタンと地雷の数を表示するフレームを作成
    control_frame = tk.Frame(root)
    control_frame.pack()

    restart_button = tk.Button(control_frame, text="やり直す", command=start_game)
    restart_button.pack()

    mines_label = tk.Label(control_frame, text="地雷: " + str(num_mines))
    mines_label.pack()

    # マスを設置するフレームを作成
    board_frame = tk.Frame(root)
    board_frame.pack()

    buttons = [[None for _ in range(cols)] for _ in range(rows)]

    for i in range(rows):
        for j in range(cols):
            buttons[i][j] = tk.Button(board_frame, text="", width=5, height=2)
            buttons[i][j].bind("<Button-1>", lambda e, i = i, j = j: leftclick_button(i, j, e))
            buttons[i][j].bind("<Button-3>", lambda e, i = i, j = j: rightclick_button(i, j, e))
            buttons[i][j].grid(row=i, column=j)

    update_buttons()

# メイン関数
def main():
    global root

    root = tk.Tk()
    root.title("マインスイーパ")
    
    start_game()

    root.mainloop()

# スクリプトが直接実行された場合にメイン関数を実行
if __name__ == '__main__':
    main()

解説

 いくつかピックアップして解説します。

ボタンにイベントと関数を割り当てる
buttons[i][j].bind("<Button-1>", lambda e, i = i, j = j: leftclick_button(i, j, e))
buttons[i][j].bind("<Button-3>", lambda e, i = i, j = j: rightclick_button(i, j, e))

 bind メソッドを使い、特定のイベントに対し、特定の関数を関連付けることができます。

  は左クリック、は右クリックを表します。キー入力など他のイベントを割りあてる場合は、参考に一覧が載っているウェブサイトのリンク先を載せていますので、そちらをご覧ください。

 関数に引数を渡したいので、ラムダ式を使っています。

盤面の作成

 地雷が配置されているマスには -1 を、それ以外のマスには 0 を割り当てます。

# マインスイーパの盤面を生成する関数
def create_board(rows, cols, num_mines):
    # 初期化された盤面を作成
    board = [[0 for _ in range(cols)] for _ in range(rows)]
    
    # 地雷を配置, 地雷が配置されたマスは -1 
    for _ in range(num_mines):
        row, col = random.randint(0, rows-1), random.randint(0, cols-1)
        while board[row][col] == -1:
            row, col = random.randint(0, rows-1), random.randint(0, cols-1)
        board[row][col] = -1

    return board

 rows, cols で指定した行数、列数の二次元リストを board として作成します。

 random.randint(a, b) は a から b までのランダムな整数を返す関数です。これを用い、row、col にランダムな行番号、列番号を代入します。もしすでに、地雷が設置されている(board[row][col] に -1 が割り当てられている)場合は、while 文を使い繰り返します。

 この操作を num_mines(地雷の個数)だけ繰り返すことで、指定した数だけ地雷が設置できます。

周囲に配置された地雷の個数を数える

 マスをクリックしたら、クリックしたマスに地雷があるか否か、ない場合は周囲に何個地雷があるのかを返す必要があります。

# 特定のマスの周囲の地雷の数を数える関数
def count_adjacent_mines(board, row, col):
    if board[row][col] == -1:
        return -1
    
    count = 0
    for i in range(max(0, row-1), min(len(board), row+2)):
        for j in range(max(0, col-1), min(len(board[0]), col+2)):
            if board[i][j] == -1:
                count += 1

    return count

 for 文でクリックしたマスの行数と列数を row, col で取得し、その周囲のマスで - 1 が割り当てられているマスの個数を数えています。

 row + 2 になっているのは、range 関数は最大値を含めないためです。range(0, 3) の場合、取得できる整数は 0, 1, 2 になります。

機能を追加するなら

 ここからさらに機能を追加して、ゲーム性を高めることもできます。例えば

  • 時間制限を設ける
  • 難易度選択ができるようにし、行数、列数、地雷の個数を難易度ごとに変える
  • 指定したマスの地雷の有無が分かるアイテムを作る

などが考えられます。