LoginSignup
0
0

Pythonで〇×ゲームのAIを一から作成する その71 GUI による AI との対戦

Last updated at Posted at 2024-04-11

目次と前回の記事

これまでに作成したモジュール

以下のリンクから、これまでに作成したモジュールを見ることができます。

ルールベースの AI の一覧

ルールベースの AI の一覧については、下記の記事を参照して下さい。

GUI での AI との対戦

前回の記事では、人間どうしGUI〇×ゲーム遊べる ようにしました。

今回の記事では GUIAI の対戦できるよう にします。

AI の対戦 は、下記の 3 種類 があります。なお、下記 の表記は、VS前に記述 したものが 〇 を担当 するという 意味 を表します。それぞれ について 順番に実装 を行います。

  • AI VS AI
  • 人間 VS AI
  • AI VS 人間

AI VS AI の対戦

まず、下記 のプログラムのように、現在play メソッドで、実引数gui=True記述 して、AI どうし対戦 を行ってみることにします。下記 は、ai2 VS ai2対戦 です。

%matplotlib widget
from marubatsu import Marubatsu
from ai import ai2

mb = Marubatsu()
mb.play(ai=[ai2, ai2], gui=True)

実行結果

実行結果 から、ゲーム盤表示されます が、AI の着手行われない ことがわかります。また、ゲーム盤マスの上マウスを押す と、着手 を行うことが できてしまう ことから、AI どうし対戦 を行ったにも 関わらず人間どうし対戦行われてしまう という 問題が発生 しています。このようなことがおきる理由ついて少し考えてみて下さい。

問題の検証

play メソッドは、下記 のプログラムの 8 行目 のように、guiTrue の場合 は、手番を人間と AI のどちらが担当していた場合でも、ゲーム盤の画像描画した後 で、return 文実行 して play メソッドの 処理を終了 します。また、その後着手の処理 は、画像の上マウス押された 際に 実行 される、on_mouse_down によって 行われる ので、人間着手を行う ことが できてしまいます。これが 上記の問題 が発生する 原因 です。

 1  def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
中略
 2          # ゲームの決着がついていない間繰り返す
 3          while self.status == Marubatsu.PLAYING:
 4              # ゲーム盤の表示
 5              if verbose:
 6                  if gui:
 7                      self.draw_board(ax)
 8                      return
 9                  else:
10                      print(self)
11              # 現在の手番を表す ai のインデックスを計算する
12              index = 0 if self.turn == Marubatsu.CIRCLE else 1
13              # ai が着手を行うかどうかを判定する
14              if ai[index] is not None:
15                  x, y = ai[index](self, **params[index])
以下略                    

下記は、前回の記事で紹介した、guiTrue代入 されていた場合の、play メソッドの フローチャート です。図から、play メソッドでは、AI〇 の手番担当する場合 でも、図の中 にある「AI着手を選択する」という 処理 が、実行されない ことが わかります

問題の修正

AI どうし対戦 の場合は、すべての着手AI が行う ので、on_mousedown による、GUI入力の処理必要ありません。従って、人間が着手行う場合 のように、play メソッドを 終了する必要ありません

また、AI着手を行う処理 は、上記 のプログラムの 15 行目 で行われます。従って、AI着手を行う 場合は、上記8 行目return 文実行しない ようにすれば、15 行目実行 されて AI着手を行う ようになります。

下記 は、そのように play メソッドを 修正 したプログラムです。

  • 8 行目:この後の 13 行目index利用 する 必要がある ので、17 行目の前 にあった、現在の手番 を表す aiインデックス計算 する 処理ここに移動 する
  • 13 行目現在の手番人間の場合return 文実行 して play メソッドを 終了 する
 1  import matplotlib.pyplot as plt
 2  import math
 3
 4  def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
元と同じなので省略
 5      # ゲームの決着がついていない間繰り返す
 6      while self.status == Marubatsu.PLAYING:
 7          # 現在の手番を表す ai のインデックスを計算する
 8          index = 0 if self.turn == Marubatsu.CIRCLE else 1
 9          # ゲーム盤の表示
10          if verbose:
11              if gui:
12                  self.draw_board(ax)
13                  if ai[index] is None:
14                      return
15              else:
16                  print(self)
17          # ai が着手を行うかどうかを判定する
18          if ai[index] is not None:
19              x, y = ai[index](self, **params[index])
元と同じなので省略
20    
21  Marubatsu.play = play
行番号のないプログラム
import matplotlib.pyplot as plt
import math

def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
    # seed が None でない場合は、seed を乱数の種として設定する
    if seed is not None:
        random.seed(seed)
    
    # 〇×ゲームを再起動する
    self.restart()

    # gui が True の場合に、ゲーム盤を描画する画像を作成し、イベントハンドラに結びつける
    if gui:
        fig, ax = plt.subplots(figsize=[size, size])
        fig.canvas.toolbar_visible = False
        fig.canvas.header_visible = False
        fig.canvas.footer_visible = False
        fig.canvas.resizable = False          
        
        # ローカル関数としてイベントハンドラを定義する
        def on_mouse_down(event):
            # Axes の上でマウスを押していた場合のみ処理を行う
            if event.inaxes and self.status == Marubatsu.PLAYING:
                x = math.floor(event.xdata)
                y = math.floor(event.ydata)
                self.move(x, y)                
                self.draw_board(ax)
        
        # fig の画像にマウスを押した際のイベントハンドラを結び付ける
        fig.canvas.mpl_connect("button_press_event", on_mouse_down)                   

    # ゲームの決着がついていない間繰り返す
    while self.status == Marubatsu.PLAYING:
        # 現在の手番を表す ai のインデックスを計算する
        index = 0 if self.turn == Marubatsu.CIRCLE else 1
        # ゲーム盤の表示
        if verbose:
            if gui:
                self.draw_board(ax)
                if ai[index] is None:
                    return
            else:
                print(self)
        # ai が着手を行うかどうかを判定する
        if ai[index] is not None:
            x, y = ai[index](self, **params[index])
        else:
            # キーボードからの座標の入力
            coord = input("x,y の形式で座標を入力して下さい。exit を入力すると終了します")
            # "exit" が入力されていればメッセージを表示して関数を終了する
            if coord == "exit":
                print("ゲームを終了します")
                return       
            # x 座標と y 座標を要素として持つ list を計算する
            xylist = coord.split(",")
            # xylist の要素の数が 2 ではない場合
            if len(xylist) != 2:
                # エラーメッセージを表示する
                print("x, y の形式ではありません")
                # 残りの while 文のブロックを実行せずに、次の繰り返し処理を行う
                continue
            x, y = xylist
        # (x, y) に着手を行う
        try:
            self.move(int(x), int(y))
        except:
            print("整数の座標を入力して下さい")

    # 決着がついたので、ゲーム盤を表示する
    if verbose:
        if gui:
            self.draw_board(ax)
        else:
            print(self)
            
    return self.status
    
