0
1

Pythonで〇×ゲームのAIを一から作成する その81 AI が手番を担当した場合のリプレイ機能とGUIの処理の分離

Last updated at Posted at 2024-05-16

目次と前回の記事

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

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

ルールベースの AI の一覧

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

AI が手番を担当した場合のリプレイ機能の実装

前回の記事では、リプレイ機能を実装する際に、人間どうしの対戦を行いましたが、AI が手番を担当した場合 では、この後で示すように リプレイ機能がうまく働きません

例えば、下記のプログラムで gui_play を実行して AI どうしの対戦を GUI で行う と、実行結果のように、すべてのリプレイのボタンが灰色で表示 され、リプレイの操作ができない という問題が発生します。なお、対戦する AI はどの AI でも構いません。このような問題が起きる原因について少し考えてみて下さい。

from util import gui_play
from ai import ai1

gui_play(ai=[ai1, ai1])

実行結果(下図は、画像なので操作することはできません)

問題の原因の検証

リプレイボタンの設定 は、update_widgets_status を呼び出すことで行いますが、この処理は現時点では下記の場合でのみ行われています。

  • play メソッド内で、restart メソッドを呼び出してゲームをリセットした直後
  • リセットボタンをクリックした時のイベントハンドラの中
  • ゲーム盤の上でマウスを押した時のイベントハンドラの中で、着手を行った場合
  • リプレイボタンをクリックした際に呼び出される change_step の中

update_widgets_status がどこで呼び出されているかを確認する場合は、VSCode の Ctrl + F による検索機能 を利用すると良いでしょう。

また、AI どうしが対戦を行う場合は以下のような処理が行われます。

  1. play メソッドの実行または、リセットボタンをクリックした際にゲームが開始される
  2. いずれの場合も update_widgets_status が呼び出され、ゲームの開始時は 0 手目の局面なので、すべてのリプレイに関するボタンが灰色で表示 され、操作ができなくなる
  3. その後、play_loop メソッドによって AI どうしの対戦が決着がつくまで行われる
  4. ゲームの決着後に draw_board メソッドが呼び出され、決着後のゲーム盤が描画される
  5. ゲームの開始から決着がつくまでの間に update_widgets_status呼び出されることはない ので、すべてのリプレイに関するボタン灰色で表示されたまま になる

これが AI どうしで対戦を行った際にリプレイ機能が利用できない原因です。

update_widgets_status を呼び出す必要がある場所の検証

これまでは、リプレイ機能に関するボタンの設定の更新を行う必要がある状況が 新しく判明した時点 で、その都度 必要な場所に update_widgets_status を記述してきました

このような、プログラムで 特定の処理を行う必要がある場合 に、その処理を行う必要がある場面が 見つかってからその処理を記述する という 場当たり的な方法 は、実際に良く行われる方法ですが、その方法では、以下の 2 つが区別できない という問題があります。

  • プログラムに特定の処理を記述する必要がある場所がもう 存在しない
  • プログラムに特定の処理を記述する必要がある場所が 存在するが、見つかっていない

もちろん、特定の処理をどこに記述する必要があるかが良くわかっていない場合は、見つかってから対処するしかないのですが、特定の処理を どこに記述する必要があるかがはっきりとわかる場合 は、その場所を明確にすることで、必要な場所に確実にその処理を記述できる ようになります。

特定の処理を記述する必要な場所が見つかってから対処するという方法は、以前の記事で説明した、経験から学ぶ という 帰納法に相当 します。以前の記事で説明したように、帰納法 では特定の処理をどこに記述する必要があるかについて、100 % 正しい答えを見つけることはできません が、理屈がわからなくても、経験からその場所を見つけることができます。一方、理屈から特定の処理をどこに記述するかを考える演繹法 を使えば、特定の処理を記述する必要がある場所を 100 % 見つけることができます が、理屈が良くわからない場合や、理屈から具体的な方法を見つけることが困難が場合は利用することはできません。

そこで、リプレイ機能に関する ボタンの設定の更新を必ず行う必要がある状況を検証 することにします。リプレイ機能に関するボタンの設定の更新を必ず行う必要がある状況が何であるかについて少し考えてみて下さい。

特定の処理を行う必要がある場所がはっきりしないという状況がピンとこない人は、特定の処理を「バグを修正する」だと考えてみて下さい。

プログラムにはバグはつきものですが、プログラムが長くなればなるほど、プログラムの中に潜むバグを見つけることは困難になります。例えば 1 万行のプログラムの中に潜むバグを、プログラムを 1 行ずつ確認して探し出すことは大変です。

そのため、プログラムの バグの多く は、バグが発生してから原因を検証して対処する という方法を 取らざるを得ません。実際に本記事でもこれまで何度もバグが発生してから対処するという方法を取ってきました。

一方、特定の処理を行った場合に発生するバグ に関しては、プログラムがどれだけ長くても、その処理に関連する部分だけに注目 してバグを修正することができます。ただし、関連する部分が多い場合、やはりバグを見つけるのは困難です。

下記の update_widgets_status が 4 つのリプレイに関するボタンに対して行う処理は、self.move_count <= 0self.move_count >= len(self.board_records) - 1 の式からわかるように、いずれも 現在の手数 を表す self.move_count の値に応じてボタンの設定を変更 する 処理を行います。従って、リプレイ機能に関するボタンの設定は、ゲームの手数が変更された際に行う 必要があります。

def update_widgets_status():
    set_button_status(first_button, self.move_count <= 0)
    set_button_status(prev_button, self.move_count <= 0)
    set_button_status(next_button, self.move_count >= len(self.board_records) - 1)
    set_button_status(last_button, self.move_count >= len(self.board_records) - 1)

上記から、ゲームの手数を変更する処理を探し出し、その直後に update_widgets_status を呼び出す処理を記述すれば良いことがわかりますが、もっと簡単な方法があるのでその方法を少し考えてみて下さい。

ゲームの手数が変更された場合 は、必ず ゲーム盤の表示を更新する必要がある ので、その処理を行う draw_board メソッドが 呼び出す必要があります。つまり、draw_board メソッドの中に update_widgets_status を呼び出す処理を記述する ことで、ゲームの手数が変更された場合に必ず update_widgets_status が呼び出されるようになります。

細かい話になりますが、手数を変更する処理の後に毎回 update_widgets_status を記述するよりも、draw_board の中に update_widgets_status を記述したほうが良い理由が他にもあります。それは、AI どうしの対戦を行う場合です。

AI どうしの対戦 が行われた場合は、すぐに決着がつくため、対戦の 途中経過のゲーム盤の画面を描画 しても 対戦結果のゲーム盤の描画で上書き されてしまいます。そのため、play_loop の下記のプログラムの 9、10 によって、必要のない途中経過の画面を描画しない という処理を行っています。

 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              # AI どうしの対戦の場合は画面を描画しない
 9              if ai[0] is None or ai[1] is None:
10                   self.draw_board(ax, ai)

これは、リプレイに関するボタンの設定も同様で、AI どうしの対戦の 途中の局面 に対して、リプレイに関するボタンの設定を行う処理 を行っても、すぐに決着がついた局面でのリプレイボタンの設定で上書きされてしまうため 意味はありませんdraw_board の中で update_widgets_status を呼び出すようにすることで、対戦の途中で 無駄な update_widgets_status の呼び出しが行われなくなります

