Python PySimpleGUIで作るデスクトップゲーム(Wordle)

Pythonで大人気ゲームのWordleを遊べる簡易ゲームアプリを作ってみました。
PySimpleGUIを使ったデスクトップアプリでの実装です。

Wordleとは

ルールは知っている人も多いかと思いますが…
四角の中にアルファベットを入力していき、5文字の英単語を当てるゲームです。
5文字の英単語を入力するたびヒントが表示されていきます。

  • 位置が合っている文字は緑色
  • 位置が合っていないが、含まれているものは黄色
  • 全く含まれないものは灰色

チャンスは6回まで。このヒントを元に正解を目指します。




Wordle公式
https://www.powerlanguage.co.uk/wordle/

プログラミングの勉強も兼ねて、再現してみようと思いました。

ゲームイメージ

プログラムを起動させた後は、キーボードボタンを押して、単語を入力していきます。
単語ができたらENTERボタンを押します。



入力に応じて判定が走り、ヒントが表示されます。



単語リストにない単語が入力されたら、先にいけません。
ここも本家の仕様と同じです。



6回チャレンジして正解できない場合、ゲームオーバーとなり正解が表示されます。



正解するとコングラチュレーションです。



コード

長いです。

word.py

こちらはwordle.txtの単語データから、単語リストを返すだけのファイルです。

"""wordle.txtからwordリストを返す"""


def generate_word():
    with open('wordle.txt') as f:
        for word in f:
            yield word.strip().upper()


def get_word_list():
    return [word for word in generate_word()]

wordle.txtは以下のようにひたすら5文字の英単語が並んだテキストファイルです。
本家でリストが公開されているので、コピペしてます。
https://github.com/alex1770/wordle/blob/main/wordlist_hidden

aback
abase
abate
abbey
abbot
abhor
abide
...


wordle.py

Wordleのロジックをまとめているファイルです。
入力された単語の判定を主に行っています。

"""Wordleのロジックを定義するファイル"""

import random


class Wordle:
    """Wordleのロジック"""

    def __init__(self, word_list: list):
        self.word_list = word_list
        self.answer = None

    def set_answer(self):
        """answerをセットする"""
        max_index = len(self.word_list)
        index = random.randrange(start=0, stop=max_index)
        self.answer = self.word_list[index]

    def is_word_in_word_list(self, word: str):
        """単語がword listの中に存在していたらTrue"""
        return word in self.word_list

    def is_word_collect(self, word: str):
        """
        5文字の単語が答えに合っていればTrue
        """
        return word == self.answer

    def is_char_in_answer(self, char: str):
        """
        英語一文字が答えに含まれていたらTrue
        """
        return char in self.answer

    def is_char_right_position(self, char: str, pos: int):
        """
        英語一文字が答えと照らし合わせて位置が合っていたらTrue
        """
        return self.answer[pos] == char


frontend.py

こちらはGUIの見た目の定義をまとめています。

"""GUIの見た目を定義するファイル"""

import PySimpleGUI as sg


class Widget:
    """widgetを定義"""

    """スタイル"""

    # relief style
    relief_size = (30, 1)
    relief_font = ("Helvetica", 15)
    relief_text = "PysimpleGUI Wordle!"

    # input box style
    input_box_color = 'black'
    input_box_color_bg = 'white'
    input_box_size = (4, 1)

    # keyboard button style
    keyboard_btn_size = (4, 1)
    keyboard_btn_color = ('#FFFFFF', '#283b5b')

    # active row mark style
    active_row_mark_text = "●"
    active_row_mark_size = (4, 1)
    active_row_mark_font = ('', 10)

    # window style
    window_title = 'PySimpleGui Wordle'
    window_size = (800, 500)

    def widget_relief(self):
        """リリーフ"""
        return sg.T(text=self.relief_text,
                    size=self.relief_size,
                    justification='center',
                    font=self.relief_font,
                    relief=sg.RELIEF_RIDGE)

    def widget_active_row_mark(self, color: str, key: str):
        """現在の入力行を示すマーク"""
        return sg.T(text=self.active_row_mark_text,
                    size=self.active_row_mark_size,
                    text_color=color,
                    font=self.active_row_mark_font,
                    key=key)

    def widget_input_box(self, key: str, disabled: bool):
        """input box widget"""
        return sg.InputText('',
                            key=key,
                            size=self.input_box_size,
                            disabled=disabled,
                            text_color=self.input_box_color,
                            justification='c',
                            background_color=self.input_box_color_bg,
                            enable_events=True)

    def widget_keyboard_button(self, char: str, key: str):
        """キーボードボタンwidget"""
        return sg.Button(char,
                         key=key,
                         size=self.keyboard_btn_size,
                         button_color=self.keyboard_btn_color)

    @staticmethod
    def popup_does_not_exist(word: str):
        """単語リストにありませんpopup"""
        sg.popup(f'Sorry, "{word}" does not exist in my word list')

    @staticmethod
    def popup_game_over(answer: str):
        """ゲームオーバーpopup"""
        msg = f'Game Over!\nThe answer is {answer}\nDo it again?'
        return sg.popup_ok_cancel(msg)

    @staticmethod
    def popup_congratulation():
        """ゲームクリアpopup"""
        msg = 'Congratulation!\nDo it again?'
        return sg.popup_ok_cancel(msg)

    @staticmethod
    def popup_see_you_later():
        """see you later popup"""
        return sg.popup('See you later')