Marubatsu.play = play
修正箇所
import matplotlib.pyplot as plt
import math

def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
元と同じなので省略
    # ゲームの決着がついていない間繰り返す
    while self.status == Marubatsu.PLAYING:
        # 現在の手番を表す ai のインデックスを計算する
+       index = 0 if self.turn == Marubatsu.CIRCLE else 1
        # ゲーム盤の表示
        if verbose:
            if gui:
                self.draw_board(ax)
-               return
+               if ai[index] is None:
+                   return
            else:
                print(self)
-       index = 0 if self.turn == Marubatsu.CIRCLE else 1
        # ai が着手を行うかどうかを判定する
        if ai[index] is not None:
            x, y = ai[index](self, **params[index])
元と同じなので省略
    
Marubatsu.play = play

上記修正後 に、下記 のプログラムで play メソッドを 実行 すると、実行結果 のように、AI どうし対戦 が行われ、その 結果の画像表示 されます。また、下記のセル何度実行し直し て、毎回異なる対戦結果表示 されることを 確認 してみて下さい。

mb.play(ai=[ai2, ai2], gui=True)

実行結果(実行結果はランダムなので下記とは異なる場合があります)

AI どうしの対戦で行われる処理のフローチャート

下図左 は、guiTrue の場合に、AI どうし対戦 が行われる際の、play メソッドの フローチャート です。下図右 の、前回の記事で紹介した、guiFalse の場合に行われる フロー駆動型play メソッドの フローチャート見比べて 下さい。

Figure に関する処理 などの 細かい部分いくつかの違いありますが、行われる 処理の流れほぼ同じ であることを 確認して下さい。従って、AI どうし対戦 では、フロー処理型 の場合と 同様 に、決着がつくまで処理play メソッドで 行います

ところで、上図左フローチャート から わかる ように、guiTrue代入 されている場合は、play メソッドは イベントハンドラ登録 するので、play メソッドの 終了後 も、ゲーム盤の上マウスを押す と、登録 した イベントハンドライベントループ から 呼び出されます。そのため、play メソッドを 実行後ゲーム盤の上マウスを押す と、着手行われてしまう のではないかと 思う人いるかもしれません

実際 に、play メソッドの 実行後ゲーム盤の上マウスを押すイベントハンドラ実行 されますが、前回の記事で示した 下記フローチャート のように、ゲーム決着後着手を禁止 する 処理イベントハンドラ記述 したので、着手行われません

無駄な描画の処理を行わないようにする

下図 は、guiTrue代入 されていた場合に、play メソッドで AI どうし対戦行った場合フローチャート一部 です(繰り返し前の部分省略 しています)。

黄色い長方形 が、draw_boardゲーム盤の画像描画を更新 する処理ですが、図から わかるように、この処理は、以下タイミング何度も実行 されます。

  • 繰り返し の処理の で、決着がつくまでそれぞれの局面描画 する
  • 繰り返し の処理の で、決着がついた局面描画 する

以前の記事 で説明したように、matplotlib画像描画の更新 は、JupyterLabセル のプログラムの 実行終了するまで行われない ので、JupyterLab 上の 画像 には、AI どうし対戦途中経過描画されず決着後の局面ゲーム盤のみ描画 されます。

上記のことから、AI どうし対戦 の場合は、対戦の途中経過描画 するために呼び出した draw_board メソッドの 処理無駄な処理 であるということが わかります〇×ゲーム のような、最大 でも 9 手ゲームが終了 するようなゲームの場合は、決着ついていない間draw_board呼び出し最大 でも 9 回 しか行われないので、play メソッドの 処理が遅い感じる ことは ありません が、ゲーム決着がつくまで にもっと 長い手数がかかる ようなゲームの場合は、無駄draw_board呼び出し によって、ゲーム決着がつくまで にかかる 時間長く感じられる ような 場合生じる可能性 があります。

そこで、下記 のプログラムのように、AI どうし対戦 で、ゲーム決着がついていない 場合は draw_board メソッドを 呼び出さない ように 修正 することにします。

  • 10 行目:「AI どうし対戦 の場合は 画像描画しない」ということは、「どちらか人間 である場合に 画像を描画 する」ということなので、その条件式記述 する
 1  def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
元と同じなので省略          
 2      # ゲームの決着がついていない間繰り返す
 3      while self.status == Marubatsu.PLAYING:
 4          # 現在の手番を表す ai のインデックスを計算する
 5          index = 0 if self.turn == Marubatsu.CIRCLE else 1
 6          # ゲーム盤の表示
 7          if verbose:
 8              if gui:
 9                  # AI どうしの対戦の場合は画面を描画しない