draw_board メソッドからの update_widgets_status の呼び出し

上記の処理は、下記のプログラムの 10 行目のように、draw_board のブロックの中の最後に update_widgets_status の呼び出しを記述すれば良いと思う人がいるかもしれません。

 1  from marubatsu import Marubatsu
 2
 3  def draw_board(self, ax, ai):
元と同じなので省略
 4      # ゲーム盤のマークを描画する
 5      for y in range(self.BOARD_SIZE):
 6          for x in range(self.BOARD_SIZE):
 7              color = "red" if (x, y) == self.last_move else "black"
 8              self.draw_mark(ax, x, y, self.board[x][y], color) 
 9           
10      update_widgets_status()
11
12  Marubatsu.draw_board = draw_board
行番号のないプログラム
from marubatsu import Marubatsu

def draw_board(self, ax, ai):           
    # Axes の内容をクリアして、これまでの描画内容を削除する
    ax.clear()
    
    # y 軸を反転させる
    ax.invert_yaxis()
    
    # 枠と目盛りを表示しないようにする
    ax.axis("off")   
    
    # ゲームの決着がついていた場合は背景色を
    facecolor = "white" if self.status == Marubatsu.PLAYING else "lightyellow"
    ax.figure.set_facecolor(facecolor)
        
    # 上部のメッセージを描画する
    # 対戦カードの文字列を計算する
    names = []
    for i in range(2):
        names.append("人間" if ai[i] is None else ai[i].__name__)
    ax.text(0, 3.5, f"{names[0]} VS {names[1]}", fontsize=20)   
    
    # ゲームの決着がついていない場合は、手番を表示する
    if self.status == Marubatsu.PLAYING:
        text = "Turn " + self.turn
    # 引き分けの場合
    elif self.status == Marubatsu.DRAW:
        text = "Draw game"
    # 決着がついていれば勝者を表示する
    else:
        text = "Winner " + self.status
    ax.text(0, -0.2, text, fontsize=20)
    
    # ゲーム盤の枠を描画する
    for i in range(1, self.BOARD_SIZE):
        ax.plot([0, self.BOARD_SIZE], [i, i], c="k") # 横方向の枠線
        ax.plot([i, i], [0, self.BOARD_SIZE], c="k") # 縦方向の枠線   

    # ゲーム盤のマークを描画する
    for y in range(self.BOARD_SIZE):
        for x in range(self.BOARD_SIZE):
            color = "red" if (x, y) == self.last_move else "black"
            self.draw_mark(ax, x, y, self.board[x][y], color) 
            
    update_widgets_status()
    
Marubatsu.draw_board = draw_board
修正箇所
from marubatsu import Marubatsu

def draw_board(self, ax, ai):
元と同じなので省略
    # ゲーム盤のマークを描画する
    for y in range(self.BOARD_SIZE):
        for x in range(self.BOARD_SIZE):
            color = "red" if (x, y) == self.last_move else "black"
            self.draw_mark(ax, x, y, self.board[x][y], color) 
            
+   update_widgets_status()
    
Marubatsu.draw_board = draw_board

しかし、上記の修正後に、下記のプログラムで gui_play を実行すると、実行結果のようなエラーが発生します。このエラーの原因について少し考えてみて下さい。

gui_play(ai=[ai1, ai1])

実行結果

略
Cell In[2], line 46
     43         color = "red" if (x, y) == self.last_move else "black"
     44         self.draw_mark(ax, x, y, self.board[x][y], color) 
---> 46 update_widgets_status()

NameError: name 'update_widgets_status' is not defined

エラーメッセージから、update_widgets_status が定義されていない ことがわかります。これは、update_widgets_statusplay メソッドのローカル関数として定義されている ため、play メソッドの外draw_board メソッドでは 利用できない からです。

以前の記事でも説明しましたが、定義されていない名前は VSCode では下図のようにオレンジ色の波線が表示されます。

そこで、下記のプログラムのように、update_widgets_status の定義 を、play メソッドの中にコピーすればよい と思う人がいるかもしれません。なお、update_widgets_status の中で、set_button_status を呼び出しているので、set_button_status もコピーしました。

 1  def draw_board(self, ax, ai):           
元と同じなので省略
 2      # ボタンのウィジェットの状態を設定する
 3      def set_button_status(button, disabled):
 4          button.disabled = disabled
 5          button.style.button_color = "lightgray" if disabled else "lightgreen"
 6
 7      # ウィジェットの状態を更新する        
 8      def update_widgets_status():
 9          # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
10          set_button_status(first_button, self.move_count <= 0)
11          set_button_status(prev_button, self.move_count <= 0)
12          set_button_status(next_button, self.move_count >= len(self.board_records) - 1)
13          set_button_status(last_button, self.move_count >= len(self.board_records) - 1)
14
15      update_widgets_status()
16  
17  Marubatsu.draw_board = draw_board
行番号のないプログラム
def draw_board(self, ax, ai):           
    # Axes の内容をクリアして、これまでの描画内容を削除する
    ax.clear()
    
    # y 軸を反転させる
    ax.invert_yaxis()
    
    # 枠と目盛りを表示しないようにする
    ax.axis("off")   
    
    # ゲームの決着がついていた場合は背景色を
    facecolor = "white" if self.status == Marubatsu.PLAYING else "lightyellow"
    ax.figure.set_facecolor(facecolor)
        
    # 上部のメッセージを描画する
    # 対戦カードの文字列を計算する
    names = []
    for i in range(2):
        names.append("人間" if ai[i] is None else ai[i].__name__)
    ax.text(0, 3.5, f"{names[0]} VS {names[1]}", fontsize=20)   
    
    # ゲームの決着がついていない場合は、手番を表示する
    if self.status == Marubatsu.PLAYING:
        text = "Turn " + self.turn
    # 引き分けの場合
    elif self.status == Marubatsu.DRAW:
        text = "Draw game"
    # 決着がついていれば勝者を表示する
    else:
        text = "Winner " + self.status
    ax.text(0, -0.2, text, fontsize=20)
    
    # ゲーム盤の枠を描画する
    for i in range(1, self.BOARD_SIZE):
        ax.plot([0, self.BOARD_SIZE], [i, i], c="k") # 横方向の枠線
        ax.plot([i, i], [0, self.BOARD_SIZE], c="k") # 縦方向の枠線   

    # ゲーム盤のマークを描画する
    for y in range(self.BOARD_SIZE):
        for x in range(self.BOARD_SIZE):
            color = "red" if (x, y) == self.last_move else "black"
            self.draw_mark(ax, x, y, self.board[x][y], color)            

    # ボタンのウィジェットの状態を設定する
    def set_button_status(button, disabled):
        button.disabled = disabled
        button.style.button_color = "lightgray" if disabled else "lightgreen"

    # ウィジェットの状態を更新する        
    def update_widgets_status():
        # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
        set_button_status(first_button, self.move_count <= 0)
        set_button_status(prev_button, self.move_count <= 0)
        set_button_status(next_button, self.move_count >= len(self.board_records) - 1)
        set_button_status(last_button, self.move_count >= len(self.board_records) - 1)

    update_widgets_status()
    
Marubatsu.draw_board = draw_board
修正箇所
def draw_board(self, ax, ai):           
元と同じなので省略
    # ボタンのウィジェットの状態を設定する
