Python Wordleを解くGUIアプリ(pandas,PySimpleGUI使用)

巷で話題のWordleという単語当てゲームにはまっています。

Wordleとは


プレイヤーが6回の試行で推測する5文字の単語が毎日選ばれる。推測を行う毎に、各文字は緑、黄色、または灰色のいずれかの色にマークされる。緑は文字が正しく、かつ正しい位置にあることを示す。黄色は答えとなる単語が推測した文字を含むが正しい位置にないことを意味し、灰色は文字が答えの単語に含まれないことを意味する。




wikipediaより
https://ja.wikipedia.org/wiki/Wordle

この記事でやること

このWodleの答えを導くデスクトップアプリを実装していきます。

使用ライブラリ

pandas
PySimpleGUI


早速、実装に移っていきます。

答えの単語リストの入手

答えの単語リストですが、githubで公開されています。
https://github.com/alex1770/wordle/blob/main/wordlist_hidden

そのままメモ帳にコピペして、wordle.txtという名前で適当なディレクトリに保存します。

pandasで読み込む

次にwordle.txtと同ディレクトリにwordle_data.pyを作成します。
このファイルでとりあえずテキストをdataframe化してみましょう。

import pandas as pd


def generate_df_row(file_name):
  """dfの行を作成して返す"""
  with open(file_name, "r") as f:
    for word in f:
      word = word.strip()
      yield [word]


def create_df(file_name):
  """dfを作成"""
  cols = ['char_full']
  values = [row for row in generate_df_row(file_name)]
  return pd.DataFrame(values, columns=cols)


def job():
  answer_file = 'wordle.txt'
  df = create_df(answer_file)
  print(df)


if __name__ == '__main__':
  job()


実行すると単語リストが出力されます。

   char_full
0    aback
1    abase
2    abate
3    abbey
4    abbot
...    ...
2310   young
2311   youth
2312   zebra
2313   zesty
2314   zonal

[2315 rows x 1 columns]


Wordleでは文字の位置に対応したヒントが表示されます。
なので、後に絞り込みがしやすいように、事前に単語を一文字ずつに分解しておきましょう。
これは簡単で、以下のように編集します。

def generate_df_row(file_name):
  """dfの行を作成して返す"""
  with open(file_name, "r") as f:
    for word in f:
      word = word.strip()
      # 変更
      yield [word[0], word[1], word[2], word[3], word[4], word]


def create_df(file_name):
  """dfを作成"""
  # 変更
  cols = ['char_1', 'char_2', 'char_3', 'char_4', 'char_5', 'char_full']
  values = [row for row in generate_df_row(file_name)]
  return pd.DataFrame(values, columns=cols)


カラムを用意しておいて、generatorで一文字ずつ分解したwordを返すだけです。
実行すると、以下のようになります。

   char_1 char_2 char_3 char_4 char_5 char_full
0     a   b   a   c   k   aback
1     a   b   a   s   e   abase
2     a   b   a   t   e   abate
3     a   b   b   e   y   abbey
4     a   b   b   o   t   abbot
...   ...  ...  ...  ...  ...    ...
2310   y   o   u   n   g   young
2311   y   o   u   t   h   youth
2312   z   e   b   r   a   zebra
2313   z   e   s   t   y   zesty
2314   z   o   n   a   l   zonal

[2315 rows x 6 columns]


データを絞り込んでみる。


次にこのdfを使ってデータを絞り込んでみましょう。

全く含まれない文字

例えば、abcの3文字が含まれないものを絞るにはこうします。

def job():
  answer_file = 'wordle.txt'
  df = create_df(answer_file)

  # a,b,cの3文字が含まれない単語
  ignore_chars = 'abc'
  for char in ignore_chars:
    df = df[~df['char_full'].str.contains(char)]
  print(df)


str.containsメソッドを使うと、文字列が含まれる場合にTrueとなります。
最初に`~`を付けると、TrueとFalseが反転します。

df = df[~df['char_full'].str.contains(char)]


なので、上記のようにすると、特定の文字が含まれないdfを返すことができます。
993文字まで絞り込まれました。

   char_1 char_2 char_3 char_4 char_5 char_full
532    d   e   f   e   r   defer
533    d   e   i   g   n   deign
534    d   e   i   t   y   deity
537    d   e   l   v   e   delve
538    d   e   m   o   n   demon
...   ...  ...  ...  ...  ...    ...
2305   w   r   y   l   y   wryly
2309   y   i   e   l   d   yield
2310   y   o   u   n   g   young
2311   y   o   u   t   h   youth
2313   z   e   s   t   y   zesty