10                  if ai[0] is None or ai[1] is None:
11                      self.draw_board(ax)
12                  # 手番を人間が担当する場合は、play メソッドを終了する
13                  if ai[index] is None:
14                      return
15              else:
16                  print(self)
元と同じなので省略          
17    
18  Marubatsu.play = play
行番号のないプログラム
def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
    # seed が None でない場合は、seed を乱数の種として設定する
    if seed is not None:
        random.seed(seed)
    
    # 〇×ゲームを再起動する
    self.restart()

    # gui が True の場合に、ゲーム盤を描画する画像を作成し、イベントハンドラに結びつける
    if gui:
        fig, ax = plt.subplots(figsize=[size, size])
        fig.canvas.toolbar_visible = False
        fig.canvas.header_visible = False
        fig.canvas.footer_visible = False
        fig.canvas.resizable = False          
        
        # ローカル関数としてイベントハンドラを定義する
        def on_mouse_down(event):
            # Axes の上でマウスを押していた場合のみ処理を行う
            if event.inaxes and self.status == Marubatsu.PLAYING:
                x = math.floor(event.xdata)
                y = math.floor(event.ydata)
                self.move(x, y)                
                self.draw_board(ax)
        
        # fig の画像にマウスを押した際のイベントハンドラを結び付ける
        fig.canvas.mpl_connect("button_press_event", on_mouse_down)                   

    # ゲームの決着がついていない間繰り返す
    while self.status == Marubatsu.PLAYING:
        # 現在の手番を表す ai のインデックスを計算する
        index = 0 if self.turn == Marubatsu.CIRCLE else 1
        # ゲーム盤の表示
        if verbose:
            if gui:
                # AI どうしの対戦の場合は画面を描画しない
                if ai[0] is None or ai[1] is None:
                    self.draw_board(ax)
                # 手番を人間が担当する場合は、play メソッドを終了する
                if ai[index] is None:
                    return
            else:
                print(self)
        # ai が着手を行うかどうかを判定する
        if ai[index] is not None:
            x, y = ai[index](self, **params[index])
        else:
            # キーボードからの座標の入力
            coord = input("x,y の形式で座標を入力して下さい。exit を入力すると終了します")
            # "exit" が入力されていればメッセージを表示して関数を終了する
            if coord == "exit":
                print("ゲームを終了します")
                return       
            # x 座標と y 座標を要素として持つ list を計算する
            xylist = coord.split(",")
            # xylist の要素の数が 2 ではない場合
            if len(xylist) != 2:
                # エラーメッセージを表示する
                print("x, y の形式ではありません")
                # 残りの while 文のブロックを実行せずに、次の繰り返し処理を行う
                continue
            x, y = xylist
        # (x, y) に着手を行う
        try:
            self.move(int(x), int(y))
        except:
            print("整数の座標を入力して下さい")

    # 決着がついたので、ゲーム盤を表示する
    if verbose:
        if gui:
            self.draw_board(ax)
        else:
            print(self)
            
    return self.status
    
Marubatsu.play = play
修正箇所
def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
元と同じなので省略          
    # ゲームの決着がついていない間繰り返す
    while self.status == Marubatsu.PLAYING:
        # 現在の手番を表す ai のインデックスを計算する
        index = 0 if self.turn == Marubatsu.CIRCLE else 1
        # ゲーム盤の表示
        if verbose:
            if gui:
                # AI どうしの対戦の場合は画面を描画しない
-               self.draw_board(ax)
+               if ai[0] is None or ai[1] is None:
+                   self.draw_board(ax)
                # 手番を人間が担当する場合は、play メソッドを終了する
                if ai[index] is None:
                    return
            else:
                print(self)
元と同じなので省略          
    
Marubatsu.play = play

10 行目 で、「AI どうし対戦 の場合は 画像描画しない」という 条件式記述しない理由 は、その条件式 は、下記 のような 複雑な条件式 になるからです。

if not((ai[0] is not None) and (ai[1] is not None)):
    self.draw_board(ax)

実行結果は省略しますが、上記修正後 に、下記 のプログラムで play メソッドを 実行 すると、AI どうし対戦 が行われ、その 結果の画像表示 されることが 確認 できます。

mb.play(ai=[ai2, ai2], gui=True)

上記の修正 は、処理無駄を省く ための修正なので、行わなくても プログラムは 正しく動作 します。また、この処理フローチャートに記述 すると、フローチャートが 複雑 になって わかりづらくなる ので、以後の play メソッドの フローチャート を図で示す際に、この処理省略 することにします。

なお、上記の修正 を行うことで、AI どうし対戦 を行った際の play メソッドの 処理速度実際短く なりますが、先程説明したように、〇×ゲーム の場合は、決着がつくまで手数が短い ので、処理速度の差人間が体感 することは できません

以前の記事プログラムのテスト の記事で説明したように、プログラムの修正 を行った場合は、新しい処理正しく動作する ことを 確認 する だけでなくこれまで動作していた処理 が、引き続き 正しく動作するか どうかを 確認したほうが良い でしょう。そこで、下記 のプログラムで play メソッドを 実行 して、人間 どうし対戦 を行う場合も、問題なく 対戦が 行える ことを 実際に確認 してみて下さい。

mb.play(ai=[None, None], gui=True)

人間 VS AI の対戦

先程と同様 に、play メソッドの 実引数gui=True記述 して、下記 のプログラムのように、人間 VS AI対戦行ってみる ことにします。下記は、人間 VS ai2対戦 です。

mb.play(ai=[None, ai2], gui=True)

実行結果

実行結果 のように、ゲーム盤が表示 されるので、好きなマス の上で マウスを押す とそのマスに 着手行われます下記 は、(0, 0) のマスの上で マウス押した場合 です。

上図 のように、人間着手行った後 で、AI の着手行われません。また、上図 でゲーム盤の マスの上マウスを押す と、着手行うことできてしまう ため、人間 VS AI対戦行おうとした のにも 関わらず人間どうし対戦行われてしまう という 問題が発生 しています。何故このようなことになるかについて少し考えてみて下さい。

問題の検証

人間 VS AI対戦 の場合は、guiTrue なので、play メソッドは、下記 のプログラムの 8 行目 のように、ゲーム盤の画像描画した後 で、return 文実行 して play メソッドの 処理を終了 します。ここまでの処理問題ありません

 1  def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
中略
 2          # ゲームの決着がついていない間繰り返す
 3          while self.status == Marubatsu.PLAYING:
 4              # ゲーム盤の表示
 5              if verbose:
 6                  if gui:
 7                      self.draw_board(ax)
 8                      return
 9                  else:
10                      print(self)
以下略                    

下図 は、guiTrue の場合に、人間 VS AI対戦 が行われる際の、play メソッドの フローチャート です。赤線の処理 が行われ、play メソッドの 処理が終了 します。

その後 で、ゲーム盤マスの上マウスを押す と、下記on_mouse_down実行 されて 着手を行う処理実行 されます。

        def on_mouse_down(event):
            # Axes の上でマウスを押していた場合のみ処理を行う
            if event.inaxes and self.status == Marubatsu.PLAYING:
                x = math.floor(event.xdata)
                y = math.floor(event.ydata)
                self.move(x, y)                
                self.draw_board(ax)