+   def set_button_status(button, disabled):
+       button.disabled = disabled
+       button.style.button_color = "lightgray" if disabled else "lightgreen"

    # ウィジェットの状態を更新する        
+   def update_widgets_status():
+       # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
+       set_button_status(first_button, self.move_count <= 0)
+       set_button_status(prev_button, self.move_count <= 0)
+       set_button_status(next_button, self.move_count >= len(self.board_records) - 1)
+       set_button_status(last_button, self.move_count >= len(self.board_records) - 1)

    update_widgets_status()
    
Marubatsu.draw_board = draw_board

上記の修正後に、下記のプログラムで gui_play を実行すると、今度は実行結果のような別のエラーが発生します。このエラーの原因について少し考えてみて下さい。

gui_play(ai=[ai1, ai1])

実行結果

略
Cell In[4], line 52
     50 def update_widgets_status():
     51     # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
---> 52     set_button_status(first_button, self.move_count <= 0)
     53     set_button_status(prev_button, self.move_count <= 0)
     54     set_button_status(next_button, self.move_count >= len(self.board_records) - 1)

NameError: name 'first_button' is not defined

エラーメッセージから、今度は first_button が定義されていない ことがわかります。これは、先程と同様に、first_buttonplay メソッドのローカル変数として定義されている からです。この問題を解決する方法について少し考えてみて下さい。

こちらの場合も、VSCode では下図のようにオレンジ色の波線が表示されます。エラーメッセージには first_button の事しか記述されていませんが、下図から prev_button なども定義されていないことがわかります。

属性によるデータの共有

first_button に関する処理を draw_board メソッドの中に移動すればよいと思う人がいるかもしれませんが、first_button は、play メソッドの中で利用されている ので、first_button に関する処理を draw_board メソッドに移動すると今度は play メソッドで first_button を利用する処理が動作しなくなる という問題が発生します。

これまで、update_widgets_status の中で first_button をそのまま利用できていた のは、update_widgets_status が、first_button をローカル変数として持つ play メソッドのローカル関数として定義されていたから です。従って、draw_board メソッドを、play メソッドのローカル関数として定義すれば良いと思った人がいるかもしれませんが、その方法はうまくいきません。その理由は、draw_boardplay_loop という、play メソッド以外のメソッドから呼び出されている ため、draw_board メソッドを、play メソッドのローカル関数として定義すると、play_loop から draw_board を利用できなくなるからです。

この問題を解決する方法の一つは、first_button をローカル変数ではなく、Marubatsu クラスのインスタンスの属性とする というものです。そのようにすることで、play メソッドと draw_board メソッド内で、self.first_button と記述することで、self.first_button のボタンのウィジェットを共有することができるようになります。

下記は、play メソッドの修正です。なお、set_button_statusupdate_widgets_statusdraw_board の中だけでしか利用しないので、それらの関数の定義を play メソッドの中から削除しました。また、update_widget_status の呼び出しは draw_board の中で行うようにしたため、必要がなくなったので play メソッドの中から削除しました。

  • 12 行目の下set_button_statusupdate_widgets_status定義を削除 する
  • 16、28、43、45行目の下update_widgets_status呼び出しを削除 する
  • 21 ~ 24、29 ~ 32、37 行目first_button などの、ボタンのウィジェットを表すローカル変数の前に self. を記述して、インスタンスの属性に修正 する
 1  import matplotlib.pyplot as plt
 2  import ipywidgets as widgets
 3  import math
 4  from copy import deepcopy
 5
 6  def play(self, ai, ai_dict=None, params=None, verbose=True, seed=None, gui=False, size=3):      
元と同じなので省略
 7      # gui が True の場合に、ゲーム盤を描画する画像を作成し、イベントハンドラに結びつける
 8      if gui:
 9          # %matplotlib widget のマジックコマンドを実行する
10          get_ipython().run_line_magic('matplotlib', 'widget')
11
12          # この下にあった set_button_status と update_widgets_status の定義を削除する
元と同じなので省略      
13          # リセットボタンのイベントハンドラを定義する
14          def on_reset_button_clicked(b):
15              self.restart()
16              # この下にあった update_widgets_status の呼び出しを削除する
17
18              on_change_button_clicked(b)
元と同じなので省略            
19          # 2 行目の UI を作成する
20          # リプレイのボタンを作成する
21          self.first_button = create_button("<<", 100)
22          self.prev_button = create_button("<", 100)
23          self.next_button = create_button(">", 100)
24          self.last_button = create_button(">>", 100)
元と同じなので省略        
25          def change_step(step):
元と同じなので省略        
26              # 描画を更新する
27              self.draw_board(ax, ai)
28              # この下にあった update_widgets_status の呼び出しを削除する
元と同じなので省略        
29          self.first_button.on_click(on_first_button_clicked)
30          self.prev_button.on_click(on_prev_button_clicked)
31          self.next_button.on_click(on_next_button_clicked)
32          self.last_button.on_click(on_last_button_clicked)
33
34          # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
35          hbox1 = widgets.HBox([dropdown_list[0], dropdown_list[1], change_button, reset_button])
36          # リプレイ機能のボタンを横に配置した HBox を作成する
37          hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button]) 
38          # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
39          display(widgets.VBox([hbox1, hbox2]))        
元と同じなので省略        
40          # ローカル関数としてイベントハンドラを定義する
41          def on_mouse_down(event):
元と同じなので省略        
42                  self.draw_board(ax, ai)
43                  # この下にあった update_widgets_status の呼び出しを削除する
元と同じなので省略        
44      self.restart()
45      # この下にあった update_widgets_status の呼び出しを削除する
46
47      return self.play_loop(ai=ai, ax=ax, params=params, verbose=verbose, gui=gui)
48
49  Marubatsu.play = play
行番号のないプログラム
import matplotlib.pyplot as plt
import ipywidgets as widgets
import math
from copy import deepcopy

