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

2020年3月17日

当サイトのリンクには広告が含まれています。

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

この記事では以下の3つを解説しています。

  • どのようなパズルゲームを作ったのか
  • どのようなライブラリを使っているのか(各ライブラリの機能説明)
  • どのようなコードを書いたのか

自分でゲームを作ってみたいという方はそのための第一歩としてこの記事を参考にしてください。

作ったパズルゲームの紹介

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

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

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

動作環境

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

私が利用したPythonのバージョンは3.8.2です。

使用したライブラリ

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

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

randomパッケージから持ってきているrandintは乱数を出力してくれる関数です。色をランダムに決める時に使います。

timeパッケージから持ってきているsleepは時を止める関数です。パズルの動きに間を作るために使います。

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

tkinterとは

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

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

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

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

コードを見るまではピンとはこないかも知れませんが、とてもシンプルで扱いやすいライブラリです。早速コードを見て使い方を確認してみましょう。

コード

それではどんなコードを書いたのか紹介します。コード内に処理内容と意図をコメントにて記載しており、分かりにくい箇所はコード例の下にて説明しています。

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)  # tk.Frameのコンストラクタの呼び出し。tkinterを利用するための諸々初期設定。
        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アプリが作れます。楽しいアプリたくさん作りましょう!

tkinterの詳細

tkinterについてもっと詳しく知りたい方は以下の記事を参考にしてください。

おすすめ本

Pythonでのゲームづくりに興味がある方におすすめの書籍は以下です。

著:廣瀬 豪
¥2,889 (2024/03/27 07:06時点 | Amazon調べ)

CUIのゲームづくりから始まって、この記事でも紹介した落ちものパズル、診断ゲームやRPGなど徐々にレベルの高いゲームの作成方法を教えてくれます。コードの説明も一行一行丁寧につけてくれているのでありがたいです。コードによるゲーム作成に興味がある方は一度ご確認ください。

開発知識Python

Posted by ラプラス