問題 は、上記on_mouse_down人間着手を行う処理行った後 で、AI着手を行う処理 がどこにも 記述されていない 点です。また、on_mouse_down処理行った後 で、ゲーム盤マスの上マウスを押す と、再び on_mouse_down実行される ため、AI の手番 であるにも 関わらず人間着手を行う ことが できてしまいます

そのことは、下記on_mouse_downフローチャート からも 確認 できます。

この問題 は、on_mouse_down着手の処理行った後 で、「次の手番AI が担当 する場合は その AI着手を選択 する」という 処理を記述 することで 解決 することが できます。どのようにその処理を記述すれば良いかについて少し考えてみて下さい。

問題を修正したフローチャート

下図 は、上記の処理を行 うように 修正 した on_mouse_downフローチャート です。

2024/04/14 修正:フローチャート画像を描画する処理入っていませんでした が、入れたほうが良いと思いましたので、入れるよう修正 しました。

このように修正 することで、人間ゲーム盤画像の上マウス押すたびon_mouse_down呼び出され、「人間の着手を行う」と「AI の着手を行う」の 処理続けて行われる ようになるため、人間 VS AI対戦行われる ようになります。

問題の修正

それぞれの手番人間AIどちらが担当するか を表す 情報 は、play メソッドの 仮引数 ai代入 されています。on_mouse_downplay メソッドの ローカル関数 なので、その ブロックの中aiそのまま利用 することが できます。従って、下記 のプログラムのように on_mouse_down修正 することで、上記の 問題を解決 できます。

  • 13 行目:マウスによる 着手行われた後手番aiインデックス計算 する
  • 14 行目AI手番であるか どうかを 判定 する
  • 15 ~ 17 行目AI の着手計算 し、着手を行いゲーム盤の画像更新 する
 1  def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
元と同じなので省略
 2          # ローカル関数としてイベントハンドラを定義する
 3          def on_mouse_down(event):
 4              # Axes の上でマウスを押していた場合のみ処理を行う
 5              if event.inaxes and self.status == Marubatsu.PLAYING:
 6                  x = math.floor(event.xdata)
 7                  y = math.floor(event.ydata)
 8                  self.move(x, y)                
 9                  self.draw_board(ax)
10
11                  # 現在の手番を表す ai のインデックスを計算する
12                  index = 0 if self.turn == Marubatsu.CIRCLE else 1
13                  # ai が着手を行うかどうかを判定する
14                  if ai[index] is not None:                
15                      x, y = ai[index](self, **params[index])
16                      self.move(x, y) 
17                      self.draw_board(ax)
元と同じなので省略
18    
19  Marubatsu.play = play
行番号のないプログラム
def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
    # seed が None でない場合は、seed を乱数の種として設定する
    if seed is not None:
        random.seed(seed)
    
    # 〇×ゲームを再起動する
    self.restart()

    # gui が True の場合に、ゲーム盤を描画する画像を作成し、イベントハンドラに結びつける
    if gui:
        fig, ax = plt.subplots(figsize=[size, size])
        fig.canvas.toolbar_visible = False
        fig.canvas.header_visible = False
        fig.canvas.footer_visible = False
        fig.canvas.resizable = False          
        
        # ローカル関数としてイベントハンドラを定義する
        def on_mouse_down(event):
            # Axes の上でマウスを押していた場合のみ処理を行う
            if event.inaxes and self.status == Marubatsu.PLAYING:
                x = math.floor(event.xdata)
                y = math.floor(event.ydata)
                self.move(x, y)                
                self.draw_board(ax)
        
                # 現在の手番を表す ai のインデックスを計算する
                index = 0 if self.turn == Marubatsu.CIRCLE else 1
                # ai が着手を行うかどうかを判定する
                if ai[index] is not None:                
                    x, y = ai[index](self, **params[index])
                    self.move(x, y) 
                    self.draw_board(ax)
                    
        # fig の画像にマウスを押した際のイベントハンドラを結び付ける
        fig.canvas.mpl_connect("button_press_event", on_mouse_down)                   

    # ゲームの決着がついていない間繰り返す
    while self.status == Marubatsu.PLAYING:
        # 現在の手番を表す ai のインデックスを計算する
        index = 0 if self.turn == Marubatsu.CIRCLE else 1
        # ゲーム盤の表示
        if verbose:
            if gui:
                # AI どうしの対戦の場合は画面を描画しない
                if ai[0] is None or ai[1] is None:
                    self.draw_board(ax)
                # 手番を人間が担当する場合は、play メソッドを終了する
                if ai[index] is None:
                    return
            else:
                print(self)
        # ai が着手を行うかどうかを判定する
        if ai[index] is not None:
            x, y = ai[index](self, **params[index])
        else:
            # キーボードからの座標の入力
            coord = input("x,y の形式で座標を入力して下さい。exit を入力すると終了します")
            # "exit" が入力されていればメッセージを表示して関数を終了する
            if coord == "exit":
                print("ゲームを終了します")
                return       
            # x 座標と y 座標を要素として持つ list を計算する
            xylist = coord.split(",")
            # xylist の要素の数が 2 ではない場合
            if len(xylist) != 2:
                # エラーメッセージを表示する
                print("x, y の形式ではありません")
                # 残りの while 文のブロックを実行せずに、次の繰り返し処理を行う
                continue
            x, y = xylist
        # (x, y) に着手を行う
        try:
            self.move(int(x), int(y))
        except:
            print("整数の座標を入力して下さい")

    # 決着がついたので、ゲーム盤を表示する
    if verbose:
        if gui:
            self.draw_board(ax)
        else:
            print(self)
            
    return self.status
    
Marubatsu.play = play
修正箇所
def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
元と同じなので省略
        # ローカル関数としてイベントハンドラを定義する
        def on_mouse_down(event):
            # Axes の上でマウスを押していた場合のみ処理を行う
            if event.inaxes and self.status == Marubatsu.PLAYING:
                x = math.floor(event.xdata)
                y = math.floor(event.ydata)
                self.move(x, y)                
                self.draw_board(ax)

                # 現在の手番を表す ai のインデックスを計算する