def play(self, ai, ai_dict=None, params=None, verbose=True, seed=None, gui=False, size=3):      
    # ai_dict が None の場合は、空の list で置き換える
    if ai_dict is None:
        ai_dict = {}
    # params が None の場合のデフォルト値を設定する
    if params is None:
        params = [{}, {}]
   
    # seed が None でない場合は、seed を乱数の種として設定する
    if seed is not None:
        random.seed(seed)
  
    # gui が True の場合に、ゲーム盤を描画する画像を作成し、イベントハンドラに結びつける
    if gui:
        # %matplotlib widget のマジックコマンドを実行する
        get_ipython().run_line_magic('matplotlib', 'widget')
        
        # 1 行目の UI を作成する
        # それぞれの手番の担当を表す Dropdown の項目の値を記録する list を初期化する
        select_values = []
        # 〇 と × の Dropdown を格納する list
        dropdown_list = []
        # ai に代入されている内容を ai_dict に追加する
        for i in range(2):
            # ラベルと項目の値を計算する
            if ai[i] is None:
                label = "人間"
                value = "人間"
            else:
                label = ai[i].__name__        
                value = ai[i]
            # value を select_values に常に登録する
            select_values.append(value)
            # value が ai_values に登録済かどうかを判定する
            if value not in ai_dict.values():
                # 項目を登録する
                ai_dict[label] = value
        
            # Dropdown の description を計算する
            description = "" if i == 0 else "×"
            dropdown_list.append(
                widgets.Dropdown(
                    options=ai_dict,
                    description=description,
                    layout=widgets.Layout(width="100px"),
                    style={"description_width": "20px"},
                    value=select_values[i],
                )
            )
        
        # ボタンを作成するローカル関数を定義する 
        def create_button(description, width):
            return widgets.Button(
                description=description,
                layout=widgets.Layout(width=f"{width}px"),
                style={"button_color": "lightgreen"},
            )
        
        # 変更、リセットボタンを作成する
        change_button = create_button("変更", 100)
        reset_button = create_button("リセット", 100)
              
        # 変更ボタンのイベントハンドラを定義する
        def on_change_button_clicked(b):
            for i in range(2):
                ai[i] = None if dropdown_list[i].value == "人間" else dropdown_list[i].value
            self.play_loop(ai=ai, ax=ax, params=params, verbose=verbose, gui=gui)

        # リセットボタンのイベントハンドラを定義する
        def on_reset_button_clicked(b):
            self.restart()
            on_change_button_clicked(b)
            
        # イベントハンドラをボタンに結びつける
        change_button.on_click(on_change_button_clicked)
        reset_button.on_click(on_reset_button_clicked)        
        
        # 2 行目の UI を作成する
        # リプレイのボタンを作成する
        self.first_button = create_button("<<", 100)
        self.prev_button = create_button("<", 100)
        self.next_button = create_button(">", 100)
        self.last_button = create_button(">>", 100)
        
        def change_step(step):
            # step が負の場合は 0 に修正する
            step = max(0, min(len(self.board_records) - 1, step))
            # step 手目のゲーム盤のデータをコピーし、board に代入する
            self.board = deepcopy(self.board_records[step])
            # 手数を表す step を move_count に代入する
            self.move_count = step
            # 手番を計算する。step が偶数の場合は 〇 の 手番
            self.turn = Marubatsu.CIRCLE if step % 2 == 0 else Marubatsu.CROSS
            # status 属性を judget を使って計算する
            self.status = self.judge()
            # 直前の着手を計算する
            self.last_move = self.records[step]
            # 描画を更新する
            self.draw_board(ax, ai)
        
        def on_first_button_clicked(b):
            change_step(0)

        def on_prev_button_clicked(b):
            change_step(self.move_count - 1)

        def on_next_button_clicked(b):
            change_step(self.move_count + 1)
            
        def on_last_button_clicked(b):
            change_step(len(self.board_records) - 1)

        self.first_button.on_click(on_first_button_clicked)
        self.prev_button.on_click(on_prev_button_clicked)
        self.next_button.on_click(on_next_button_clicked)
        self.last_button.on_click(on_last_button_clicked)

        # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
        hbox1 = widgets.HBox([dropdown_list[0], dropdown_list[1], change_button, reset_button])
        # リプレイ機能のボタンを横に配置した HBox を作成する
        hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button]) 
        # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
        display(widgets.VBox([hbox1, hbox2]))        
  
        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)
                # 次の手番の処理を行うメソッドを呼び出す
                self.play_loop(ai=ai, ax=ax, params=params, verbose=verbose, gui=gui)
                
        # fig の画像にマウスを押した際のイベントハンドラを結び付ける
        fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    else:
        ax = None

    self.restart()
    return self.play_loop(ai=ai, ax=ax, params=params, verbose=verbose, gui=gui)

Marubatsu.play = play
修正箇所
import matplotlib.pyplot as plt
import ipywidgets as widgets
import math
from copy import deepcopy

def play(self, ai, ai_dict=None, params=None, verbose=True, seed=None, gui=False, size=3):      
元と同じなので省略
    # gui が True の場合に、ゲーム盤を描画する画像を作成し、イベントハンドラに結びつける
    if gui:
        # %matplotlib widget のマジックコマンドを実行する
        get_ipython().run_line_magic('matplotlib', 'widget')

        # ボタンのウィジェットの状態を設定する
-       def set_button_status(button, disabled):
-           button.disabled = disabled
-           button.style.button_color = "lightgray" if disabled else "lightgreen"

        # ウィジェットの状態を更新する        
-       def update_widgets_status():
            # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
-           set_button_status(first_button, self.move_count <= 0)
-           set_button_status(prev_button, self.move_count <= 0)
-           set_button_status(next_button, self.move_count >= len(self.board_records) - 1)
-           set_button_status(last_button, self.move_count >= len(self.board_records) - 1)

元と同じなので省略      
        # リセットボタンのイベントハンドラを定義する
        def on_reset_button_clicked(b):
            self.restart()
-           update_widgets_status()
            on_change_button_clicked(b)
元と同じなので省略            
        # 2 行目の UI を作成する
        # リプレイのボタンを作成する
-       first_button = create_button("<<", 100)
+       self.first_button = create_button("<<", 100)
-       prev_button = create_button("<", 100)
+       self.prev_button = create_button("<", 100)
-       next_button = create_button(">", 100)
+       self.next_button = create_button(">", 100)
-       last_button = create_button(">>", 100)
+       self.last_button = create_button(">>", 100)
元と同じなので省略        
        def change_step(step):
元と同じなので省略        
            # 描画を更新する
            self.draw_board(ax, ai)
-           update_widgets_status()
元と同じなので省略        
-       first_button.on_click(on_first_button_clicked)
+       self.first_button.on_click(on_first_button_clicked)
-       prev_button.on_click(on_prev_button_clicked)
+       self.prev_button.on_click(on_prev_button_clicked)
-       next_button.on_click(on_next_button_clicked)
+       self.next_button.on_click(on_next_button_clicked)
-       last_button.on_click(on_last_button_clicked)
+       self.last_button.on_click(on_last_button_clicked)

        # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
        hbox1 = widgets.HBox([dropdown_list[0], dropdown_list[1], change_button, reset_button])
        # リプレイ機能のボタンを横に配置した HBox を作成する
-       hbox2 = widgets.HBox([first_button, prev_button, next_button, last_button]) 
+       hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button]) 
        # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
        display(widgets.VBox([hbox1, hbox2]))        
元と同じなので省略        
        # ローカル関数としてイベントハンドラを定義する
        def on_mouse_down(event):
元と同じなので省略        
                self.draw_board(ax, ai)
-               update_widgets_status()
元と同じなので省略        
    self.restart()
-   update_widgets_status()
    return self.play_loop(ai=ai, ax=ax, params=params, verbose=verbose, gui=gui)

Marubatsu.play = play

下記は、draw_board メソッドを修正したプログラムです。

  • 5 ~ 8 行目first_button などのボタンのウィジェットが代入されているローカル変数の前に、self. を記述してインスタンスの属性に修正する
 1  def draw_board(self, ax, ai): 
元と同じなので省略
 2      # ウィジェットの状態を更新する        
 3      def update_widgets_status():
 4          # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
 5          set_button_status(self.first_button, self.move_count <= 0)
 6          set_button_status(self.prev_button, self.move_count <= 0)
 7          set_button_status(self.next_button, self.move_count >= len(self.board_records) - 1)
 8          set_button_status(self.last_button, self.move_count >= len(self.board_records) - 1)
 9