[993 rows x 6 columns]


含まれている文字

次に含まれている文字を絞りこんでいきます。
ここは二つの条件に付いて考えないといけません。
char_fullに含まれている、そして特定の位置に含まれていない、ということです。
例えば、dを1列目に打ち込んだ場合で絞り込むにはこうします。

# 含まれている文字の絞り込み
  char = 'd'
  # 全文字の中に含まれているの絞り込み
  df = df[df['char_full'].str.contains(char)]
  # 特定の位置に含まれていないの絞り込み
  pos = 1
  col_name = f'char_{pos}'
  df = df[df[col_name] != char]
  print(df)


   char_1 char_2 char_3 char_4 char_5 char_full
633    e   d   i   f   y   edify
641    e   l   d   e   r   elder
645    e   l   i   d   e   elide
648    e   l   u   d   e   elude
655    e   n   d   o   w   endow
...   ...  ...  ...  ...  ...    ...
2285   w   o   r   d   y   wordy
2286   w   o   r   l   d   world
2291   w   o   u   l   d   would
2292   w   o   u   n   d   wound
2309   y   i   e   l   d   yield

[126 rows x 6 columns]


位置が正しいの絞り込み

これも同じ要領で位置と文字を指定します。
2列目にnを含んでいる文字を絞り込みましょう。

# 位置が合っている文字の絞り込み
  char = 'n'
  pos = 2
  col_name = f'char_{pos}'
  df = df[df[col_name] == char]
  print(df)


   char_1 char_2 char_3 char_4 char_5 char_full
655    e   n   d   o   w   endow
1031   i   n   d   e   x   index
1080   k   n   e   e   d   kneed
1823   s   n   i   d   e   snide
2153   u   n   d   e   r   under
2154   u   n   d   i   d   undid
2155   u   n   d   u   e   undue
2156   u   n   f   e   d   unfed
2167   u   n   w   e   d   unwed


以上のようにして、3つの条件を使ってDataFrameで絞り込んでみました。

GUIで実装する


ソースコードの変数をいちいち変えたりするのは少し面倒です。
なので、GUIで簡単に操作できるようにしていきましょう。

同ディレクトリにsolver_gui.pyを作成します。

まずはアプリの外郭を作ります。
PySimpleGUIで苦戦する部分はレイアウトの設定だと思っています。
個人的には、できるだけColumnやFrameを細かく分解することで、見通しが良くなると思います。

import PySimpleGUI as sg
from wordle_data import create_df


class Frontend:
  """GUIの見た目"""
  window_size = (800, 500)
  frame_left_size = (550, 500)
  frame_right_size = (240, 500)

  def ignore_input(self):
    """含まれていない文字を入力"""
    layout = [[]]
    return sg.Column(layout=layout,
             justification='c')

  def green_input(self):
    """位置が合っている文字を入力"""
    layout = [[]]
    return sg.Column(layout=layout,
             justification='c')

  def orange_input(self):
    """含まれているけど位置が合っていない文字を入力"""
    layout = [[]]
    return sg.Column(layout=layout,
             justification='c')

  def control(self):
    """guiコントロール"""
    layout = [[]]
    return sg.Column(layout=layout,
             justification='c')

  def guess_answer(self):
    """絞り込まれた答えの候補を表示"""
    layout = [[]]
    return sg.Column(layout=layout, justification='c')

  def frame_left(self):
    """左側フレーム"""
    layout = [[]]
    return sg.Frame('',
            layout=layout,
            vertical_alignment='c',
            size=self.frame_left_size)

  def frame_right(self):
    """右側フレーム"""
    layout = [[]]
    return sg.Frame('',
            layout=layout,
            vertical_alignment='c',
            size=self.frame_right_size)

  def layout(self):
    """レイアウト"""
    layout = [
      [self.frame_left(), self.frame_right()]
    ]
    return layout

  def window(self):
    """ウィンドウ"""
    layout = self.layout()
    window_title = 'PySimpleGUI Wordle Solver'
    window_size = self.window_size
    return sg.Window(title=window_title,
             layout=layout,
             size=window_size,
             finalize=True)