+               index = 0 if self.turn == Marubatsu.CIRCLE else 1
+               # ai が着手を行うかどうかを判定する
+               if ai[index] is not None:                
+                   x, y = ai[index](self, **params[index])
+                   self.move(x, y) 
+                   self.draw_board(ax)
元と同じなので省略
    
Marubatsu.play = play

マウス による 着手の選択 と、AI による 着手の選択 の処理は、下記の表 のように 対応している ので 見比べて みて下さい。

処理 人間 AI
着手を行う座標を計算する 6、7 行目 15 行目
着手を行う 8 行目 16 行目
画像を更新する 9 行目 17 行目

上記修正後 に、下記 のプログラムで play メソッドを 実行 すると、人間が着手 を行うと 自動的AI着手を行う ようになります。下記 の実行結果は、(0, 0)マスの上マウスを押した 場合の図で、AI(2, 0) のマスに 着手を行う ことが 確認 できます。

mb.play(ai=[None, ai2], gui=True)

実行結果(実行結果はランダムなので下記とは異なる場合があります)

うまく プログラムが 動作している ように 見えるかもしれません が、実は、上記 のプログラムには 二つの問題 があります。実際AI何度か対戦行ってみて、どのような 問題 があるかを 探してみて 下さい。

二つの問題の検証

一つ目の問題

一つ目の問題 は、〇 の着手 によって 勝利した後 で、AI着手を行ってしまう というものです。下記 は、〇 が勝利 する 着手を行った場合 です。〇 が勝利 しているにも 関わらず× を担当 する AI着手行っています実際〇 が勝利 する 着手を行った場合AI着手を行ってしまう ことを 確認 してみて下さい。

二つ目の問題

二つ目の問題 は、9 手目着手行った場合 に、下記 のような エラーが発生 してしまうというものです。実際9 手目着手行った際 に、下記エラーが発生 することを 確認 してみて下さい。

略
File c:\Users\ys\ai\marubatsu\071\ai.py:235, in ai2(mb)
    224 """ランダムなマスに着手する AI.
    225 
    226 Args:
   (...)
    231     着手する座標を表す tuple
    232 """
    234 legal_moves = mb.calc_legal_moves()
--> 235 return choice(legal_moves)

File c:\Users\ys\Anaconda3\envs\marubatsu\Lib\random.py:373, in Random.choice(self, seq)
    370 # As an accommodation for NumPy, we don't use "if not seq"
    371 # because bool(numpy.array()) raises a ValueError.
    372 if not len(seq):
--> 373     raise IndexError('Cannot choose from an empty sequence')
    374 return seq[self._randbelow(len(seq))]

IndexError: Cannot choose from an empty sequence

Pythonバージョン 3.10 では、下記 のような エラーメッセージ表示される ので、バージョン によって、同じエラー でも 表示 される エラーメッセージ若干変わる場合あるようです。ただし、エラーメッセージ若干が変わっても、この後で説明する、エラーメッセージ読み方変わりません

略
File c:\Users\ys\ai\marubatsu\071\ai.py:235, in ai2(mb)
    224 """ランダムなマスに着手する AI.
    225 
    226 Args:
   (...)
    231     着手する座標を表す tuple
    232 """
    234 legal_moves = mb.calc_legal_moves()
--> 235 return choice(legal_moves)

File c:\Users\ys\Anaconda3\envs\ml\lib\random.py:346, in Random.choice(self, seq)
    344 """Choose a random element from a non-empty sequence."""
    345 # raises IndexError if seq is empty
--> 346 return seq[self._randbelow(len(seq))]

IndexError: list index out of range

エラーメッセージの表記の意味

先に、エラーメッセージ という 手がかり がある、二つ目の問題 から 検証 することにします。これまで は、エラーメッセージ の中で、最後表示された内容 を見て エラーの原因推測 してきましたが、上記 の 「(empty)の シーケンス型(sequence)から(from)選択(choose)することは できない(cannot)」という意味の IndexError: Cannot choose from an empty sequence というエラーメッセージ だけからエラーの原因推測 することは 困難 です。また、その上に表示される、エラーが発生した行を表す、--> 378 return seq[self._randbelow(len(seq))] は、自分で記述 した 覚えのない プログラムであり、これを見ても エラーの原因推測 することは 困難 です。

このような場合 は、それより前エラーメッセージさかのぼって 原因を 推測 する 必要あります。そのためには、エラーメッセージどのような情報表示 されているかを 理解する必要あります

関数メソッド は、プログラム様々な場所 から 呼び出され利用 されます。そのため、関数の中エラーが発生 した場合は、その関数どこから呼び出されたか の情報を 知ること が、エラー発生するまでプログラム処理の流れ辿るため重要 になります。そのため、エラーメッセージ には、エラー発生するまで呼び出された関数順番に記述 されるようになっています。

エラーメッセージの読み方の具体例

言葉の説明だけではわかりづらいので、具体例挙げます下記 のプログラムは、2 つ関数 xy定義 し、y()返り値表示 するプログラムです。

1  def x():
2      return a
3
4  def y():
5      return x()
6
7  print(y())
行番号のないプログラム
def x():
    return a

def y():
    return x()

print(y())

上記プログラム処理の流れ は以下のようになります。

  1. 7 行目y()実行 して 関数 y呼び出す
  2. 4 行目関数 y呼び出される
  3. 5 行目x()実行 して 関数 x呼び出す
  4. 1 行目関数 x呼び出される
  5. 2 行目a関数 x返り値 として 返す
  6. 5 行目関数 x返り値 を、関数 y返り値 として 返す
  7. 7 行目関数 返り値print表示 する

ただし、実際 には、2 行目a関数 xブロックの中一度値が代入されていない ので、2 行目実行 すると、下記実行結果最後の行 のように、a定義されていない という 意味NameError: name 'a' is not defined という エラーが発生 します。

実行結果

---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In[11], line 7
      4 def y():
      5     return x()
----> 7 print(y())

Cell In[11], line 5
      4 def y():
----> 5     return x()

Cell In[11], line 2
      1 def x():
----> 2     return a

NameError: name 'a' is not defined