10      update_widgets_status()    
11
12  Marubatsu.draw_board = draw_board
行番号のないプログラム
def draw_board(self, ax, ai):           
    # Axes の内容をクリアして、これまでの描画内容を削除する
    ax.clear()
    
    # y 軸を反転させる
    ax.invert_yaxis()
    
    # 枠と目盛りを表示しないようにする
    ax.axis("off")   
    
    # ゲームの決着がついていた場合は背景色を
    facecolor = "white" if self.status == Marubatsu.PLAYING else "lightyellow"
    ax.figure.set_facecolor(facecolor)
        
    # 上部のメッセージを描画する
    # 対戦カードの文字列を計算する
    names = []
    for i in range(2):
        names.append("人間" if ai[i] is None else ai[i].__name__)
    ax.text(0, 3.5, f"{names[0]} VS {names[1]}", fontsize=20)   
    
    # ゲームの決着がついていない場合は、手番を表示する
    if self.status == Marubatsu.PLAYING:
        text = "Turn " + self.turn
    # 引き分けの場合
    elif self.status == Marubatsu.DRAW:
        text = "Draw game"
    # 決着がついていれば勝者を表示する
    else:
        text = "Winner " + self.status
    ax.text(0, -0.2, text, fontsize=20)
    
    # ゲーム盤の枠を描画する
    for i in range(1, self.BOARD_SIZE):
        ax.plot([0, self.BOARD_SIZE], [i, i], c="k") # 横方向の枠線
        ax.plot([i, i], [0, self.BOARD_SIZE], c="k") # 縦方向の枠線   

    # ゲーム盤のマークを描画する
    for y in range(self.BOARD_SIZE):
        for x in range(self.BOARD_SIZE):
            color = "red" if (x, y) == self.last_move else "black"
            self.draw_mark(ax, x, y, self.board[x][y], color)            

    # ボタンのウィジェットの状態を設定する
    def set_button_status(button, disabled):
        button.disabled = disabled
        button.style.button_color = "lightgray" if disabled else "lightgreen"

    # ウィジェットの状態を更新する        
    def update_widgets_status():
        # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
        set_button_status(self.first_button, self.move_count <= 0)
        set_button_status(self.prev_button, self.move_count <= 0)
        set_button_status(self.next_button, self.move_count >= len(self.board_records) - 1)
        set_button_status(self.last_button, self.move_count >= len(self.board_records) - 1)

    update_widgets_status()    

Marubatsu.draw_board = draw_board
修正箇所
def draw_board(self, ax, ai): 
元と同じなので省略
    # ウィジェットの状態を更新する        
    def update_widgets_status():
        # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
-       set_button_status(first_button, self.move_count <= 0)
+       set_button_status(self.first_button, self.move_count <= 0)
-       set_button_status(prev_button, self.move_count <= 0)
+       set_button_status(self.prev_button, self.move_count <= 0)
-       set_button_status(next_button, self.move_count >= len(self.board_records) - 1)
+       set_button_status(self.next_button, self.move_count >= len(self.board_records) - 1)
-       set_button_status(last_button, self.move_count >= len(self.board_records) - 1)
+       set_button_status(self.last_button, self.move_count >= len(self.board_records) - 1)

    update_widgets_status()

Marubatsu.draw_board = draw_board

上記の修正後に、下記のプログラムで gui_play を実行して AI どうしの対戦を GUI で行うと、実行結果のように << と < ボタンが緑色で表示され、リプレイ機能を利用できるようになります。実際にリプレイ機能のボタンをクリックして確認して下さい。

gui_play(ai=[ai1, ai1])

実行結果(下図は、画像なので操作することはできません)

また、図は省略しますが、人間 VS AI と AI VS 人間でもリプレイ機能を利用できるようになったことを確認して下さい。

クラスによる GUI の機能の分離

ここまでで、リプレイ機能の主要な機能は完成しましたが、play メソッドの内容がかなり長く、複雑になりすぎてプログラムがわかりづらくなったと感じている人が多いかもしれません。また、以下のような点が気になっている人はいないでしょうか?

  • first_button などのリプレイに関するボタンはインスタンスのメソッドに代入されているが、reset_button などのボタンはあいかわらずローカル変数に代入されている
  • GUI に関する多くの関数は、play メソッドのローカル関数として定義されているが、draw_boardMarubatsu クラスのメソッドとして定義されている

上記のような、統一性がないプログラム は、バグの原因になる可能性が高くなる ので、避けたほうが良い でしょう。

また、これまでのプログラムで、first_buttonupdate_widgets_status などを、Marubatsu クラスの属性やメソッドではなく、play メソッドのローカル変数やローカル関数としてきたのは、以下のような理由からでした。

  • それらが play メソッドの中でしか利用されないデータや関数であったため
  • Marubatsu クラスの属性やメソッドを増やすと、クラスの構成が複雑になり、わかりづらくなるため

GUI に関する処理が少なければローカル変数やローカル関数で処理を記述してもあまり問題はありませんが、現状の play メソッドのように長く、複雑になった場合は、もっとわかりやすい方法で記述したほうが良いでしょう。

プログラミムの記述の方法に、唯一の正解はありません。また、人によってわかりやすいプログラムは異なります。データや関数をローカル変数やローカル関数で記述するか、属性やメソッドで記述するかは、一長一短があるので、どちらかが必ずしも優れているとは限らない点に注意して下さい。

本記事で行ってきたように、必要な機能を、必要になった時点で実装していく という方法でプログラムを記述すると、どうしてもプログラムの変数や関数などの構成が複雑になったり、統一性がなくなってわかりづらくなることが良くあります。そのような場合は、プログラムの構成を整理して作り直すことが良く行われます。そのようなことを体験することは、プログラミングの能力の向上につながると思いますので実例を紹介することにします。

現実の世界で例えると、例えば自分の部屋の本棚に購入した本を入れる場合、本の数が少ないうちは、その場でその本を入れる場所を考えて入れても大きな問題は発生しないでしょう。しかし、本の数がある程度以上増えてくると、本の配置に矛盾が生じたりしてわかりづらくなるため、本の配置を整理し直す必要が生じるでしょう。

〇×ゲームの場合は、AI で対戦を行う機能を実装後に、play メソッド内に GUI に関する処理を追加して記述しましたが、GUI に関する機能 は、〇×ゲームを AI で遊ぶ際に必要となる 必須の機能ではありません。また、GUI に関する処理を Marubatsu クラスの中に記述すると、必須の機能とそうでない機能が混ざってわかりづらくなります。そこで、Marubatsu クラスの中から GUI に関する処理を分離する ことで、Marubatsu クラスの 構成をシンプルにしてわかりやすくする ことにします。具体的には、GUI に関する処理別のクラスを定義して記述する ことで、Marubatsu クラスから分離する ことにします。

なお、関数ではなく、クラスという形で分離することにしたのは、以下の理由からです。

  • GUI に関する処理は、play メソッドで行うウィジェットの定義や配置などに関する処理と、draw_board メソッドで行うウィジェットの表示に関する処理がある。それらの処理を クラスのメソッドとして定義する 事で、まとめて扱うことができる ようになる
  • ウィジェットの定義や配置などに関する処理と、ウィジェットの表示に関する処理では、AI やウィジェットの情報などの 共通する情報を扱う必要がある。それぞれを関数で定義すると、共通する情報を仮引数に代入する必要が生じるが、クラスのメソッドとして定義した場合は、それらの情報を属性に代入 することで、仮引数が必要なくなる