class GuiFrontEnd(Widget):
    """GUIの見た目を定義"""

    def input_box_widgets(self):
        """縦6 x 横5の入力マス"""
        layout = []

        for row in range(1, 7):
            disabled = False if row == 1 else True
            color_mark = 'green' if row == 1 else 'white'
            active_row_mark = self.widget_active_row_mark(color=color_mark,
                                                          key=f'row{row}')

            widgets = [active_row_mark]
            for col in range(1, 6):
                # keyはr1c1形式で記入
                key = f'r{row}c{col}'
                widget = self.widget_input_box(key=key, disabled=disabled)
                widgets.append(widget)

            layout.append(widgets)

        layout = sg.Column(layout=layout, justification='c')

        return layout

    def key_boards_widgets(self):
        """キーボードボタン"""
        layout = []
        keyboards = ['QWERTYUIOP', 'ASDFGHJKL', 'ZXCVBNM']
        for chars in keyboards:
            widgets = []
            for char in list(chars):
                widget = self.widget_keyboard_button(char=char, key=char)
                widgets.append(widget)
            widgets = sg.Column(layout=[widgets], justification='c')
            layout.append([widgets])

        return layout

    def layout(self):
        """レイアウトを返す"""

        col_relief = sg.Column([[self.widget_relief()]], justification='c')

        col_control = sg.Column(layout=[[sg.Button('ENTER'),
                                         sg.Button('BACK'),
                                         sg.Button('PREV'),
                                         sg.Button('NEXT'),
                                         sg.Button('CLEAR')]],
                                justification='c')
        layout = [[col_relief],
                  [self.input_box_widgets()],
                  [self.key_boards_widgets()],
                  [col_control]]

        return layout

    def window(self):
        """ウィンドウを返す"""
        window = sg.Window(title=self.window_title,
                           layout=self.layout(),
                           size=self.window_size,
                           finalize=True)
        return window


game.py

実際のゲーム進行と付随するロジックをまとめています。

"""実際にゲームするときに使うファイル"""

import PySimpleGUI as sg
import words
from wordle import Wordle
from frontend import GuiFrontEnd