上記エラーメッセージ の中の、最初の行表示 される Traceback (most recent call last) は、最も最近(most recent)に 呼び出された(call)ものを 最後(last)に 表示 するという 順番 で、プログラムの 処理さかのぼって(Traceback)表示 するという 意味 です。実際に、エラーメッセージ では、エラー発生するまで実行 された 処理の流れ が、関数呼び出された順表示 されています。

エラーメッセージ の表示の中の、Cell In [11], line 7 のような 表示 と、---->表示された行 は、以下 のような 意味 を持ちます。

  • エラー発生するまで実行 した 関数呼び出し記述 されている 行の情報を 表す。ただし、最後に表示 されたものは、エラー発生した行情報 を表す
  • Cell In [11], line 7 のような 表記 は、関数呼び出し が行われた(または エラーが発生 した)JupyterLabセルの番号 と、そのセルの中行数 を表す
  • ---->記述 されている が、関数呼び出し が行われた(または エラーが発生 した)行の内容 を表す

上記エラーメッセージの右 に、上記の 処理の手順対応を記述 したものを 下記記しますエラーメッセージ が、処理の手順順番通りエラーが発生するまで処理の流れ表記 していることを 確認 して下さい。

Cell In[11], line 7
      4 def y():
      5     return x()
----> 7 print(y())                   1. y() を実行して 関数 y を呼び出す

Cell In[11], line 5
      4 def y():                     2. 関数 y が呼び出される
----> 5     return x()               3. x() を実行して 関数 x を呼び出す

Cell In[11], line 2
      1 def x():                     4. 関数 x が呼び出される
----> 2     return a                 5. a を 関数 x の返り値として返す

NameError: name 'a' is not defined   上記の手順 5 でこのエラーが発生した

エラーメッセージ読み解く際 には、下から見る のが 一般的 です。その理由 は、下から見たほうエラーの原因見つけやすい場合多い からです。例えば、上記エラーメッセージ の場合は、NameError: name 'a' is not definedその上部 に記述された x の定義 の部分を 見るだけ で、a定義されていない ことが 原因 であることが わかります

二つ目のエラーメッセージの検証

下記 は、先程の 二つ目エラーメッセージ再掲 したものです。上記の例 は、JupyterLabセルに記述 された プログラム でしたが、play メソッドの では、インポート した モジュールファイル記述 された プログラムが実行 される場合があります。そのような場合 は、Cell ではなく、File で始まる行 で、下記 のような 方法関数呼び出し(または エラー)の 情報記述 されます。

  • File ファイルのパス:行数, in その下のプログラムが記述された関数名(仮引数)
略
File c:\Users\ys\ai\marubatsu\071\ai.py:235, in ai2(mb)
    224 """ランダムなマスに着手する AI.
    225 
    226 Args:
   (...)
    231     着手する座標を表す tuple
    232 """
    234 legal_moves = mb.calc_legal_moves()
--> 235 return choice(legal_moves)

File c:\Users\ys\Anaconda3\envs\marubatsu\Lib\random.py:373, in Random.choice(self, seq)
    370 # As an accommodation for NumPy, we don't use "if not seq"
    371 # because bool(numpy.array()) raises a ValueError.
    372 if not len(seq):
--> 373     raise IndexError('Cannot choose from an empty sequence')
    374 return seq[self._randbelow(len(seq))]

IndexError: Cannot choose from an empty sequence

この エラーメッセージ下から見る と、下記 のようなことが わかります

  • エラー発生した行内容 は、raise IndexError('Cannot choose from an empty sequence') である
  • File:(略)\random.py:373 から、エラー発生した行 は、random.py という ファイル373 行目記述 されている
  • in Random.choice(self, seq) から、373 行目 は、Random という クラスchoice という メソッドで定義 されており、この メソッド には、seq という 仮引数存在 する

373 行目raise は、その 後ろに記述 した エラー発生する処理 を行います。実際raise後ろ記述 されている 内容 が、最後の行エラーメッセージ対応 していることを 確認 して下さい。

raise の使い方に関しては、必要になった時点で紹介する予定です。

上記 から random モジュールの choice メソッドで エラーが発生 したことが わかります

また、Cannot choose from an empty sequence という エラーメッセージ から、おそらく choice実引数空のシーケンス型 の一つである 空の list記述 して 呼び出した ことが 原因エラーが発生 したことが 推測されます

そのこと確認 するために、下記 のプログラムのように、random モジュールの choice実引数 に、空の list記述 して 実行 すると、確かに 同じエラー発生 します。

from random import choice

print(choice([]))

実行結果

---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[12], line 3
      1 from random import choice
----> 3 print(choice([]))

File c:\Users\ys\Anaconda3\envs\marubatsu\Lib\random.py:373, in Random.choice(self, seq)
    370 # As an accommodation for NumPy, we don't use "if not seq"
    371 # because bool(numpy.array()) raises a ValueError.
    372 if not len(seq):
--> 373     raise IndexError('Cannot choose from an empty sequence')
    374 return seq[self._randbelow(len(seq))]

IndexError: Cannot choose from an empty sequence

choice実引数空の list記述 して 実行 したことが 原因 であることが わかりました が、何故 そのような処理行われたか についてまでは わかりません。そこで、下記 の、先程エラーメッセージ一つ上に表示 された メッセージ検証 することにします。

略
File c:\Users\ys\ai\marubatsu\071\ai.py:235, in ai2(mb)
    224 """ランダムなマスに着手する AI.
    225 
    226 Args:
   (...)
    231     着手する座標を表す tuple
    232 """
    234 legal_moves = mb.calc_legal_moves()
--> 235 return choice(legal_moves)

上記エラーメッセージ から、下記 のようなことが わかります

  • 確かにreturn choice(legal_moves) によって、choice実引数 legal_moves記述 して 呼び出している
  • File:(略)\ai.py:235 から、choice呼び出した行 は、ai.py という ファイル235 行目記述 されている
  • in ai2(mb) から、この行 は、ai2 という 関数で定義 されており、この関数 には、mb という 仮引数存在 する

上記 から choice は、ai.py記述 された ai2 から 呼び出された ことが わかります

ai.py は、これまでに定義 した AI記述 した モジュール で、ai2 は、先程 play メソッドを 実行した際 に、× の手番担当 するように 指定 しました。