後者の理由は、言葉だけの説明ではわかりづらいと思いますが、この後で行う実装を見れば、意味がわかるようになるのではないかと思います。

Marubatsu_GUI クラスの記述の方針

作成するクラスの名前は、〇×ゲームの GUI の処理を行うので、Marubatsu_GUI とします。

クラスには、play メソッド内に記述されていた GUI に関する処理を記述 しますが、play メソッドと同じように記述するとわかりづらいので、下記の方針で記述することにします。

  • ウィジェットの作成ウィジェットの配置ウィジェットの表示の更新イベントハンドラの定義と結び付け などの処理を行う メソッドをそれぞれ定義 する。そうする事で、プログラムの処理が整理 されて、わかりやすくなるという効果が得られる
  • 必要がない限り、ローカル変数やローカル関数を使わずに、属性とメソッドを利用 する
  • draw_board メソッドは GUI に関する処理 なので Marubatsu_GUI のメソッド にする

__init__ の定義

まず、インスタンスを作成した際に実行される __init__ メソッドの定義を行います。そのためには、__init__仮引数を決める必要 があります。もともと GUI の処理は Marubatsu クラスの play メソッド内で行われていた処理なので、Marubatsu_GUI クラスが行う GUI の処理に必要となるデータ は、play メソッドの仮引数の一部 です。従って、__init__ メソッドの仮引数に必要となる、play メソッドの仮引数を検証する ことにします。

play メソッドの仮引数と、Marubatsu_GUI での必要性は以下の通りです。なお、必要性が × となっている仮引数が、今後必要になった場合は、その都度追加することにします。

仮引数 意味 必要性 理由
self Marubatsu クラスのインスタンス 様々な場面で必要
ai 手番を担当する AI の list AI の処理を行う際に必要
ai_dict Dropdown の AI の一覧 Dropdown の作成に必要
params AI の関数に渡すパラメータ AI の処理を行う際に必要
verbose 途中経過を表示するか × GUI で遊ぶ場合は必ず
True になるので不要
seed 乱数の種 × 乱数の種の処理は GUI
とは関係がないので不要
gui GUI で遊ぶか × GUI で遊ぶ場合は必ず
True になるので不要
size ゲーム盤の画像のサイズ ゲーム盤の描画の際に必要

なお、play メソッドの selfMarubatsu クラスのインスタンス を表しますが、上記の __init__ のメソッドの selfMarubatsu_GUI クラスのインスタンス を表すので、上記の表の self を代入する __init__ メソッドの 仮引数の名前self 以外の名前 にする必要があります。そこで、本記事では mb という名前にする ことにします。

下記は、Marubatsu_GUI クラスと、__init__ メソッドの定義です。__init__ メソッドには、play メソッドの先頭に記述されていた処理の中で、GUI に関する処理の一部を記述しました。なお、乱数の種に関する処理は GUI には関係がないので記述していません。

  • 2 行目:上記の表の仮引数を記述する。デフォルト引数のデフォルト値は play メソッドと同じ値を設定した
  • 4 ~ 8 行目play メソッド内の、仮引数 ai_dictparams に関する処理を記述する
  • 10 ~ 14 行目__init__ メソッドの 仮引数 は、一般的にそのクラスの さまざまな場所で使われるデータ なので、下記のプログラムの 10 ~ 14 行目のように、それぞれの仮引数の値 を、同じ名前の属性に代入するのが一般的 である。そのようにすることで、クラスのメソッド でそれらの値を 利用できるようになる
  • 17 行目play メソッド内で GUI の処理が記述されている、if gui: のブロックの最初で行われている、%matplotlib widget のマジックコマンドを実行する処理を記述する。なお、それ以降の処理は、この後で順次追加する
 1  class Marubatsu_GUI:
 2      def __init__(self, mb, ai, ai_dict=None, params=None, size=3):
 3          # ai_dict が None の場合は、空の list で置き換える
 4          if ai_dict is None:
 5              ai_dict = {}
 6          # params が None の場合のデフォルト値を設定する
 7          if params is None:
 8              params = [{}, {}]
 9
10          self.mb = mb
11          self.ai = ai
12          self.ai_dict = ai_dict
13          self.ai_params = params
14          self.size = size         
15        
16          # %matplotlib widget のマジックコマンドを実行する
17          get_ipython().run_line_magic('matplotlib', 'widget')
行番号のないプログラム
class Marubatsu_GUI:
    def __init__(self, mb, ai, ai_dict=None, params=None, size=3):
        # ai_dict が None の場合は、空の list で置き換える
        if ai_dict is None:
            ai_dict = {}
        # params が None の場合のデフォルト値を設定する
        if params is None:
            params = [{}, {}]

        self.mb = mb
        self.ai = ai
        self.ai_dict = ai_dict
        self.ai_params = params
        self.size = size         
        
        # %matplotlib widget のマジックコマンドを実行する
        get_ipython().run_line_magic('matplotlib', 'widget')

上記の 4、5、12 行目は、下記のプログラムのように 1 行で記述することもできます。7、8、13 行目も同様です。わかりやすいと思ったほうを採用して下さい。

self.ai_dict = {} if ai_dict is None else ai_dict

下記の Marubatsu クラスの __init__ メソッドでも、上記と同様に、仮引数 board_size を同じ名前の属性に代入しています。なお、BOARD_SIZE 属性を大文字にしているのは、以前の記事で説明したように、後から BOARD_SIZE 属性に代入された値を変更する予定がない定数であることを明確にするためです。

class Marubatsu:
    def __init__(self, board_size=3):
        # ゲーム盤の縦横のサイズ
        self.BOARD_SIZE = board_size
        # 〇×ゲーム盤を再起動するメソッドを呼び出す
        self.restart()

AI を選択する Dropdown を作成する関数の定義

play メソッドで次に記述されているのは、AI を選択する Dropdown を作成する処理 です。そこで、Dropdown を作成する create_dropdown を下記のプログラムのように定義します。基本的には play メソッド内の処理をコピー しますが、以下の点が異なります。なお、Dropdown を作成するために 必要なデータ は、__init__ メソッド内で、インスタンスの属性に代入済 なので、仮引数self 以外を記述する必要はありません。これが、先程説明した、GUI の機能をクラスで定義する事の 2 つ目の理由です。

  • 5、24 行目:ウィジェットをローカル変数ではなく、インスタンスの属性に代入する
  • 9、13、14、18、20、26 行目ai 等のローカル変数を self.ai に変更する
 1  def create_dropdown(self):
 2      # それぞれの手番の担当を表す Dropdown の項目の値を記録する list を初期化する
 3      select_values = []
 4      # 〇 と × の Dropdown を格納する list
 5      self.dropdown_list = []
 6      # ai に代入されている内容を ai_dict に追加する
 7      for i in range(2):
 8          # ラベルと項目の値を計算する
 9          if self.ai[i] is None:
10              label = "人間"
11              value = "人間"
12          else:
13              label = self.ai[i].__name__        
14              value = self.ai[i]
15          # value を select_values に常に登録する
16          select_values.append(value)
17          # value が ai_values に登録済かどうかを判定する
18          if value not in self.ai_dict.values():
19              # 項目を登録する
20              self.ai_dict[label] = value
21    
22          # Dropdown の description を計算する
23          description = "" if i == 0 else "×"
24          self.dropdown_list.append(
25              widgets.Dropdown(
26                  options=self.ai_dict,
27                  description=description,
28                  layout=widgets.Layout(width="100px"),
29                  style={"description_width": "20px"},
30                  value=select_values[i],
31              )
32          )    
33    
34  Marubatsu_GUI.create_dropdown = create_dropdown
行番号のないプログラム
def create_dropdown(self):
    # それぞれの手番の担当を表す Dropdown の項目の値を記録する list を初期化する
    select_values = []
    # 〇 と × の Dropdown を格納する list
    self.dropdown_list = []
    # ai に代入されている内容を ai_dict に追加する
    for i in range(2):
        # ラベルと項目の値を計算する
        if self.ai[i] is None:
            label = "人間"
            value = "人間"
        else:
            label = self.ai[i].__name__        
            value = self.ai[i]
        # value を select_values に常に登録する
        select_values.append(value)
        # value が ai_values に登録済かどうかを判定する
        if value not in self.ai_dict.values():
            # 項目を登録する
            self.ai_dict[label] = value
    
        # Dropdown の description を計算する
        description = "" if i == 0 else "×"
        self.dropdown_list.append(
            widgets.Dropdown(
                options=self.ai_dict,
                description=description,
                layout=widgets.Layout(width="100px"),
                style={"description_width": "20px"},
                value=select_values[i],
            )
        )    
    
Marubatsu_GUI.create_dropdown = create_dropdown
修正箇所
def create_dropdown(self):
    # それぞれの手番の担当を表す Dropdown の項目の値を記録する list を初期化する
    select_values = []
    # 〇 と × の Dropdown を格納する list
-   dropdown_list = []
+   self.dropdown_list = []
    # ai に代入されている内容を ai_dict に追加する
    for i in range(2):
        # ラベルと項目の値を計算する
-       if ai[i] is None:
+       if self.ai[i] is None:
            label = "人間"
            value = "人間"
        else:
-           label = ai[i].__name__        
+           label = self.ai[i].__name__        
-           value = ai[i]
+           value = self.ai[i]
        # value を select_values に常に登録する
        select_values.append(value)
        # value が ai_values に登録済かどうかを判定する
-       if value not in ai_dict.values():
+       if value not in self.ai_dict.values():
            # 項目を登録する
-           ai_dict[label] = value
+           self.ai_dict[label] = value
    
        # Dropdown の description を計算する
        description = "" if i == 0 else "×"
-       dropdown_list.append(
+       self.dropdown_list.append(
            widgets.Dropdown(
-               options=ai_dict,
+               options=self.ai_dict,
                description=description,
                layout=widgets.Layout(width="100px"),
                style={"description_width": "20px"},
                value=select_values[i],
            )
        )    
    
Marubatsu_GUI.create_dropdown = create_dropdown

下記のプログラムの 2、3 行目のように、self.ai などをローカル変数に代入することで、aiai_dictself.ai のように修正する必要が無くなります。

def create_dropdown(self):
    ai = self.ai
    ai_dict = self.ai_dict
以下略

ただし、例えば self.ai = None などのように、self.aiself.ai_dict直接値を代入する処理この後のプログラムで記述する必要がある場合 は、aiself.ai値が別のデータになってしまう ため バグが発生する可能性が生じる 点に注意して下さい。

なお、self.ai[0] = None のように、要素の値を変更する場合は、ai[0] の値も同時に変更されるので問題はありません。

上記の区別がついていない方は、このような修正は避けたほうが良いでしょう

ウィジェットを配置して表示する関数の定義

上記の関数はウィジェットを作成するだけなので、ウィジェットを配置して表示 する、display_widgets という関数を下記のプログラムのように定義します。なお、現時点では、Dropdown しか作成していないので Dropdown を配置して表示する処理だけを記述します。

def display_widgets(self):
    hbox = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1]])
    display(hbox) 
    
Marubatsu_GUI.display_widgets = display_widgets   

次に、下記のプログラムのように、__init__ メソッドの最後の 5、6 行目に Dropdown を作成し、ウィジェットを表示するプログラムを記述します。

1  def __init__(self, mb, ai, ai_dict=None, params=None, size=3):
元と同じなので省略
2      # %matplotlib widget のマジックコマンドを実行する
3      get_ipython().run_line_magic('matplotlib', 'widget')
4    
5      self.create_dropdown()
6      self.display_widgets()
7    
8  Marubatsu_GUI.__init__ = __init__
行番号のないプログラム
def __init__(self, mb, ai, ai_dict=None, params=None, size=3):
    # ai_dict が None の場合は、空の list で置き換える
    if ai_dict is None:
        ai_dict = {}
    # params が None の場合のデフォルト値を設定する
    if params is None:
        params = [{}, {}]

    self.mb = mb
    self.ai = ai
    self.ai_dict = ai_dict
    self.size = size
    
    # %matplotlib widget のマジックコマンドを実行する
    get_ipython().run_line_magic('matplotlib', 'widget')
    
    self.create_dropdown()
    self.display_widgets()
    
Marubatsu_GUI.__init__ = __init__
修正箇所
def __init__(self, mb, ai, ai_dict=None, params=None, size=3):
元と同じなので省略
    # %matplotlib widget のマジックコマンドを実行する
    get_ipython().run_line_magic('matplotlib', 'widget')
    
+   self.create_dropdown()
+   self.display_widgets()
    
Marubatsu_GUI.__init__ = __init__

上記の修正後に、下記のプログラムを実行して Marubatsu_GUI クラスのインスタンスを作成すると、Marubatsu_GUI クラスの __init__ メソッドが実行されるので、実行結果のように Dropdown が表示されます。なお、Marubatsu_GUI の実引数には、〇×ゲームのインスタンス人間 VS 人間 を表すデータを記述しました。また、今夏の記事では利用しませんが、作成した Marubatsu_GUI クラスのインスタンスを mb_gui という名前の変数に代入しました。

mb = Marubatsu()
mb_gui = Marubatsu_GUI(mb, ai=[None, None])

実行結果(下図は、画像なので操作することはできません)

ボタンを作成する関数の定義

次に、play メソッド内の、ボタンを作成するローカル関数 create_buttonMarubatsu_GUI のメソッドとして定義 します。なお、create_button は、Marubatsu_GUIインスタンスの情報を利用しない ので、以前の記事で説明した @staticmethod のデコレータを使って 静的メソッド として定義する事にしました。静的メソッドの意味が良くわからない人は、通常のメソッドとして定義 しても 問題はありません。その場合は、@staticmethod を削除 し、最初の仮引数に self を追加 して下さい。

@staticmethod
# ボタンを作成するローカル関数を定義する 
def create_button(description, width):
    return widgets.Button(
        description=description,
        layout=widgets.Layout(width=f"{width}px"),
        style={"button_color": "lightgreen"},
    )
    
Marubatsu_GUI.create_button = create_button

静的メソッドとして定義する事で、下記のプログラムのように Marubatsu_GUI のインスタンスを作成しなくてもMarubatsu_GUI の後に直接 .create_button を記述して 利用できる という利点が得られます。

display(Marubatsu_GUI.create_button("リセット", 100))

実行結果(下図は、画像なので操作することはできません)

ウィジェットをまとめて作成する関数の定義