class Game:
    """ゲームの進行とロジック"""

    def __init__(self, word_list: list):
        self.frontend = GuiFrontEnd()
        self.window = self.frontend.window()
        self.wordle = Wordle(word_list)
        # 入力可能行の制御
        self.turn = 1

    @staticmethod
    def get_input_widget_key():
        """入力widgetのキーをyieldして返す"""
        for r in range(1, 7):
            for c in range(1, 6):
                key = f'r{r}c{c}'
                yield key

    @staticmethod
    def get_keyboard_key():
        """キーボードwidgetのキーをyieldして返す"""
        chars = "QWERTYUIOPASDFGHJKLZXCVBNM"
        for c in chars:
            yield c

    def filter_values_by_turn(self, values: dict):
        """ターンに応じてvaluesをフィルタ"""
        return dict(filter(lambda x: f'r{self.turn}' in x[0], values.items()))

    def get_word_by_5chars(self, values: dict):
        """5文字の英単語を作成する"""
        row = self.filter_values_by_turn(values)
        word = ''
        for char in row.values():
            word += char
        return word

    def set_focus_next(self, key: str):
        """次のキーにフォーカスする"""
        row_num = int(key[1])
        col_num = int(key[-1])
        # 最後の場合は最初に戻る
        if col_num == 5:
            next_key = f'r{row_num}c1'
        else:
            next_key = f'r{row_num}c{col_num + 1}'
        self.window[next_key].SetFocus()

    def set_focus_prev(self, key: str):
        """前のフォーカスのキーを返す"""
        row_num = int(key[1])
        col_num = int(key[-1])
        # 最初の場合は最後に送る
        if col_num == 1:
            prev_key = f'r{row_num}c5'
        else:
            prev_key = f'r{row_num}c{col_num - 1}'
        self.window[prev_key].SetFocus()

    def clear_row(self, values: dict):
        """入力されている行をクリア"""
        row = self.filter_values_by_turn(values)
        for key in row.keys():
            self.window[key].update('')

        self.window[f'r{self.turn}c1'].SetFocus()

    def update_active_row_mark_color(self):
        """入力可能行表示マークの色をアップデート"""
        for row_num in range(1, 7):
            color = 'white'
            if row_num == self.turn:
                color = 'green'
            elif row_num < self.turn:
                color = 'gray'
            key = f'row{row_num}'
            self.window[key].update(text_color=color)

    def update_widget_bg_color(self, values: dict):
        """答えが入力された後のウィジェットの色を変える処理"""
        row = self.filter_values_by_turn(values)
        for i, char in enumerate(row.values()):
            col_num = i + 1
            input_key = f'r{self.turn}c{col_num}'
            pos = i
            if self.wordle.is_char_right_position(char=char,
                                                  pos=pos):
                bg_color = 'green'
            elif self.wordle.is_char_in_answer(char):
                bg_color = 'orange'
            else:
                bg_color = 'gray'

            self.window[input_key].Update(text_color='white',
                                          background_color=bg_color)

            # キーボードは既にgreenのものが上書きされないようにする
            c = self.window[char].ButtonColor
            if 'green' not in c:
                self.window[char].Update(button_color=('white', bg_color))

    def goto_next_turn(self, values: dict):
        """次のターンに移行"""
        self.turn = self.turn + 1
        row = self.filter_values_by_turn(values)
        self.update_active_row_mark_color()
        for key in row.keys():
            self.window[key].Update(disabled=False)
        self.window[f'r{self.turn}c1'].set_focus()

    def refresh_game(self):
        """ゲームを初期化する"""

        # 入力マスを初期化
        for key in self.get_input_widget_key():
            disabled = False if 'r1' in key else True
            self.window[key].update('',
                                    text_color=self.frontend.input_box_color,
                                    background_color=self.frontend.input_box_color_bg,
                                    disabled=disabled)
        # キーボードボタンを初期化
        for key in self.get_keyboard_key():
            self.window[key].update(button_color=self.frontend.keyboard_btn_color)

        # リフレッシュ
        self.window.refresh()
        # 答えを新しくセット
        self.wordle.set_answer()
        # ターンを戻す
        self.turn = 1
        # 入力可能行マークを更新
        self.update_active_row_mark_color()
        # フォーカスを一番最初に
        self.window['r1c1'].SetFocus()

    def start_game(self):
        """ゲームスタート"""
        # wordleに答えをセット
        self.wordle.set_answer()
        keyboards_events = list(self.get_keyboard_key())
        input_box_event = list(self.get_input_widget_key())

        while True:
            event, values = self.window.read()

            if event == sg.WIN_CLOSED:
                break

            # inputボックスのフォーカスされているマス
            focus_input = self.window.find_element_with_focus()

            # 小文字で入力された場合、大文字に変換
            if event in input_box_event:
                char = values[event]
                self.window[event].update(char.upper())

            if event in keyboards_events:
                # 違うターンのマスは入力できない
                if f'r{self.turn}' not in focus_input.Key:
                    continue

                focus_input.update(event)
                self.set_focus_next(key=focus_input.Key)

            # フォーカスされているマスを消去
            if event == 'BACK':
                focus_input.update('')

            # 前のマスに移動
            if event == 'PREV':
                self.set_focus_prev(key=focus_input.Key)

            # 次のマスに移動
            if event == 'NEXT':
                self.set_focus_next(key=focus_input.Key)

            # 入力されている行をクリア
            if event == 'CLEAR':
                self.clear_row(values)

            if event == 'ENTER':
                # 入力値を5文字の英単語に
                word = self.get_word_by_5chars(values=values)

                # 5文字入力されているかの確認
                if len(word) != 5:
                    continue

                # 入力値が単語リストに存在しなかったら次にいけない
                if not self.wordle.is_word_in_word_list(word=word):
                    self.frontend.popup_does_not_exist(word=word)
                    continue

                # widgetの背景を更新
                self.update_widget_bg_color(values=values)

                # wordが正解していたらゲーム終了
                if self.wordle.is_word_collect(word=word):
                    res = self.frontend.popup_congratulation()
                    if res == 'OK':
                        self.refresh_game()
                        continue
                    else:
                        self.frontend.popup_see_you_later()
                        break

                # 正解していなくて既に6ターン目だったらゲームオーバー
                if self.turn == 6:
                    res = self.frontend.popup_game_over(answer=self.wordle.answer)
                    if res == 'OK':
                        self.refresh_game()
                        continue
                    else:
                        self.frontend.popup_see_you_later()
                        break

                # 次のターンに移動
                self.goto_next_turn(values=values)


def job():
    # 単語リストを取得
    word_list = words.get_word_list()
    game = Game(word_list)
    # ゲーム開始
    game.start_game()


if __name__ == '__main__':
    job()


ソースコード全文

最新版をgithubに公開しています。
https://github.com/qlitre/pysimplegui-wordle

遊び方

適当なディレクトリでgit cloneします。

git clone https://github.com/qlitre/pysimplegui-wordle/


仮想環境を作成してライブラリをインストールします。

cd pysimplegui-wordle
python -m venv myvenv
myvenv\scripts\activate
pip install -r requirements.txt


後はgame.pyを起動します。

game.py


おわりに

Wordleはシンプルなゲームですので、プログラミングの題材にとても良いと思いました。
本家のWordleは一日に一回しか遊べないので、練習も兼ねていかがでしょうか。

TOPページ