ai2 は、自分で定義 した 関数 なので、その中で 行う処理わかるはず です。忘れた方 は、以前の記事復習 してください。下記ai2 の定義 です。ai2 は、ランダムな着手 を行う AI で、ai2行う処理 は、合法手中からrandom モジュールchoice を使って ランダム選択を行う というものです1

def ai2(mb):
    legal_moves = mb.calc_legal_moves()
    return choice(legal_moves)

今回の エラーが発生 した 状況 は、9 手目着手行った場合 です。上記エラーメッセージ から、ai2呼び出されている ことがわかるので、9 手目着手行った後 で、ai2 が呼び出されて 着手を選択 する 処理を行った ことがわかります。

〇×ゲームゲーム盤マスの数9 マス なので、9 手目着手行った後 は、すべてのマス埋まっている ため、合法手存在しません。従って、ai22 行目legal_moves = mb.calc_legal_moves()実行 すると、legal_moves には 空の list代入 され、3 行目return choice(legal_moves) は、空の list実引数に記述 して choice呼び出す ことになります。これで、エラー発生した原因判明 しました。

このようなことがおきるのは、9 手目着手後 に、AI着手を行ってしまう ことが 原因 です。従って、9 手目着手後 に、AI着手行わないようにする ことで、この 問題解決できる ことが わかりました

上記のように、エラーメッセージ読み解く ことで、エラーの原因判明する場合あります。もちろん、エラーメッセージ読み解くだけすべてエラーの原因わかる とは 限りません が、エラーメッセージ には、エラーの原因重要なヒント記述 されている 場合が多い ので、エラーメッセージ意味を理解 することは デバッグ能力の向上つながる ため 非常に重要 です。

先程エラーメッセージ最初 には、下記 のような メッセージが表示 されます。ファイルのパス の中に ipympl記述 されていることと、メッセージ(イベントのこと)を 処理 する(handle)という 意味_handle_message という 名前関数処理が行われている ことから、イベントループ から、ipympl の仕組みを使ってで登録した イベントハンドラ処理呼び出された ことが 推測 できます。

File c:\Users\ys\Anaconda3\envs\marubatsu\Lib\site-packages\ipympl\backend_nbagg.py:279,
in Canvas._handle_message(self, object, content, buffers)

一つ目と二つ目の問題の整理と統合

下記は、上記の検証判明 した 一つ目二つ目問題点 です。

原因
一つ目の問題 〇 の勝利後AI が着手 する
二つ目の問題 9 手目の着手後AI が着手 する

上記 から、いずれの場合 も、特定の状況 で、AI着手してしまう ことが 問題 であることが わかります。従って、この 2 つそれぞれの状況 に対して、AI着手を行わないようにする ことで、問題解決 することが できます が、実は 上記の 二つの状況 を、一つの状況まとめる ことで、より簡単問題解決する ことが できます 。それが何かについて少し考えてみて下さい。

上記2 つの問題状況共通する のは、ゲーム決着した状況 であるということです。また、深く考えなくても 下記のことはすぐにわかると思います。

  • ゲーム決着していない 状況では、AI着手行う必要 がある
  • ゲーム決着した 状況では、AI着手行ってはいけない

従って、ゲーム決着した 状況で、AI着手行わないようにする ことで、二つの問題同時に解決 することが できる ことが わかります

問題の解決とフローチャート

問題の原因 は、on_mouse_down行う処理 が、人間着手行った後 で、ゲーム決着がついているか どうかに 関係なくAI着手を選択 する なので、下記 のプログラムの 14 行目 のように、ゲーム決着がついていない場合のみAI着手を行うよう修正 することで、問題解決 することが できます

 1  def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
元と同じなので省略
 2          # ローカル関数としてイベントハンドラを定義する
 3          def on_mouse_down(event):
 4              # Axes の上でマウスを押していた場合のみ処理を行う
 5              if event.inaxes and self.status == Marubatsu.PLAYING:
 6                  x = math.floor(event.xdata)
 7                  y = math.floor(event.ydata)
 8                  self.move(x, y)                
 9                  self.draw_board(ax)
10
11                  # 現在の手番を表す ai のインデックスを計算する
12                  index = 0 if self.turn == Marubatsu.CIRCLE else 1
13                  # ゲームの決着がついていない場合に、ai が着手を行うかどうかを判定する
14                  if self.status == Marubatsu.PLAYING and ai[index] is not None:         
15                      x, y = ai[index](self, **params[index])
16                      self.move(x, y) 
17                      self.draw_board(ax)
元と同じなので省略                  
18    
19  Marubatsu.play = play
行番号のないプログラム
def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
    # seed が None でない場合は、seed を乱数の種として設定する
    if seed is not None:
        random.seed(seed)
    
    # 〇×ゲームを再起動する
    self.restart()

    # gui が True の場合に、ゲーム盤を描画する画像を作成し、イベントハンドラに結びつける
    if gui:
        fig, ax = plt.subplots(figsize=[size, size])
        fig.canvas.toolbar_visible = False
        fig.canvas.header_visible = False
        fig.canvas.footer_visible = False
        fig.canvas.resizable = False          
        
        # ローカル関数としてイベントハンドラを定義する
        def on_mouse_down(event):
            # Axes の上でマウスを押していた場合のみ処理を行う
            if event.inaxes and self.status == Marubatsu.PLAYING:
                x = math.floor(event.xdata)
                y = math.floor(event.ydata)
                self.move(x, y)                
                self.draw_board(ax)
        
                # 現在の手番を表す ai のインデックスを計算する
                index = 0 if self.turn == Marubatsu.CIRCLE else 1
                # ゲームの決着がついていない場合に、ai が着手を行うかどうかを判定する
                if self.status == Marubatsu.PLAYING and ai[index] is not None:               
                    x, y = ai[index](self, **params[index])
                    self.move(x, y) 
                    self.draw_board(ax)
                    
        # fig の画像にマウスを押した際のイベントハンドラを結び付ける
        fig.canvas.mpl_connect("button_press_event", on_mouse_down)                   

    # ゲームの決着がついていない間繰り返す
    while self.status == Marubatsu.PLAYING:
        # 現在の手番を表す ai のインデックスを計算する
        index = 0 if self.turn == Marubatsu.CIRCLE else 1
        # ゲーム盤の表示
        if verbose:
            if gui:
                # AI どうしの対戦の場合は画面を描画しない
                if ai[0] is None or ai[1] is None:
                    self.draw_board(ax)
                # 手番を人間が担当する場合は、play メソッドを終了する
                if ai[index] is None:
                    return
            else:
                print(self)
        # ai が着手を行うかどうかを判定する
        if ai[index] is not None:
            x, y = ai[index](self, **params[index])
        else:
            # キーボードからの座標の入力
            coord = input("x,y の形式で座標を入力して下さい。exit を入力すると終了します")
            # "exit" が入力されていればメッセージを表示して関数を終了する
            if coord == "exit":
                print("ゲームを終了します")
                return       
            # x 座標と y 座標を要素として持つ list を計算する
            xylist = coord.split(",")
            # xylist の要素の数が 2 ではない場合
            if len(xylist) != 2:
                # エラーメッセージを表示する
                print("x, y の形式ではありません")
                # 残りの while 文のブロックを実行せずに、次の繰り返し処理を行う
                continue
            x, y = xylist
        # (x, y) に着手を行う
        try:
            self.move(int(x), int(y))
        except:
            print("整数の座標を入力して下さい")

    # 決着がついたので、ゲーム盤を表示する
    if verbose:
        if gui:
            self.draw_board(ax)
        else:
            print(self)
            
    return self.status
    