次に、create_button を使って 6 つのボタンを作成する処理を __init__ メソッドに直接記述しても良いのですが、ウィジェットの数が多くなるとわかりづらくなるので、下記のプログラムのように、ウィジェットをまとめて作成 する create_widgets というメソッドを定義する事にします。先程 __init__ メソッドに記述した Dropdown を作成する処理を記述し、その後で play メソッドに記述されていた 6 つのボタンを作成する処理を記述します。

特に難しい点はないと思いますので、修正箇所の説明などは省略します。

def create_widgets(self):
    # AI を選択する Dropdown を作成する
    self.create_dropdown()
    # 変更、リセットボタンを作成する
    self.change_button = self.create_button("変更", 100)
    self.reset_button = self.create_button("リセット", 100)
    # リプレイのボタンを作成する
    self.first_button = self.create_button("<<", 100)
    self.prev_button = self.create_button("<", 100)
    self.next_button = self.create_button(">", 100)
    self.last_button = self.create_button(">>", 100)    
    
Marubatsu_GUI.create_widgets = create_widgets

ウィジェットが増えたので、ウィジェットを配置して表示する display_widgets を下記のプログラムのように修正します。play メソッドの該当する処理の記述との違いは、それぞれのウィジェットが代入されたローカル変数の前に self. を記述するだけです。

def display_widgets(self):
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
    hbox1 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
    hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button]) 
    # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2]))        
    
Marubatsu_GUI.display_widgets = display_widgets
修正箇所
def display_widgets(self):
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
-   hbox1 = widgets.HBox([dropdown_list[0], dropdown_list[1], change_button, reset_button])
+   hbox1 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
-   hbox2 = widgets.HBox([first_button, prev_button, next_button, last_button]) 
+   hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button]) 
    # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2]))        
    
Marubatsu_GUI.display_widgets = display_widgets

次に __init__ メソッドを下記のプログラムのように修正します。

  • 2 行目create_dropdowncreate_widgets に修正する
1  def __init__(self, mb, ai, ai_dict=None, params=None, size=3):
元と同じなので省略    
2      self.create_widgets()
3      self.display_widgets()
4      
5  Marubatsu_GUI.__init__ = __init__
行番号のないプログラム
def __init__(self, mb, ai, ai_dict=None, params=None, size=3):
    # ai_dict が None の場合は、空の list で置き換える
    if ai_dict is None:
        ai_dict = {}
    # params が None の場合のデフォルト値を設定する
    if params is None:
        params = [{}, {}]

    self.mb = mb
    self.ai = ai
    self.ai_dict = ai_dict
    self.size = size
    
    # ai_dict が None の場合は、空の list で置き換える
    if ai_dict is None:
        self.ai_dict = {}

    # %matplotlib widget のマジックコマンドを実行する
    get_ipython().run_line_magic('matplotlib', 'widget')
    
    self.create_widgets()
    self.display_widgets()
    
Marubatsu_GUI.__init__ = __init__
修正箇所
def __init__(self, mb, ai, ai_dict=None, params=None, size=3):
元と同じなので省略    
-   self.create_dropdown()
+   self.create_widgets()
    self.display_widgets()
    
Marubatsu_GUI.__init__ = __init__

上記の修正後に、下記のプログラムを実行して Marubatsu_GUI クラスのインスタンスを作成すると、実行結果のように Dropdown とボタンが表示されるようになります。なお、ボタンの設定の変更の処理や、イベントハンドラはまだ記述していないので、すべてのボタンの色は緑色で、クリックしても何も行われません。

gui = Marubatsu_GUI(mb, ai=[None, None])

実行結果(下図は、画像なので操作することはできません)

ゲーム盤を表す Figure を作成する関数

次に、ゲーム盤を表す Figure を作成 する create_figure を下記のプログラムのように定義します。なお、figax は、draw_board メソッド内で ゲーム盤を描画する際などで必要となる ので、Marubatsu_GUI クラスの属性に代入 しました。

def create_figure(self):
    self.fig, self.ax = plt.subplots(figsize=[self.size, self.size])
    self.fig.canvas.toolbar_visible = False
    self.fig.canvas.header_visible = False
    self.fig.canvas.footer_visible = False
    self.fig.canvas.resizable = False   
    
Marubatsu_GUI.create_figure = create_figure
修正箇所
def create_figure(self):
-   fig, ax = plt.subplots(figsize=[size, size])
+   self.fig, self.ax = plt.subplots(figsize=[self.size, self.size])
-   fig.canvas.toolbar_visible = False
+   self.fig.canvas.toolbar_visible = False
-   fig.canvas.toolbar_visible = False
+   self.fig.canvas.toolbar_visible = False
-   fig.canvas.footer_visible = False
+   self.fig.canvas.footer_visible = False
-   fig.canvas.resizable = False   
+   self.fig.canvas.resizable = False   
    
Marubatsu_GUI.create_figure = create_figure

次に、create_widgets メソッドを下記のプログラムのように修正します。

  • 4 行目create_figue を呼び出して、ゲーム盤の画像を表す Figure を作成する
1  def create_widgets(self):
元と同じなので省略
2      self.last_button = self.create_button(">>", 100)    
3      # ゲーム盤の画像を表す figure を作成する
4      self.create_figure()
5
6  Marubatsu_GUI.create_widgets = create_widgets
行番号のないプログラム
def create_widgets(self):
    # AI を選択する Dropdown を作成する
    self.create_dropdown()
    # 変更、リセットボタンを作成する
    self.change_button = self.create_button("変更", 100)
    self.reset_button = self.create_button("リセット", 100)
    # リプレイのボタンを作成する
    self.first_button = self.create_button("<<", 100)
    self.prev_button = self.create_button("<", 100)
    self.next_button = self.create_button(">", 100)
    self.last_button = self.create_button(">>", 100)    
    # ゲーム盤の画像を表す figure を作成する
    self.create_figure()
    
Marubatsu_GUI.create_widgets = create_widgets
修正箇所
def create_widgets(self):
元と同じなので省略
    self.last_button = self.create_button(">>", 100)    
    # ゲーム盤の画像を表す figure を作成する
+   self.create_figure()

Marubatsu_GUI.create_widgets = create_widgets

作成した Figure は自動的に VSCode のセルに描画されるので、display_widgets を修正する必要はありません。上記の修正後に、下記のプログラムを実行して Marubatsu_GUI クラスのインスタンスを作成すると、実行結果のように Dropdown とボタンの下に Figure が表示されるようになります。ただし、ゲーム盤の画像を描画する処理はまだ記述していないので、ゲーム盤の画像はまだ描画されません。

gui = Marubatsu_GUI(mb, ai=[None, None])

実行結果(下図は、画像なので操作することはできません)

ゲーム盤の描画を行うメソッドの定義は少し長くなるので今回の記事はここまでにします。

今回の記事のまとめ

今回の記事では、AI が手番を担当する場合リプレイ機能の問題を修正 しました。

また、GUI に関する処理 を行う Marubatsu_GUI クラスを定義 し、Marubatsu クラスの処理の中から GUI の処理を分離 する作業を開始しました。今回の記事では、Marubatsu_GUI のインスタンスを作成することで、GUI のウィジェットが表示される所まで実装しました。

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

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

以下のリンクは、今回の記事で更新した marubatsu.py です。なお、Marubatsu_GUI クラスは、〇×ゲームに関する処理であることには変わりがないので、Marubatsu クラスの後でその定義を記述することにします。

次回の記事

0
1
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
1