Pythonの標準ライブラリ3つだけでパズルゲーム作ってみた

2020年3月17日

あるYouTube動画内で高速でパズルゲームを作り上げていることに触発されて、私もPython3の標準ライブラリのみを使ってぷよぷよもどきを作ってみました。ロジックをとてもとてもとても参考にさせてもらっています。

動画内ではC++でコーディングしていたのですが、私が好きなPythonならもっと簡単に書けるのではないかと思い作ってみました。

記事の後半でコードを全て公開しています。

作った物紹介

とりあえず動いているところを見てもらいましょう。

ゲームの特徴

このゲームの特徴は以下です。

  • ボード内の二箇所を選択すると選択された箇所のブロックが交換される
  • 交換された結果、3つ以上同じ色でつながっているブロックは削除する
  • 削除されたブロックの箇所には上のブロックが落ちてくる
  • たくさんコンボをしたら高得点(私の最高は64)

動作環境

私はMacOS上で実行していますが、利用しているライブラリが標準ライブラリのみで、1ファイルで完結するものなので、WindowsでもLinuxでもPython3が動く環境であれば動作するはずです。(動作確認はMacでしかしていないのでご注意を。)

Pythonのバージョンは3.8.2で試してます。

使用ライブラリ

このアプリケーションは3つの標準ライブラリしかimportしていません。

from random import randint
from time import sleep
import tkinter as tk

random.randintは乱数を出力してくれる関数。色をランダムに決める時に使います。

sleepは時を止める関数です。パズルの動きに間を作るために使います。

tkinterはGUIインターフェースを提供してくれる標準ライブラリで、このアプリの肝です。そのため少し詳しく使い方をお話しします。

tkinterの簡単な解説

tkinterはGUI画面を提供してくれるツールです。
今回紹介するゲームを作る上で重要なtkinterの特徴は以下になります。

  • ウィンドウという名称のGUIの枠を作ることが出来る
    • windowにはタイトルを付けられる
  • ウィンドウ上に置けるラベルボタンを作れる
  • オブジェクトは背景色や文字色を設定することが出来る
  • ボタンはクリックされた時の挙動を指定できる
    • 関数の実行など

今回作成したぷよぷよもどきの例の場合以下のようにtkinterを使っています。

  • パズル画面全体がウィンドウ
  • ウィンドウの上に書いてあるpunyopunyoがタイトル
  • 右下のnow comboなどの文字列がラベル
  • カラフルな一個一個のブロックがボタン
  • ボタンはクリックされたときにある関数が実行されるようにしている

簡単に使えそうでしょう。

実際簡単です。とても扱いやすい良いライブラリだと思います。

tkinterについてもっと詳しく知りたい方は以下のサイトを参考にすると良いです。

コード

それではどんなコードを書いたのか紹介します。

from random import randint
from time import sleep
import tkinter as tk


# パズルの形状
HEIGHT = 10
WIDTH = 10

# ボタン(ブロック)の色の種類
BUTTON_COLLORS = [
    "#ffffff",  # white
    "#ff0000",  # red
    "#0000ff",  # blue
    "#ffff00",  # yellow
    "#00ff00",  # lime
    "#ffc0cb",  # pink
    "#00ffff",  # aqua
    # "#000000",  # black
]

# 色の数
COLOR_NUM = len(BUTTON_COLLORS)

# ボタンに設定できる背景色のタイプ3つ
COLOR_CHANGEABLE_BACKGROUNDS = [
    "background",
    "highlightbackground",
    "activebackground",
]

# ボタンを選択した時に、選択されたボタン上に表示されるもの
SELECTED_BUTTON_TEXT = "⛄"