Marubatsu.play = play
修正箇所
def play(self, ai, params=[{}, {}], verbose=True, seed=None, gui=False, size=3):
元と同じなので省略
        # ローカル関数としてイベントハンドラを定義する
        def on_mouse_down(event):
            # Axes の上でマウスを押していた場合のみ処理を行う
            if event.inaxes and self.status == Marubatsu.PLAYING:
                x = math.floor(event.xdata)
                y = math.floor(event.ydata)
                self.move(x, y)                
                self.draw_board(ax)

                # 現在の手番を表す ai のインデックスを計算する
                index = 0 if self.turn == Marubatsu.CIRCLE else 1
                # ゲームの決着がついていない場合に、ai が着手を行うかどうかを判定する
-               if ai[index] is not None:             
+               if self.status == Marubatsu.PLAYING and ai[index] is not None:             
                    x, y = ai[index](self, **params[index])
                    self.move(x, y) 
                    self.draw_board(ax)
元と同じなので省略                  
    
Marubatsu.play = play

下図 は、修正後on_mouse_downフローチャート です。

上記修正後 に、下記 のプログラムで play メソッドを 実行 すると、下記2 つ実行結果 のように、人間の着手ゲーム決着がついた 場合に、AI着手行わなくなる ことが 確認 できます。

mb.play(ai=[None, ai2], gui=True)

実行結果(実行結果はランダムなので下記とは異なる場合があります)

下図の後 で、×着手しません

下図の後 で、エラー発生ません

AI VS 人間 の対戦

先程と同様 に、play メソッドで 実引数 に、gui=True記述 して 下記 のプログラムのように、AI VS 人間対戦行う ことにします。下記 は、ai2 VS 人間対戦 です。

mb.play(ai=[ai2, None], gui=True)

実行結果示しません が、問題なく AI VS 人間対戦が行える ことが 確認 できます。実際対戦を行って問題がない ことを 確認 してみて下さい。

AI VS 人間の対戦で行われる処理

対戦問題なく行う ことが できる理由 は、play メソッドを 実行 した際に、下記 のような 処理行われる からです。

  • AI が担当 するので、下記play メソッドの 9 行目ai[index]None ではない ため、10 行目return 文実行されない
  • そのため、15 行目実行されAI着手を選択 する
  • 次の繰り返し 処理では、×人間が担当 するので、下記play メソッドの 9 行目ai[index]None になる ため、10 行目return 文実行 されて play メソッドの 処理が終了 する
  • その後 は、ゲーム盤の上マウスを押す ことで「人間の着手」と「AI の着手」の 処理 が、人間 VS AI同様の手順行われる

 1  # ゲームの決着がついていない間繰り返す
 2  while self.status == Marubatsu.PLAYING:
 3      # 現在の手番を表す ai のインデックスを計算する
 4      index = 0 if self.turn == Marubatsu.CIRCLE else 1
 5      # ゲーム盤の表示
 6      if verbose:
 7          if gui:
 8              self.draw_board(ax)
 9              if ai[index] is None:
10                   return
11          else:
12              print(self)
13      # ai が着手を行うかどうかを判定する
14      if ai[index] is not None:
15          x, y = ai[index](self, **params[index])

AI VS 人間の対戦のフローチャート

下図 は、guiTrue代入 されていた場合に、play メソッドで AI VS 人間対戦行った場合フローチャート です。赤い線 が、1 回目繰り返し で行われる 処理 を、緑の線 が、2 回目繰り返し で行われる 処理 を表します。

図から、1 手目〇 の手番AI着手を行い2 手目着手行う前play メソッドが 終了 することが わかります以後 は、人間 VS AI の場合と 同様 に、ゲーム盤の上マウスを押す と、on_mouse_down呼び出され人間の着手 と、AI の着手続けて行われます。そのため、上記 のプログラムで AI VS 人間対戦 を行うことが できます

これで、GUI による AI との対戦実装が完了 しました。最後に 念のため上記の修正 を行っても 人間 VS 人間AI VS AI対戦問題なく行えること確認 してみて下さい。

今回の記事のまとめ

今回の記事では、GUIAI との対戦行える ようにしました。

また、その際に、エラーメッセージ読み方 について 説明 しました。エラーメッセージデバッグ行う際重要な手掛かり となるので、正しい読み方理解する ことを 強くお勧めします

次回の記事 では、GUIゲームのリセット などを 行える ようにします。

本記事で入力したプログラム

以下のリンクから、本記事で入力して実行した JupyterLab のファイルを見ることができます。

以下のリンクは、今回の記事で更新した marubatsu.py です。

次回の記事

更新日時 更新内容
2024/04/14 フローチャートに画像を描画する処理を表記するように修正しました
  1. ai2s同じ処理 を行いますが、ai2 のほうが わかりやすい ので、今回の記事では ai2 を採用 しました

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0