class Solver:
  """solverのロジック"""

  def __init__(self, file_name):
    self.file_name = file_name
    self.df = None
    self.window = Frontend().window()

  def set_df(self):
    self.df = create_df(self.file_name)

  def filter_ignore(self):
    """含まれていないの絞り込み"""
    pass

  def filter_green(self):
    """位置が合っているの絞り込み"""
    pass

  def filter_orange(self):
    """含まれているの絞り込み"""
    pass

  def output_guess(self):
    """答えの候補の出力"""
    pass

  def start_solver(self):
    """スタート"""
    # dfをセット
    self.set_df()

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

      if event == sg.WIN_CLOSED:
        break


def job():
  answer_file_name = 'wordle.txt'
  solver = Solver(file_name=answer_file_name)
  solver.start_solver()


if __name__ == '__main__':
  job()


まずFrontendクラスですが、GUIアプリの見た目を定義して、windowを返すクラスです。
次にSolverクラスでwindowを実体化して、操作の補助をするようなイメージです。

これを起動させると、とりあえずフレームのみ表示されます。


Frontendの編集


次にFrontendの中身を作っていきます。

class Frontend:
  """GUIの見た目"""
  window_size = (800, 500)
  frame_left_size = (550, 500)
  frame_right_size = (240, 500)

  def ignore_input(self):
    """含まれていない文字を入力"""
    layout = [[sg.T('Ignore Chars')],
         [sg.InputText('', size=(50, 3), key='IGNORE')]]
    return sg.Column(layout=layout,
             justification='c')

  def green_input(self):
    """位置が合っている文字を入力"""
    size = (4, 1)
    widgets = []
    # 位置が合っているものは最大で5個
    for col in range(1, 6):
      key = f'GREEN-C{col}'
      widget = sg.InputText('',
                 key=key,
                 size=size,
                 justification='c',
                 background_color='green')
      # [widget,widget...]という一次元リストを作る
      widgets.append(widget)
    # 2次元にする
    layout = [widgets]

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

  def orange_input(self):
    """含まれているけど位置が合っていない文字を入力"""
    size = (4, 1)
    layout = []
    # ターンに応じて入力できるように縦6,横5マス
    for row in range(1, 7):
      widgets = []
      for col in range(1, 6):
        key = f'ORANGE-R{row}C{col}'
        widget = sg.InputText('',
                   key=key,
                   size=size,
                   justification='c',
                   background_color='orange')
        # [widget,widget...]という一次元リストを作る
        widgets.append(widget)
      # 一行ごとにlayoutに加えることで、縦に並ぶ
      layout.append(widgets)
    return sg.Column(layout=layout,
             justification='c')

  def control(self):
    """guiコントロール"""
    # 入力後の確定と、初期化するREFRESHボタン
    layout = [[sg.Button('ENTER'),
          sg.Button('REFRESH')]]
    return sg.Column(layout=layout,
             justification='c')

  def guess_answer(self):
    """絞り込まれた答えの候補を表示"""
    layout = [[sg.MLine('',
              size=(35, 20),
              key='GUESS')]]
    return sg.Column(layout=layout, justification='c')

  def frame_left(self):
    """左側フレーム"""
    layout = [[self.ignore_input()],
         [self.green_input(), self.orange_input()],
		 [self.control()]]

    return sg.Frame('',
            layout=layout,
            vertical_alignment='c',
            size=self.frame_left_size)

  def frame_right(self):
    """右側フレーム"""
    layout = [[self.guess_answer()]]
    return sg.Frame('',
            layout=layout,
            vertical_alignment='c',
            size=self.frame_right_size)

  def layout(self):
    """レイアウト"""
    layout = [
      [self.frame_left(), self.frame_right()]
    ]
    return layout

  def window(self):
    """ウィンドウ"""
    layout = self.layout()
    window_title = 'PySimpleGUI Wordle Solver'
    window_size = self.window_size
    return sg.Window(title=window_title,
             layout=layout,
             size=window_size,
             finalize=True)


このように表示されます。