class Punyopunyo(tk.Frame):

    def __init__(self, master=None):
        super().__init__(master)
        self.master.geometry()  # ボタンの位置を柔軟に設定出来るwindowに設定
        self.master.title('punyopunyo')  # タイトルを付与

        # 各種インスタンス変数を初期化
        self.exists_selected_button = False

        self.init_board()  # ボタン(パズル用ブロック)を作成
        self.lock_buttons()  # ボタンを押せないようにする
        self.init_checked_buttons()  # あとで使うボタンのチェックフラグ群を初期化
        sleep(0.5)  # ボタンが作成されたタイミングで一休み

        self.create_now_combo_label()

        # 最初に作成されたパズルの時点で、
        # すでに3つ以上連結しているブロックをすべて消し、
        # 上から詰める、を繰り返す
        self.erased = True
        while self.erased:
            self.init_checked_buttons()
            self.erase_all_connected_buttons()
            self.drop_buttons()
        self.now_combo = 0  # ユーザー操作ではない部分でのコンボが計算されているため、0で初期化

        self.create_max_combo_label()
        self.unlock_buttons()  # もろもろの設定が終わった段階でパズルをクリック可能にする

    def create_now_combo_label(self):
        """現在のコンボ数を示すためのlabelを作成する
        """
        self.now_combo = 0
        self.now_combo_txt = tk.StringVar()
        self.now_combo_txt.set(f"Now Combo is {self.now_combo}")
        self.now_combo_label = tk.Label(
            self.master, textvariable=self.now_combo_txt)
        self.now_combo_label.grid(row=HEIGHT, column=WIDTH+1)

    def create_max_combo_label(self):
        """最大コンボ数を示すためのlabelを作成する
        """
        self.max_combo = 0
        self.max_combo_txt = tk.StringVar()
        self.max_combo_txt.set(f"Now max Combo is {self.max_combo}")
        self.label = tk.Label(self.master, textvariable=self.max_combo_txt)
        self.label.grid(row=HEIGHT+1, column=WIDTH+1)

    def button_action(self, btn):
        """ボタンが押された時に実行される関数
        """

        # チェック
        if not self.exists_selected_button:
            # すでにセレクトされたセルがなければ選択してボタンアクション終了
            self.select_button(btn)
            return

        self.select_button(btn)
        self.master.update()
        self.lock_buttons()  # 2つのボタンが選択されて、パズルの処理が終わるまでユーザー操作をロック
        sleep(0.4)  # ちょっと一息

        self.exists_selected_button = False

        # 選択されたボタンを見つける
        change_target_buttons = []
        for button in self.flatten_buttons:
            if button["text"] == SELECTED_BUTTON_TEXT:
                change_target_buttons.append(button)

        # 選択されたボタン同士の色を入れ替える
        tmp_color = change_target_buttons[0]["background"]
        self.change_button_color(change_target_buttons[0], change_target_buttons[1]["background"])
        self.change_button_color(change_target_buttons[1], tmp_color)

        # 色の交換が終わったので、選択されたことを表すボタン上のテキストを消す
        for target in change_target_buttons:
            target["text"] = ""

        self.master.update()

        self.erased = True
        self.now_combo = 0
        while self.erased:
            self.drop_buttons()
            self.init_checked_buttons()
            self.erase_all_connected_buttons()

        if self.max_combo < self.now_combo:
            self.max_combo = self.now_combo
            self.max_combo_txt.set(f"Your Max Combo is {self.max_combo}")

        self.unlock_buttons()

    def erase_all_connected_buttons(self):
        """3つ以上結合しているボタンを全て消す
        """
        self.erased = False
        for row in range(HEIGHT-1, -1, -1):
            for column in range(WIDTH):
                connected_count = self.count_connected_buttons(
                    x=column,
                    y=row,
                    color=self.buttons[row][column]["background"],
                    connected_count=0
                )
                if connected_count > 2:
                    self.erase_connected_buttons(
                        x=column,
                        y=row,
                        color=self.buttons[row][column]["background"])

                    self.erased = True
                    self.now_combo += 1
                    self.now_combo_txt.set(f"Now Combo is {self.now_combo}")
                    sleep(0.2)
                    self.master.update()  # 一箇所消したら都度画面の再描画

    def drop_buttons(self):
        """空白箇所を詰める
        """
        exist_white = True
        while exist_white:
            exist_white = False
            for y in range(HEIGHT-2, -1, -1):
                for x in range(WIDTH):
                    if (self.buttons[y][x]["background"] != BUTTON_COLLORS[0]
                            and self.buttons[y+1][x]["background"] == BUTTON_COLLORS[0]):
                        self.change_button_color(
                            self.buttons[y+1][x], self.buttons[y][x]["background"])
                        self.change_button_color(
                            self.buttons[y][x], BUTTON_COLLORS[0])

            for x in range(WIDTH):
                if self.buttons[0][x]["background"] == BUTTON_COLLORS[0]:
                    self.change_button_color(
                        self.buttons[0][x], BUTTON_COLLORS[randint(1, COLOR_NUM-1)])
                    exist_white = True

            sleep(0.3)
            self.master.update()

    def count_connected_buttons(self, x, y, color, connected_count):
        """隣接する同じ色のセルを数える(再起メソッド)
        """
        if (x < 0 or WIDTH <= x
            or y < 0 or HEIGHT <= y
            or self.checked_buttons[y][x]
            or self.buttons[y][x]["background"] != color):
            return connected_count

        connected_count += 1
        self.checked_buttons[y][x] = True

        connected_count = self.count_connected_buttons(x-1, y, color, connected_count)
        connected_count = self.count_connected_buttons(x+1, y, color, connected_count)
        connected_count = self.count_connected_buttons(x, y-1, color, connected_count)
        connected_count = self.count_connected_buttons(x, y+1, color, connected_count)

        return connected_count

    def erase_connected_buttons(self, x, y, color):
        """隣接する同じ色のセルを消す
        """
        if (x < 0 or WIDTH <= x
            or y < 0 or HEIGHT <= y
            or self.buttons[y][x]["background"] == BUTTON_COLLORS[0]
            or self.buttons[y][x]["background"] != color):
            return

        self.change_button_color(self.buttons[y][x], BUTTON_COLLORS[0])

        self.erase_connected_buttons(x-1, y, color)
        self.erase_connected_buttons(x+1, y, color)
        self.erase_connected_buttons(x, y-1, color)
        self.erase_connected_buttons(x, y+1, color)

    def change_button_color(self, button, color):
        """ボタンを指定された色に変える
        (なんかOSによって適用される背景が異なるから、三種類の背景を全て変える)
        """
        for background in COLOR_CHANGEABLE_BACKGROUNDS:
            button[background] = color

    def init_checked_buttons(self):
        """ボタンに対する処理が完了したか判断する配列を作成
        配列の位置はself.buttonsと対応している
        チェック済み状態を全てリセットすることにも利用
        """

        self.checked_buttons = []
        for i in range(HEIGHT):
            checked_rows = []
            for j in range(WIDTH):
                checked_rows.append(False)
            self.checked_buttons.append(checked_rows)

    def select_button(self, button):
        """セルを選択状態にする
        """
        button["text"] = SELECTED_BUTTON_TEXT
        button["state"] = tk.DISABLED

        self.exists_selected_button = True

    def create_button(self, row, column):
        """ボタンオブジェクトを作成して引数で指定された位置に置く
        """
        btn_color = BUTTON_COLLORS[randint(1, COLOR_NUM-1)]

        btn = tk.Button(self.master,
                        height=2, width=4)
        self.change_button_color(btn, btn_color)
        btn["command"] = lambda: self.button_action(btn)
        btn.grid(row=row, column=column)

        return btn

    def init_board(self):
        """全ボタンを作成(最初の盤面準備)
        """

        self.buttons = []
        for i in range(HEIGHT):
            button_rows = []
            for j in range(WIDTH):
                btn = self.create_button(row=i, column=j)
                button_rows.append(btn)
            self.buttons.append(button_rows)

        # のちのち扱いやすいようにボタンの一次元配列も作成しておく
        self.flatten_buttons = \
            [button for a_row_buttons in self.buttons for button in a_row_buttons]

    def lock_buttons(self):
        """全ボタンを選択不可にする
        """
        for row_buttons in self.buttons:
            for button in row_buttons:
                button["state"] = tk.DISABLED

    def unlock_buttons(self):
        """全ボタンを選択可にする
        """
        for row_buttons in self.buttons:
            for button in row_buttons:
                button["state"] = tk.NORMAL