緑とオレンジのインプットボックスの部分は共通化できそうなので、関数を切り出してすっきりさせます。
また、緑のマスが縦中央に配置されているのが、気持ち悪いです。上揃えします。

 # 追加
  def input_widget(self, key, bg_color):
    """入力マスのwidget"""
    size = (4, 1)
    return sg.InputText('',
              key=key,
              size=size,
              justification='c',
              background_color=bg_color)

  def green_input(self):
    """位置が合っている文字を入力"""
    widgets = []
    # 位置が合っているものは最大で5個
    for col in range(1, 6):
      key = f'GREEN-C{col}'
      widget = self.input_widget(key=key, bg_color='green')
      # [widget,widget...]という一次元リストを作る
      widgets.append(widget)
    # 2次元にする
    layout = [widgets]

    return sg.Column(layout=layout,
             justification='c',
             # 追加
             vertical_alignment='t')

  def orange_input(self):
    """含まれているけど位置が合っていない文字を入力"""
    layout = []
    # ターンに応じて入力できるように縦6,横5マス
    for row in range(1, 7):
      widgets = []
      for col in range(1, 6):
        key = f'ORANGE-R{row}C{col}'
        widget = self.input_widget(key=key, bg_color='orange')
        # [widget,widget...]という一次元リストを作る
        widgets.append(widget)
      # 一行ごとにlayoutに加えることで、縦に並ぶ
      layout.append(widgets)
    return sg.Column(layout=layout,
             justification='c')




componentごとにパーツ化すると、レイアウトの調整が楽になります。

Solverクラスの編集

次にロジックの部分を追記していきましょう。
まず、Frontendクラスで設定したkeyの値を確認してみましょう。
ついでにコメントでこれからやることを書いています。

  def start_solver(self):
    """スタート"""
    # dfをセット
    self.set_df()

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

      if event == sg.WIN_CLOSED:
        break

      # 絞り込む処理
      if event == 'ENTER':
        print(values)
        pass
        # 含まれない文字で絞り込む

        # 位置が合っている文字で絞り込む

        # 含まれている文字で絞り込む

        # 絞り込まれた結果を表示する

      # 初期化する処理
      if event == 'REFRESH':
        pass


valuesは以下のように出力されます。

{'IGNORE': '''GREEN-C1': '', 'GREEN-C2': '', 'GREEN-C3': '', 'GREEN-C4': '', 'GREEN-C5': '',
'ORANGE-R1C1': '', 'ORANGE-R1C2': '', 'ORANGE-R1C3': '', 'ORANGE-R1C4': '', 'ORANGE-R1C5': '', ...,
'GUESS': ''}


あとはEnterキーを押したタイミングで、絞り込んでいくだけです。
最初にpandasのみで絞り込んだ場合を参考にして、関数を追記していきましょう。

class Solver:
  """solverのロジック"""

  ...

  def filter_ignore(self, chars):
    """含まれていないの絞り込み"""
    for char in chars:
      self.df = self.df[~self.df['char_full'].str.contains(char)]

  def filter_green(self, col_name, char):
    """位置が合っているの絞り込み"""
    self.df = self.df[self.df[col_name] == char]

  def filter_orange(self, col_name, char):
    """含まれているの絞り込み"""
    self.df = self.df[self.df['char_full'].str.contains(char)]
    self.df = self.df[~self.df[col_name].str.contains(char)]

  def output_guess(self):
    """答えの候補の出力"""
    # 一旦消す
    self.window['GUESS'].update('')
    # dfから文字リストを取得
    words = list(self.df['char_full'].values)
    # あんまり多すぎても意味がないので、50単語表示
    words = words[:50]
    guess = ''
    for word in words:
      guess += word + '\n'
    self.window['GUESS'].update(guess)

  def start_solver(self):
    """スタート"""
    # dfをセット
    self.set_df()

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

      if event == sg.WIN_CLOSED:
        break

      # 絞り込む処理
      if event == 'ENTER':
        # 含まれない文字で絞り込む
        chars = values['IGNORE']
        if chars:
          self.filter_ignore(chars=chars)

        # 位置が合っている文字で絞り込む
        for c in range(1, 6):
          key = f'GREEN-C{c}'
          char = values[key]
          if char:
            col_name = f'char_{c}'
            self.filter_green(col_name=col_name, char=char)

        # 含まれている文字で絞り込む
        for r in range(1, 7):
          for c in range(1, 6):
            key = f'ORANGE-R{r}C{c}'
            char = values[key]
            if char:
              col_name = f'char_{c}'
              self.filter_orange(col_name=col_name, char=char)

        # 絞り込まれた結果を表示する
        self.output_guess()

      # 初期化する処理
      if event == 'REFRESH':
        self.set_df()


試しに先ほどのpandasで絞り込んだのと同じように入力していってみます

含まれない文字がabc



含まれる文字がdで1列目ではない



2列目がn


ソースコード全文

https://github.com/qlitre/pysimple-wordle-solver

TOPページ