root = tk.Tk()  # TKinterのウィンドウを作成
app = Punyopunyo(master=root)
app.mainloop()  # ウィンドウを起動させる

コピペしてpunyopunyo.pyファイルを作成し、python punyopunyo.pyと実行すればパズル画面が立ち上がり、遊ぶことができます。

重要な部分解説

  • self.masterはウィンドウ全体を表しています。
  • ボタンの色を変えたり、外見を変えるコーディングをしてもウィンドウの再描画をしないと反映されません
    • そのため反映させたいタイミングでself.master.update()をしています
  • ボタンを処理しやすいようにするためボードの形と同じ二次元配列に格納しています
  • gridメソッドでボタンの配置を決められます
  • 同じ色のボタンがつながっている個数を数えるcount_connected_buttonsメソッドでは、再起処理を使っています。
    • パズルをするより、この箇所のコードの動きを理解することの方がよっぽどパズルです
  • app.mainloop()はウィンドウ内でイベントの発生を待ち、イベントを発生させることを繰り返し行うためのメソッドです。

コード内にコメントをたくさん書いておいたので、時間をかけてコードを読み進めればどんな実装か理解できるはずです。ぜひ頑張って読み解いていただき、改善できる箇所があればご指摘ください。

ちなみにコード内のHIGHTとWIDTHの値を変えればパズルの形を変えることができます。BUTTON_COLLORSのコメントアウトされているblackの行を有効にすれば、ブロックの色に黒が増え難易度をあげることができます。

WIDTHを20に変えて、黒色のブロックを追加するとこんな感じの盤面になります。

自分でゲーム作るといろいろカスタマイズできて楽しいです。

まとめ

一番はじめに紹介した動画の主は、早い段階で一通り動作するものを作って、後は機能を足しながら都度デバッグをしており、さくさく開発を進めていたのが印象的でした。

コードだけでなく開発の仕方でも大いに参考になります。

まだ見たことがない方は是非見ておくことをお勧めします。
パズドラを小一時間で作ってみた【プログラミング実況】Programming Match-Three Game – YouTube

tkinterを使うと簡単にGUIアプリが作れます。楽しいアプリたくさん作りましょう!