0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonで〇×ゲームのAIを一から作成する その83 GUIの機能の分離の完了と差分による局面の再現

Last updated at Posted at 2024-05-23

目次と前回の記事

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

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

ルールベースの AI の一覧

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

前回の記事で発生しなかったエラー

前回の記事では、Marubatsu_GUI クラスを定義 することで Marubatsu クラスから GUI の機能を分離する作業 の中で、ゲーム盤の描画を行う処理を実装 しました。

実は、前回の記事で記述したプログラムにはバグがあるため、下記のプログラムで gui_play を実行すると、前回の記事で gui_play を実行した際に発生しなかった、実行結果のようなエラーが発生します。このエラーの原因について少し考えてみて下さい。

from util import gui_play

gui_play()

実行結果(ボタンやゲーム盤の画像は省略します)

略
File c:\Users\ys\ai\marubatsu\083\marubatsu.py:665, in Marubatsu_GUI.update_widgets_status(self)
    662 """ウィジェットの状態を更新する."""
    664 # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
--> 665 set_button_status(self.first_button, self.mb.move_count <= 0)
    666 set_button_status(self.prev_button, self.mb.move_count <= 0)
    667 set_button_status(self.next_button, self.mb.move_count >= len(self.mb.board_records) - 1)

NameError: name 'set_button_status' is not defined

エラーの検証と修正

エラーメッセージから、update_widgets_status の中で、定義されていない set_button_status を呼び出そうした ためエラーが発生したことがわかります。

この、set_button_status は、前回の記事Marubatsu_GUI のメソッドとして定義1したものなので、呼び出す際には self.set_button_status のように記述する必要があります。従って、下記のプログラムのように update_widgets_status を修正することでこの問題を解決することができます。

  • 5 ~ 8 行目set_button_statusself.set_button_status に修正する
 1  from marubatsu import Marubatsu_GUI
 2  
 3  def update_widgets_status(self):   
 4      # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
 5      self.set_button_status(self.first_button, self.mb.move_count <= 0)
 6      self.set_button_status(self.prev_button, self.mb.move_count <= 0)
 7      self.set_button_status(self.next_button, self.mb.move_count >= len(self.mb.board_records) - 1)
 8      self.set_button_status(self.last_button, self.mb.move_count >= len(self.mb.board_records) - 1)          
 9
10  Marubatsu_GUI.update_widgets_status = update_widgets_status
行番号のないプログラム
from marubatsu import Marubatsu_GUI

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

Marubatsu_GUI.update_widgets_status = update_widgets_status
修正箇所
from marubatsu import Marubatsu_GUI

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

Marubatsu_GUI.update_widgets_status = update_widgets_status

実行結果は省略しますが、上記の修正後に、下記のプログラムで gui_play を実行してエラーが発生しなくなることを確認して下さい。

gui_play()

前回の記事でエラーが発生しなかった理由

前回の記事でこのエラーが発生しなかった点 について疑問に思っている人が多いかもしれません。何故、前回の記事でこのエラーが発生しなかったかについて少し考えてみて下さい。

前回の記事では、下記のプログラムで set_button_status メソッドを定義しました。

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

Marubatsu_GUI.set_button_status = set_button_status

このプログラムでは、通常の関数として set_button_status を定義した後 で、Marubatsu_GUI の属性にこの関数を代入 するという形で set_button_status メソッドを定義しました。そのため、上記のプログラムを実行した後 では、set_button_status を通常の関数として呼び出すことが可能 です。これが、前回の記事で update_widgets_status メソッドの中で set_button_status を呼び出してもエラーが発生しなかった原因です。

一方、今回の記事では、VSCode で新しい JupyterLab のファイルを作成したので、そのファイルでプログラムを実行した際には、上記の set_button_status という関数は定義されていません。また、marubatsu.py の中で、set_button_status は、Marubatsu_GUI クラスの定義の中でメソッドとして定義 されていますが、通常の関数としては定義されていない ので、先程のようなエラーが発生します。

これまでの記事で「関数を定義し、その関数をクラスの属性に代入する」という方法でクラスのメソッドの定義を修正してきたのは、修正するメソッドの定義だけを JupyterLab のセルに記述して実行することで、メソッドを修正できるからです。

この方法は個別のメソッドを修正する際は便利ですが、定義した関数を通常の関数として利用できてしまうという点に注意が必要です。

なお、修正したメソッドを marubatsu.py に反映させる際には、クラスの定義の中に修正したメソッドを記述したほうが良いので、そのようにしています。

draw_mark の定義の移動

前回の記事では、GUI に関する機能を Marubatsu クラスから分離するために、ゲーム盤を描画 する処理を行う draw_board メソッドの定義 を、Marubatsu クラスから Marubatsu_GUI クラスのメソッドに移動 しました。筆者は、これで GUI に関する機能を完全に分離できたと勘違いしていたのですが、Marubatsu クラスの定義をよく見ると、ゲーム盤にマークを描画 する draw_mark というメソッドが まだ残っている ことに気づきましたので、この関数も Marubatsu_GUI クラスに移動することにします。

draw_mark静的メソッド として定義したので、前回の記事で 様々なバグの原因となった self は存在しません。そのため、下記のプログラムのように、単純に その定義をコピーするだけMarubatsu_GUI クラスのメソッドとして定義しなおすことができます。

下記の draw_mark の定義は Marubatsu クラスの draw_mark と全く同じです。

from marubatsu import Marubatsu
import matplotlib.patches as patches

@staticmethod
def draw_mark(ax, x, y, mark, color="black"):
    if mark == Marubatsu.CIRCLE:
        circle = patches.Circle([x + 0.5, y + 0.5], 0.35, ec=color, fill=False, lw=2)
        ax.add_artist(circle)
    elif mark == Marubatsu.CROSS:
        ax.plot([x + 0.15, x + 0.85], [y + 0.15, y + 0.85], c=color, lw="2")
        ax.plot([x + 0.15, x + 0.85], [y + 0.85, y + 0.15], c=color, lw="2")

Marubatsu_GUI.draw_mark = draw_mark

次に、draw_markdraw_board メソッドの中から呼び出されている ので、下記のプログラムのように、draw_board を修正する必要があります。

  • 6 行目self.mb.draw_markself.draw_mark に修正することで、Marubatsu_GUI クラスの draw_mark メソッドを呼び出すように修正する
 1  def draw_board(self):
元と同じなので省略
 2      # ゲーム盤のマークを描画する
 3      for y in range(self.mb.BOARD_SIZE):
 4          for x in range(self.mb.BOARD_SIZE):
 5              color = "red" if (x, y) == self.mb.last_move else "black"
 6              self.draw_mark(ax, x, y, self.mb.board[x][y], color)
 7
 8      self.update_widgets_status() 
 9    
10  Marubatsu_GUI.draw_board = draw_board  
行番号のないプログラム
def draw_board(self):
    ax = self.ax
    ai = self.mb.ai
    
    # Axes の内容をクリアして、これまでの描画内容を削除する
    ax.clear()
    
    # y 軸を反転させる
    ax.invert_yaxis()
    
    # 枠と目盛りを表示しないようにする
    ax.axis("off")   
    
    # ゲームの決着がついていた場合は背景色を
    facecolor = "white" if self.mb.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.mb.status == Marubatsu.PLAYING:
        text = "Turn " + self.mb.turn
    # 引き分けの場合
    elif self.mb.status == Marubatsu.DRAW:
        text = "Draw game"
    # 決着がついていれば勝者を表示する
    else:
        text = "Winner " + self.mb.status
    ax.text(0, -0.2, text, fontsize=20)
    
    # ゲーム盤の枠を描画する
    for i in range(1, self.mb.BOARD_SIZE):
        ax.plot([0, self.mb.BOARD_SIZE], [i, i], c="k") # 横方向の枠線
        ax.plot([i, i], [0, self.mb.BOARD_SIZE], c="k") # 縦方向の枠線   

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

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

    self.update_widgets_status() 
    
Marubatsu_GUI.draw_board = draw_board  

実行結果は省略しますが、上記の修正後に、下記のプログラムで gui_play を実行し、正しい処理が行われることを確認して下さい。

gui_play()

イベントハンドラに関する関数の定義

次は、play メソッドに元々記述されていた、イベントハンドラを定義し、ボタンなどに結びつける処理 を実装します。イベントハンドラも Marubatsu_GUI クラスのメソッドとして定義すればよいのではないかと思う人がいるかもしれませんが、イベントハンドラ は、Marubatsu_GUI クラスのインスタンスからではなく、ipywidgets のイベントループの中から呼び出される ので、イベントハンドラに実引数で Marubatsu_GUI クラスのインスタンスの情報を渡すことはできません。そのため、イベントハンドラの中で Marubatsu_GUI の情報を利用するためには、play メソッドで行っていたのと同様に、Marubatsu_GUI クラスのメソッドの ローカル関数としてイベントハンドラを定義 する必要があります。

create_event_handler のひな形

そこで、イベントハンドラをローカル関数として定義し、ウィジェットに結びつける処理を行う create_event_handler というメソッド定義することにします。

前回の記事で、play メソッドの中から イベントハンドラに関する処理を削除 してしまったので、前々回の記事の play メソッドの中から、イベントハンドラに関する処理を抜き出してコピー することで、下記のプログラムのように create_event_handler を定義し、これを雛形に create_event_handler が正しく動作するように修正 していくことにします。

import math
from copy import deepcopy

def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    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)   
    
    def change_step(step):
        # step の範囲を正しい範囲に修正する
        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)
    
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    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)    
               
Marubatsu_GUI.create_event_handler = create_event_handler

Marubatsu クラスでの change_step の定義

create_event_handler の中で定義されている change_step が行う処理のうち、ゲーム盤の描画を更新する self.draw_board 以外の処理 は、step 手目の局面に移動するという処理であり、GUI の処理ではありません。また、特定の手数の局面に移動 するという処理は、GUI 以外の場合でも利用できた方が便利 ですが、この関数を create_event_handler の中の ローカル関数として定義すると GUI でしか利用できない という欠点が生じます。

そこで、change_step の中の self.draw_board 以外の処理を行うメソッド を 下記のプログラムのように、Marubatsu クラスに同じ名前と仮引数で定義 して移動することにします。このようにすることで、Marubatsu クラスのインスタンス から、change_step を呼び出して、任意の手数の局面に移動できる ようになります。

  • 1 行目:ローカル関数をメソッドとして定義し直すので、仮引数 self を追加する
  • 4 行目の下にあった描画を更新する処理を削除する
1  def change_step(self, step):
元と同じなので省略
2      # 直前の着手を計算する
3      self.last_move = self.records[step]
4      # この下にあった描画を更新する処理を削除する
5
6  Marubatsu.change_step = change_step     
行番号のないプログラム
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    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]

Marubatsu.change_step = change_step
修正箇所
-def change_step(step):
+def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    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)  

Marubatsu.change_step = change_step  

次に、create_event_handler のローカル関数 change_step を下記のプログラムのように修正します。

  • 3 行目:手数を移動する処理を削除し、代わりに先程定義した Marubatsu クラスの change_step を呼び出す
  • 5 行目:描画を更新する処理は GUI の処理なので元のまま残す
1  def create_event_handler(self):
元と同じなので省略
2      def change_step(step):
3          self.mb.change_step(step)
4          # 描画を更新する
5          self.draw_board(ax, ai)        
元と同じなので省略
6               
7  Marubatsu_GUI.create_event_handler = create_event_handler
行番号のないプログラム
def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    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)   
    
    def change_step(step):
        self.mb.change_step(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)
    
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    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)    
               
Marubatsu_GUI.create_event_handler = create_event_handler
修正箇所
def create_event_handler(self):
元と同じなので省略
    def change_step(step):
        # step の範囲を正しい範囲に修正する
-       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.mb.change_step(step)
        # 描画を更新する
        self.draw_board(ax, ai)        
元と同じなので省略
               
Marubatsu_GUI.create_event_handler = create_event_handler

なお、このローカル関数 change_stepMarubatsu_GUI クラスのメソッドとして定義する事もできますが、本記事では以下の理由からローカル関数として定義する事にします。

  • ローカル関数 change_step は、create_event_handler の中で定義されたイベントハンドラの中からしか呼び出されない
  • change_stepMarubatsu_GUI のメソッドとして定義すると、self.change_step と記述した場合 に、Marubatsu クラスと Marubatsu_GUI クラスの どちらの change_step メソッドを呼び出しているかがわかりにくくなる。なお、この問題を解決する別の方法として、change_step とは別の名前を付けるという方法も考えられる

create_event_handler のブロックの中の名前の修正

上記の create_event_handler は、VSCode では下図のように多くの部分に、名前が定義されていないことを表すオレンジ色の波線が表示 されます。

これらの波線は、前回の記事で、play メソッドの中でローカル変数だったものが、Marubatsu_GUI の属性に変更されたことなどが原因で 定義されなくなった名前 を表します。

また、play_loop などの 仮引数が変更されたメソッド などや、前回の記事で多くのバグの原因となった self の意味の混同に関する修正 を行う必要があります。

下記は修正を行う必要がある名前の一覧です。本記事では、これらをすべて修正したプログラムを下記に示しますが、このような複雑で大量の修正を行う必要がある場合は、修正漏れが発生し、プログラムを実行するとエラーが発生する可能性が高い でしょう。そのような場合は、前回の記事で行ったように、エラーが発生するたびに原因を検証して修正するという、地道な作業を行う 必要があります。

Marubatsu クラスの属性になった名前

下記は、先頭に self.mb. をつけるという修正を行う必要があります。

行数
ai 5
self.play_loop 6、48
self.restart 10
move_count 26、29
board_records 32
status 42
move 45

Marubatsu_GUI クラスの属性になった名前

下記は、先頭に self. をつけるという修正を行う必要があります。

行数
dropdown_list 5
change_button 14
reset_button 15
fig 51
  • 仮引数が変化したメソッド
変更点 行数
play_loop 仮引数が無くなった 6、48
draw_board 仮引数が無くなった 20、46

変更してはいけない名前

下記の名前は Marubatsu_GUI の属性なので、変更してはいけない点に注意して下さい。
* self.draw_board
* self.first_button
* self.prev_button
* self.next_button
* self.last_button

なお、上記の修正は、下記の理由から前回の記事で紹介したシンボル名の変更の機能を利用することはできないので、検索や置換の機能を使って地道に行う必要があります。

  • ai などは、名前が定義されていないので 文法的に正しくない
  • 異なる意味の self が混在する ので、シンボル名の変更を行うと、変更してはいけない self までもが変更されてしまう

修正した create_event_handler の定義

下記は、上記の修正を行った create_event_handler の定義を行うプログラムです。

 1  def create_event_handler(self):
 2      # 変更ボタンのイベントハンドラを定義する
 3      def on_change_button_clicked(b):
 4          for i in range(2):
 5              self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
 6          self.mb.play_loop()
 7
 8      # リセットボタンのイベントハンドラを定義する
 9      def on_reset_button_clicked(b):
10          self.mb.restart()
11          on_change_button_clicked(b)
12        
13      # イベントハンドラをボタンに結びつける
14      self.change_button.on_click(on_change_button_clicked)
15      self.reset_button.on_click(on_reset_button_clicked)   
16    
17      def change_step(step):
18          self.mb.change_step(step)
19          # 描画を更新する
20          self.draw_board() 
21
22      def on_first_button_clicked(b):
23          change_step(0)
24
25      def on_prev_button_clicked(b):
26          change_step(self.mb.move_count - 1)
27
28      def on_next_button_clicked(b):
29          change_step(self.mb.move_count + 1)
30        
31      def on_last_button_clicked(b):
32          change_step(len(self.mb.board_records) - 1)
33
34      self.first_button.on_click(on_first_button_clicked)
35      self.prev_button.on_click(on_prev_button_clicked)
36      self.next_button.on_click(on_next_button_clicked)
37      self.last_button.on_click(on_last_button_clicked)
38    
39      # ゲーム盤の上でマウスを押した場合のイベントハンドラ
40      def on_mouse_down(event):
41          # Axes の上でマウスを押していた場合のみ処理を行う
42          if event.inaxes and self.mb.status == Marubatsu.PLAYING:
43              x = math.floor(event.xdata)
44              y = math.floor(event.ydata)
45              self.mb.move(x, y)                
46              self.draw_board()
47              # 次の手番の処理を行うメソッドを呼び出す
48              self.mb.play_loop()
49            
50      # fig の画像にマウスを押した際のイベントハンドラを結び付ける
51      self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)    
52               
53  Marubatsu_GUI.create_event_handler = create_event_handler
行番号のないプログラム
def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    def on_change_button_clicked(b):
        for i in range(2):
            self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
        self.mb.play_loop()

    # リセットボタンのイベントハンドラを定義する
    def on_reset_button_clicked(b):
        self.mb.restart()
        on_change_button_clicked(b)
        
    # イベントハンドラをボタンに結びつける
    self.change_button.on_click(on_change_button_clicked)
    self.reset_button.on_click(on_reset_button_clicked)   
    
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.draw_board()        

    def on_first_button_clicked(b):
        change_step(0)

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

    def on_next_button_clicked(b):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b):
        change_step(len(self.mb.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)
    
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    def on_mouse_down(event):
        # Axes の上でマウスを押していた場合のみ処理を行う
        if event.inaxes and self.mb.status == Marubatsu.PLAYING:
            x = math.floor(event.xdata)
            y = math.floor(event.ydata)
            self.mb.move(x, y)                
            self.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
            self.mb.play_loop()
            
    # fig の画像にマウスを押した際のイベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)    
               
Marubatsu_GUI.create_event_handler = create_event_handler
修正箇所
def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    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.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
-       self.play_loop(ai=ai, ax=ax, params=params, verbose=verbose, gui=gui)
+       self.mb.play_loop()

    # リセットボタンのイベントハンドラを定義する
    def on_reset_button_clicked(b):
-       self.restart()
+       self.mb.restart()
        on_change_button_clicked(b)
        
    # イベントハンドラをボタンに結びつける
-   change_button.on_click(on_change_button_clicked)
+   self.change_button.on_click(on_change_button_clicked)
-   reset_button.on_click(on_reset_button_clicked)   
+   self.reset_button.on_click(on_reset_button_clicked)   
    
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
-       self.draw_board(ai, ax)        
+       self.draw_board()        

    def on_first_button_clicked(b):
        change_step(0)

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

    def on_next_button_clicked(b):
-       change_step(self.move_count + 1)
+       change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b):
-       change_step(len(self.board_records) - 1)
+       change_step(len(self.mb.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)
    
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    def on_mouse_down(event):
        # Axes の上でマウスを押していた場合のみ処理を行う
-       if event.inaxes and self.status == Marubatsu.PLAYING:
+       if event.inaxes and self.mb.status == Marubatsu.PLAYING:
            x = math.floor(event.xdata)
            y = math.floor(event.ydata)
-           self.move(x, y)                
+           self.mb.move(x, y)                
-           self.draw_board(ai, ax)
+           self.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
-           self.play_loop(ai=ai, ax=ax, params=params, verbose=verbose, gui=gui)
+           self.mb.play_loop()
            
    # fig の画像にマウスを押した際のイベントハンドラを結び付ける
-   fig.canvas.mpl_connect("button_press_event", on_mouse_down)    
+   self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)    
               
Marubatsu_GUI.create_event_handler = create_event_handler

このように、バグの修正はかなり地道な作業が必要になりますが、避けて通ることはできません。個人的には、プログラミングで重要となる能力の一つ が、何度も何度も発生するバグを修正する 忍耐力 ではないかと思っています。

次に、下記のプログラムの 3 行目のように、Marubatsu_GUI__init__ 内で、create_event_handler を呼び出すように修正します。

1  def __init__(self, mb, ai_dict=None, size=3):
元と同じなので省略
2      self.create_widgets()
3      self.create_event_handler()
4      self.display_widgets() 
5        
6  Marubatsu_GUI.__init__ = __init__

行番号のないプログラム
def __init__(self, mb, ai_dict=None, size=3):
    # ai_dict が None の場合は、空の list で置き換える
    if ai_dict is None:
        ai_dict = {}

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

実行結果は省略しますが、上記の修正後に、下記のプログラムで、gui_play を実行すると、ゲーム盤のクリックによる着手や、リセットボタンやリプレイボタンなどが正しく動作するようになることが確認できます。

gui_play()

AI が手番を担当した場合のエラーと修正

上記では、人間どうしが対戦した場合の確認を行いましたが、Dropdown で AI を選択して AI どうしが対戦をおこなうようにしてからリセットボタンをクリックすると、下記のようなエラーが発生します。このエラーに関しては、これまでの知識では理解できないと思います。

略
File c:\Users\ys\Anaconda3\envs\marubatsu\Lib\site-packages\ipywidgets\widgets\widget.py:512, in Widget.__deepcopy__(self, memo)
    511 def __deepcopy__(self, memo):
--> 512     raise NotImplementedError("Widgets cannot be copied; custom implementation required")

NotImplementedError: Widgets cannot be copied; custom implementation required

上記のエラーメッセージは、以下のような意味を持ちます。

  • NotImplementedError
    実装(Implemented)されていない(Not)ことを表すエラー
  • Widgets cannot be copied; custom implementation required
    ウィジェット(Widgets)はコピーできない(can not be copied)。(ウィジェットをコピーするためには)独自の(custon)実装(implementation)が必要である(required)

エラーの原因の検証

エラーメッセージから、ウィジェットをコピーしようとして発生したエラー であることがわかります。そこで、どこでウィジェットがコピーされたかを検証 することにします。

このエラーは、人間どうしの対戦では発生しなかったので、AI が着手を選択する処理の中で発生している可能性が高い と推測できます。先ほどの エラーメッセージをさかのぼってみてみる と、下記のように、AI が着手を選択する際に実行する ai_by_score の中で、deepcopy(mb_orig) を呼び出した際にこのエラーが発生する ことがわかります。

File c:\Users\ys\ai\marubatsu\083\ai.py:130, in ai_by_score(mb_orig, eval_func, debug, rand)
    128 dprint(debug, "=" * 20)
    129 dprint(debug, "move", move)
--> 130 mb = deepcopy(mb_orig)
    131 x, y = move
    132 mb.move(x, y)

deepcopy以前の記事で説明したように、深いコピー を行う組み込み関数です。深いコピーは、オブジェクトから辿ることができるすべての属性をコピーする処理 が行われるので、この部分では下記のような処理が行われます。

  • mb_origMarubatsu クラスのインスタンスなので、Marubatsu クラスのインスタンスの深いコピー の処理が行われる
  • deepcopy は、mg_origすべての属性をコピー するという処理を行う
  • Marubatsu クラスのインスタンスには、mb_gui という、Marubatsu_GUI クラスのインスタンスが代入された属性 が存在する
  • deepcopymb_gui 属性をコピー する際に、mb_gui に代入された Marubatsu_GUI クラスのインスタンスの すべての属性をコピー するという処理を行う
  • mb_gui 属性 には、first_button など の、ウィジェットが代入された属性が存在する

上記から、deepcopy(mb_orig) によって、ウィジェットをコピーする処理が行われる ことが確認できました。

エラーの修正

これまでの記事では説明していませんでしたが、deepcopy には、ウィジェットなど、コピーすることができないオブジェクト があり、そのようなオブジェクトをコピーしようとすると、先程のような NotImplementedError というエラーが発生 します。従って、このエラーを修正するためには、Marubatsu クラスのインスタンスから辿れるの属性の中 に、ウィジェットのデータが存在しないようにする 必要があります。

ウィジェットのデータが代入 されているのは、Marubatsu_GUI クラスのインスタンスが代入されている mb_gui 属性 です。Marubatsu_GUI クラス は 〇×ゲームを GUI で遊ぶために必要な処理を行う ものですが、AI が着手を選択する際に GUI の機能は必要がありません。そのため、Marubatsu クラスのインスタンスに mb_gui 属性が存在しなくても AI が着手を選択することができます。そこで、Marubatsu クラスのインスタンスから mg_gui 属性を削除する ことで、上記の問題を解決することにします。

具体的には、下記のようにプログラムを修正します。

  • play メソッド内で作成する Marubatsu_GUI クラスのインスタンスを ローカル変数 mb_gui に代入 する
  • play_loopself.mb_gui が利用できなくなる ので、その代わりに Marubatsu_GUI クラスのインスタンスを代入する 仮引数 mb_guiplay_loop に追加する
  • play_loop を呼び出す処理の 実引数に Marubatsu_GUI クラスのインスタンスを記述 する

deepcopy がコピーするデータは 、オブジェクトから辿れる 属性の値だけ で、ローカル変数の値はコピーしません。従って、上記の修正を行うことで、deepcopy がウィジェットのデータをコピーしなくなるため、エラーが発生しなくなります。

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

  • 4 行目Marubatsu_GUI のインスタンスをローカル変数 mb_gui に代入するようにする
  • 7 行目play_loop の実引数に mb_gui を追加する
1  def play(self, ai, ai_dict=None, params=None, verbose=True, seed=None, gui=False, size=3):   
元と同じなので省略
2      # gui が True の場合に、GUI の処理を行う Marubatsu_GUI のインスタンスを作成する
3      if gui:
4          mb_gui = Marubatsu_GUI(self, ai_dict=ai_dict, size=size)    
5        
6      self.restart()
7      return self.play_loop(mb_gui)
8
9  Marubatsu.play = play
行番号のないプログラム
def play(self, ai, ai_dict=None, params=None, verbose=True, seed=None, gui=False, size=3):   
    # params が None の場合のデフォルト値を設定する
    if params is None:
        params = [{}, {}]
        
    # 一部の仮引数をインスタンスの属性に代入する
    self.ai = ai
    self.params = params
    self.verbose = verbose
    self.gui = gui

    # seed が None でない場合は、seed を乱数の種として設定する
    if seed is not None:
        random.seed(seed)

    # gui が True の場合に、GUI の処理を行う Marubatsu_GUI のインスタンスを作成する
    if gui:
        mb_gui = Marubatsu_GUI(self, ai_dict=ai_dict, size=size)    
        
    self.restart()
    return self.play_loop(mb_gui)

Marubatsu.play = play
修正箇所
def play(self, ai, ai_dict=None, params=None, verbose=True, seed=None, gui=False, size=3):   
元と同じなので省略
    # gui が True の場合に、GUI の処理を行う Marubatsu_GUI のインスタンスを作成する
    if gui:
-       self.mb_gui = Marubatsu_GUI(self, ai_dict=ai_dict, size=size)    
+       mb_gui = Marubatsu_GUI(self, ai_dict=ai_dict, size=size)    
        
    self.restart()
-   return self.play_loop()
+   return self.play_loop(mb_gui)

Marubatsu.play = play

上記の 4 行目の修正は、前回の記事で行った修正を元に戻す という修正でなので、前回の記事の修正は無駄だったのではないか と思った人がいるかもしれません。

確かに、結果として は前回の記事の修正は 無駄のように見えるかもしれません が、前回の記事の時点 では 意味のある修正 であり、今回の記事で前回とは 事情が変化したため 元に戻したので、一概には無駄であると言えません

プログラムでは、それほど頻繁ではありませんが、一度何らかの理由で修正した内容 を、後から別の理由で元に戻す ということが実際行われることがあります。

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

  • 1 行目:仮引数 mb_gui を追加する
  • 5、9 行目self.mb_guimb_gui に修正する
 1  def play_loop(self, mb_gui):
元と同じなので省略
 2             if gui:
 3                  # AI どうしの対戦の場合は画面を描画しない
 4                  if ai[0] is None or ai[1] is None:
 5                      mb_gui.draw_board()
元と同じなので省略
 6      # 決着がついたので、ゲーム盤を表示する
 7      if verbose:
 8          if gui:
 9              mb_gui.draw_board()
10          else:
11              print(self)
12            
13      return self.status
14
15  Marubatsu.play_loop = play_loop
行番号のないプログラム
def play_loop(self, mb_gui):   
    ai = self.ai
    params = self.params
    verbose = self.verbose
    gui = self.gui
    
    # ゲームの決着がついていない間繰り返す
    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:
                    mb_gui.draw_board()
                # 手番を人間が担当する場合は、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:
            mb_gui.draw_board()
        else:
            print(self)
            
    return self.status

Marubatsu.play_loop = play_loop
修正箇所
-def play_loop(self):
+def play_loop(self, mb_gui):
元と同じなので省略
            if gui:
                # AI どうしの対戦の場合は画面を描画しない
                if ai[0] is None or ai[1] is None:
-                   self.mb_gui.draw_board()
+                   mb_gui.draw_board()
元と同じなので省略
    # 決着がついたので、ゲーム盤を表示する
    if verbose:
        if gui:
-           self.mb_gui.draw_board()
+           mb_gui.draw_board()
        else:
            print(self)
            
    return self.status

Marubatsu.play_loop = play_loop

play_loopcreate_event_handler 内で呼び出されているので、その部分を下記のプログラムのように修正します。

  • 6、16 行目play_loop の実引数に、Marubatsu_GUI のインスタンスを表す self を追加する。紛らわくてわかりづらいが、play_loop の仮引数 self には Marubatsu クラスのインスタンスである self.mb が、play_loop の仮引数 mb_gui には、Marubatsu_GUI クラスのインスタンスである self が代入される
 1  def create_event_handler(self):
元と同じなので省略
 2      # 変更ボタンのイベントハンドラを定義する
 3      def on_change_button_clicked(b):
 4          for i in range(2):
 5              self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
 6          self.mb.play_loop(self)
元と同じなので省略
 7      # ゲーム盤の上でマウスを押した場合のイベントハンドラ
 8      def on_mouse_down(event):
 9          # Axes の上でマウスを押していた場合のみ処理を行う
10          if event.inaxes and self.mb.status == Marubatsu.PLAYING:
11              x = math.floor(event.xdata)
12              y = math.floor(event.ydata)
13              self.mb.move(x, y)                
14              self.draw_board()
15              # 次の手番の処理を行うメソッドを呼び出す
16              self.mb.play_loop(self)
元と同じなので省略
17               
18  Marubatsu_GUI.create_event_handler = create_event_handler
行番号のないプログラム
def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    def on_change_button_clicked(b):
        for i in range(2):
            self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
        self.mb.play_loop(self)

    # リセットボタンのイベントハンドラを定義する
    def on_reset_button_clicked(b):
        self.mb.restart()
        on_change_button_clicked(b)
        
    # イベントハンドラをボタンに結びつける
    self.change_button.on_click(on_change_button_clicked)
    self.reset_button.on_click(on_reset_button_clicked)   
    
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.draw_board()        

    def on_first_button_clicked(b):
        change_step(0)

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

    def on_next_button_clicked(b):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b):
        change_step(len(self.mb.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)
    
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    def on_mouse_down(event):
        # Axes の上でマウスを押していた場合のみ処理を行う
        if event.inaxes and self.mb.status == Marubatsu.PLAYING:
            x = math.floor(event.xdata)
            y = math.floor(event.ydata)
            self.mb.move(x, y)                
            self.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
            self.mb.play_loop(self)
            
    # fig の画像にマウスを押した際のイベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)    
               
Marubatsu_GUI.create_event_handler = create_event_handler
修正箇所
def create_event_handler(self):
元と同じなので省略
    # 変更ボタンのイベントハンドラを定義する
    def on_change_button_clicked(b):
        for i in range(2):
            self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
-       self.mb.play_loop()
+       self.mb.play_loop(self)
元と同じなので省略
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    def on_mouse_down(event):
        # Axes の上でマウスを押していた場合のみ処理を行う
        if event.inaxes and self.mb.status == Marubatsu.PLAYING:
            x = math.floor(event.xdata)
            y = math.floor(event.ydata)
            self.mb.move(x, y)                
            self.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
-           self.mb.play_loop()
+           self.mb.play_loop(self)
元と同じなので省略
               
Marubatsu_GUI.create_event_handler = create_event_handler

実行結果は省略しますが、上記の修正後に、下記のプログラムで、gui_play を実行すると、手番を AI が担当しても、ゲーム盤のクリックによる着手や、リセットボタンやリプレイボタンなどが正しく動作するようになることが確認できます。

gui_play()

CUI での動作確認

GUI の機能を実装するためにさまざまなプログラムの変更を行ってきたので、CUI でプログラムが正しく動作するか を下記のプログラムで確認することにします。実行結果から、エラーが発生することがわかります。このエラーの原因について少し考えてみて下さい。

from ai import ai1s
mb = Marubatsu()

mb.play(ai=[ai1s, ai1s])

実行結果

---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
Cell In[17], line 4
      1 from ai import ai1s
      2 mb = Marubatsu()
----> 4 mb.play(ai=[ai1s, ai1s])

Cell In[13], line 17
     14     mb_gui = Marubatsu_GUI(self, ai_dict=ai_dict, size=size)    
     16 self.restart()
---> 17 return self.play_loop(mb_gui)

UnboundLocalError: cannot access local variable 'mb_gui' where it is not associated with a value

エラーの検証と修正

エラーメッセージから、play メソッド内で self.play_loop(mb_gui) を呼び出す際 に、mb_gui に値が代入されていない ことがわかります。play メソッドの中で mb_guiguiTrue の場合だけ値が代入される ので、guiFalse の場合 に何らかの 値を代入する必要があります。どのような値を入力すればよいかについて少し考えてみて下さい。

mb_guiplay_loop の中で、guiTrue の場合のみ利用されます。従って、guiFalse の場合 は、mb_gui には何が代入されていてもかまわない ことがわかります。

そこで、本記事では、下記のプログラムの 5、6 行目のようには、guiFalse の場合は mb_guiNone を代入する ことにします。

1  def play(self, ai, ai_dict=None, params=None, verbose=True, seed=None, gui=False, size=3):   
元と同じなので省略
2      # gui が True の場合に、GUI の処理を行う Marubatsu_GUI のインスタンスを作成する
3      if gui:
4          mb_gui = Marubatsu_GUI(self, ai_dict=ai_dict, size=size)  
5      else:
6          mb_gui = None
元と同じなので省略
7
8  Marubatsu.play = play
行番号のないプログラム
def play(self, ai, ai_dict=None, params=None, verbose=True, seed=None, gui=False, size=3):   
    # params が None の場合のデフォルト値を設定する
    if params is None:
        params = [{}, {}]
        
    # 一部の仮引数をインスタンスの属性に代入する
    self.ai = ai
    self.params = params
    self.verbose = verbose
    self.gui = gui
    
    # seed が None でない場合は、seed を乱数の種として設定する
    if seed is not None:
        random.seed(seed)

    # gui が True の場合に、GUI の処理を行う Marubatsu_GUI のインスタンスを作成する
    if gui:
        mb_gui = Marubatsu_GUI(self, ai_dict=ai_dict, size=size)  
    else:
        mb_gui = None
        
    self.restart()
    return self.play_loop(mb_gui)

Marubatsu.play = play
修正箇所
def play(self, ai, ai_dict=None, params=None, verbose=True, seed=None, gui=False, size=3):   
元と同じなので省略
    # gui が True の場合に、GUI の処理を行う Marubatsu_GUI のインスタンスを作成する
    if gui:
        mb_gui = Marubatsu_GUI(self, ai_dict=ai_dict, size=size)  
    else:
        mb_gui = None
元と同じなので省略

Marubatsu.play = play

上記のプログラムが行う処理は、GUI の機能を分離する前の play メソッドで、guiFalse の場合に axNone を代入していたのと同様です。

実行結果は省略しますが、下記のプログラムを実行することで、CUI でも正しくプログラムが動作することが確認できます。下記は AI どうしの対戦ですが、人間を含めた CUI の対戦でも正しくプログラムが動作することを確認してみて下さい。

mb.play(ai=[ai1s, ai1s])

これでリプレイのために最低限必要な機能は実装できましたが、まだいくつか改良できる部分があるので、その方法を紹介したいと思います。

着手の記録による局面の再現

これまでは、ゲーム盤のデータを記録する方法 として、以前の記事で紹介した、board_records 属性に代入された list の要素 に、それぞれの手数の局面を表すデータを代入する という方法を採用してきました。この方法は プログラムの記述が簡単に行える という利点がある一方で、board_records 属性に 最大で 0 手目から 9 手目までの、10 個の局面のデータを保存する必要がある という欠点があります。

Marubatsu クラスのインスタンスの 属性に記録するデータの量が増える と、その分だけ AI が着手を行う際に実行する deepcopy の処理に時間がかかってしまう ため、ゲーム盤のデータを記録する 前と後 では、AI が対戦したときにかかる時間が増えてしまいます

そのことを示すために、下記のプログラムで ai1s どうし(他の AI でもかまいません)が 10000 回対戦した時にかかる時間を計測 してみることにします。実行結果は重要ではないので省略しますが、筆者のパソコンで下記のプログラムを実行すると 約 67 秒 かかりました。

from ai import ai_match

ai_match(ai=[ai1s, ai1s])

着手の記録を使った局面の再現の仕組み

Marubatsu クラスのインスタンスには、各手数で行われた 着手を記録する records 属性 が存在しますが、このデータを利用 すれば、board_records 属性のデータが存在しなくても好きな手数の局面を再現する ことができます。その方法について少し考えてみて下さい。

例えば、3 手目の局面は以下の手順で再現できます。

  • ゲーム盤をゲーム開始時の状態にする
  • records 属性から 3 手分の着手のデータを順番に取り出し、着手を行う

この方針に従って、任意の手数の局面に移動する Marubatsu クラスの change_step メソッドを修正する方法について少し考えてみて下さい。

各手数で行われた着手のデータは、前の局面と次の局面の差異を表すデータ なので、このようなデータのことを 差分データ と呼びます。

〇×ゲームに限らず、オセロ、将棋、囲碁などのデータは、差分を表す着手のデータがあれば、任意の局面のデータを再現できる ので、すべての局面のデータを記録するかわりに、着手のデータだけを記録 することで、記録する必要があるデータの量を大幅に削減する ことが良く行われます。

例えば、将棋のゲーム盤は 81 マスあるので、将棋の各局面のデータを記録するためにはかなりの量のデータが必要なりますが、着手のデータは局面のデータと比較すると数十分の一以下の小さなデータで表現できます。また、将棋はゲームが終了するまで平均すると約 100 回の着手が行われるので、着手のデータだけを記録することで、記録するデータを実際に大幅に削減することができます。

余談ですが、動画は画像を高速にパラパラ漫画のように切り替えて表示するという仕組みになっています。それぞれの画像の内容はほとんど変わらないので、すべての画像をそのまま記録する代わりに、前の画像と次の画像の差分のデータだけを記録することで、動画のデータを大幅に小さくするという工夫が行われています。

board_records 属性の削除

まず、不要になった board_records 属性を削除する ことから始めることにします。board_records 属性は多くの場所で記述されているので少々面倒ですが、VSCode の Ctrl + F による検索機能 を使って、board_records を検索すると良い でしょう。

下記は、restart メソッドから board_records を削除したプログラムです。

def restart(self):
元と同じなので省略
    self.records = [self.last_turn]
    # この下にあった board_records 属性を初期化する処理を削除する
    
Marubatsu.restart = restart
行番号のないプログラム
def restart(self):
    self.initialize_board()
    self.turn = Marubatsu.CIRCLE     
    self.move_count = 0
    self.status = Marubatsu.PLAYING
    self.last_move = -1, -1          
    self.last_turn = None
    self.records = [self.last_turn]
    
Marubatsu.restart = restart
修正箇所
def restart(self):
元と同じなので省略
    self.records = [self.last_turn]
-   self.board_records = [deepcopy(self.board)]      

Marubatsu.restart = restart

下記は、move メソッドから board_records を削除したプログラムです。なお、board_records 属性を削除することで、元のプログラムの len(self.board_records) を修正 する必要があります。board_records の要素の数は、records 属性の要素の数と同じ なので、len(self.records) に修正します。

  • 3 行目len(self.board_records)len(self.records) に修正する
  • 4、7、10 行目の下にあった board_records 属性に関する処理を削除する
 1  def move(self, x, y):
 2      if self.place_mark(x, y, self.turn):
元と同じなので省略
 3          if len(self.records) <= self.move_count:            
 4              # この下にあった board_records 属性に関する処理を削除する
 5              self.records.append(self.last_move)
 6          else:
 7              # この下にあった board_records 属性に関する処理を削除する
 8              self.records[self.move_count] = self.last_move
 9              self.records = self.records[0:self.move_count + 1]
10              # この下にあった board_records 属性に関する処理を削除する
11
12  Marubatsu.move = move
行番号のないプログラム
def move(self, x, y):
    if self.place_mark(x, y, self.turn):
        self.last_turn = self.turn
        self.turn = Marubatsu.CROSS if self.turn == Marubatsu.CIRCLE else Marubatsu.CIRCLE  
        self.move_count += 1
        self.status = self.judge()
        self.last_move = x, y
        if len(self.records) <= self.move_count:            
            self.records.append(self.last_move)
        else:
            self.records[self.move_count] = self.last_move
            self.records = self.records[0:self.move_count + 1]

Marubatsu.move = move
修正箇所
def move(self, x, y):
    if self.place_mark(x, y, self.turn):
元と同じなので省略
-       if len(self.board_records) <= self.move_count:            
+       if len(self.records) <= self.move_count:            
-           self.board_records.append(deepcopy(self.board))
            self.records.append(self.last_move)
        else:
-           self.board_records[self.move_count] = deepcopy(self.board)
            self.records[self.move_count] = self.last_move
            self.records = self.records[0:self.move_count + 1]
-           self.board_records = self.board_records[0:self.move_count + 1]

Marubatsu.move = move

下記は、上記と同様に update_widgets_status メソッドの len(self.board_records) を修正 したプログラムです。

  • 5、6 行目len(self.board_records)len(self.records) に修正する
1  def update_widgets_status(self):   
2      # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
3      self.set_button_status(self.first_button, self.mb.move_count <= 0)
4      self.set_button_status(self.prev_button, self.mb.move_count <= 0)
5      self.set_button_status(self.next_button, self.mb.move_count >= len(self.mb.records) - 1)
6      self.set_button_status(self.last_button, self.mb.move_count >= len(self.mb.records) - 1)          
7
8  Marubatsu_GUI.update_widgets_status = update_widgets_status
行番号のないプログラム
def update_widgets_status(self):   
    # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
    self.set_button_status(self.first_button, self.mb.move_count <= 0)
    self.set_button_status(self.prev_button, self.mb.move_count <= 0)
    self.set_button_status(self.next_button, self.mb.move_count >= len(self.mb.records) - 1)
    self.set_button_status(self.last_button, self.mb.move_count >= len(self.mb.records) - 1)          

Marubatsu_GUI.update_widgets_status = update_widgets_status
修正箇所
def update_widgets_status(self):   
    # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
    self.set_button_status(self.first_button, self.mb.move_count <= 0)
    self.set_button_status(self.prev_button, self.mb.move_count <= 0)
-   self.set_button_status(self.next_button, self.mb.move_count >= len(self.mb.board_records) - 1)
+   self.set_button_status(self.next_button, self.mb.move_count >= len(self.mb.records) - 1)
-   self.set_button_status(self.last_button, self.mb.move_count >= len(self.mb.board_records) - 1)          
+   self.set_button_status(self.last_button, self.mb.move_count >= len(self.mb.records) - 1)          

Marubatsu_GUI.update_widgets_status = update_widgets_status

下記は、上記と同様に create_event_handler メソッドの len(self.board_records) を修正 したプログラムです。

  • 3 行目len(self.board_records)len(self.records) に修正する
1  def create_event_handler(self):
元と同じなので省略
2      def on_last_button_clicked(b):
3          change_step(len(self.mb.records) - 1)
元と同じなので省略
4
5  Marubatsu_GUI.create_event_handler = create_event_handler
行番号のないプログラム
def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    def on_change_button_clicked(b):
        for i in range(2):
            self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
        self.mb.play_loop(self)

    # リセットボタンのイベントハンドラを定義する
    def on_reset_button_clicked(b):
        self.mb.restart()
        on_change_button_clicked(b)
        
    # イベントハンドラをボタンに結びつける
    self.change_button.on_click(on_change_button_clicked)
    self.reset_button.on_click(on_reset_button_clicked)   
    
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.draw_board()        

    def on_first_button_clicked(b):
        change_step(0)

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

    def on_next_button_clicked(b):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b):
        change_step(len(self.mb.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)
    
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    def on_mouse_down(event):
        # Axes の上でマウスを押していた場合のみ処理を行う
        if event.inaxes and self.mb.status == Marubatsu.PLAYING:
            x = math.floor(event.xdata)
            y = math.floor(event.ydata)
            self.mb.move(x, y)                
            self.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
            self.mb.play_loop(self)
            
    # fig の画像にマウスを押した際のイベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)    
               
Marubatsu_GUI.create_event_handler = create_event_handler
修正箇所
def create_event_handler(self):
元と同じなので省略
    def on_last_button_clicked(b):
-       change_step(len(self.mb.board_records) - 1)
+       change_step(len(self.mb.records) - 1)
元と同じなので省略

Marubatsu_GUI.create_event_handler = create_event_handler

change_step メソッドについては、この後でまとめて修正することにします。

change_step メソッドの修正方法

change_step の修正を、下記のプログラムのように行えば良いと思ったはいないでしょうか?この発想は悪くないと思いますが、実際にはこの修正ではうまくいきません。

  • 3 行目len(self.board_records)len(self.records) に修正する
  • 4 行目:ゲームをリセットして、ゲームの開始時の局面にする
  • 5、6 行目records 属性から 1 ~ step 手目の着手 を繰り返し処理を使って 順番に取り出し、move メソッドで着手を行うrecords 属性の 0 番の要素 には None が代入 されているので、1 番の要素から取り出す必要がある 点に注意する事

なお、元のプログラムで行っていた、boardmove_count などを更新する処理move メソッド内で行われる ので 記述する必要はありません

1  def change_step(self, step):
2      # step の範囲を正しい範囲に修正する
3      step = max(0, min(len(self.records) - 1, step))
4      self.restart()
5      for x, y in self.records[1:step+1]:
6          self.move(x, y)
7
8  Marubatsu.change_step = change_step    
行番号のないプログラム
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    step = max(0, min(len(self.records) - 1, step))
    self.restart()
    for x, y in self.records[1:step+1]:
        self.move(x, y)

Marubatsu.change_step = change_step 
修正箇所
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
-   step = max(0, min(len(self.board_records) - 1, step))
+   step = max(0, min(len(self.records) - 1, step))
+   self.restart()
+   for x, y in self.records[1:step+1]:
+       self.move(x, y)
    # 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]

Marubatsu.change_step = change_step 

上記の修正後に、下記のプログラムで gui_play を実行し、いくつかの着手を行った後でリプレイ機能のボタンをクリック すると、どのような場合 でも実行結果のように、ゲーム開始時の局面が表示 され、すべてのリプレイ機能のボタンが灰色で表示 されて操作できなくなります。このような現象が起きる原因について少し考えてみて下さい。

gui_play()

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

問題の原因の検証

この問題の原因は、change_step の中の self.restart() にあります。restart メソッドは、下記のプログラムの 3 行目のように、ゲーム盤をゲーム開始時の状態にするだけではなく、records 属性の値も [self.last_turn] で初期化してしまう からです。

1  def restart(self):

2      self.last_turn = None
3      self.records = [self.last_turn]

従って、下記の change_step は、仮引数 step にどのような値が代入されていた場合でも、下記のような処理が行われ、画面にはゲーム開始時の局面が表示されるようになります。

  • self.last_turn には、上記の restart の 2 行目で None が代入される ので、self.restart() を実行後の self.records には必ず [None] が代入される
  • for x, y in self.records[1:step+1]:1 ~ step 番の要素 を取り出そうとしても、 self.records には 0 番の要素しか代入されておらず、そのような要素は存在しない ため、繰り返し処理のブロック一度も実行されない
  • 結果として、change_step では、self.restart() によってゲームが初期化された後に 何の処理も行われない ので、ゲーム開始時の局面の状態になる
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    step = max(0, min(len(self.records) - 1, step))
    self.restart()
    for x, y in self.records[1:step+1]:
        self.move(x, y)

問題の修正

この問題は、restart メソッドを実行すると、records 属性の値が別の値で置き換わってしまう ことが原因です。従って、下記のプログラムのように、restart メソッドを実行する前 に、records 属性の値を別の変数に代入して取っておく という方法が考えられます。

  • 6 行目self.records の値をローカル変数 records に代入して取っておく
  • 8 行目:取っておいた records を使って繰り返し処理を行うように修正する
 1  def change_step(self, step):
 2      # step の範囲を正しい範囲に修正する
 3      step = max(0, min(len(self.records) - 1, step))
 4      # self.records の値は restart メソッドを呼び出すと初期化されるので
 5      # ローカル変数 records に代入して取っておく
 6      records = self.records
 7      self.restart()
 8      for x, y in records[1:step+1]:
 8          self.move(x, y)
 9
10  Marubatsu.change_step = change_step 
行番号のないプログラム
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    step = max(0, min(len(self.records) - 1, step))
    # self.records の値は restart メソッドを呼び出すと初期化されるので
    # ローカル変数 records に代入して取っておく
    records = self.records
    self.restart()
    for x, y in records[1:step+1]:
        self.move(x, y)

Marubatsu.change_step = change_step    
修正箇所
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    step = max(0, min(len(self.records) - 1, step))
    # self.records の値は restart メソッドを呼び出すと初期化されるので
    # ローカル変数 records に代入して取っておく
+   records = self.records
    self.restart()
-   for x, y in self.records[1:step+1]:
+   for x, y in records[1:step+1]:
        self.move(x, y)

Marubatsu.change_step = change_step 

ところで、上記のプログラムの 4 行目で、records = deepcopy(self.records) のように、self.records をコピーする必要があるのではないかと思った人はいないでしょうか?

上記の場合は コピーを行う必要はありません。その理由は、restart メソッドの中 で、self.records別の新しい list を代入 するという処理を行っているからです。

self.records別の値を代入 すると、recordsself.records共有が解除 され、別々の list のデータが代入 されることになります。そのため、restart メソッドを実行しても、records の値が変化することはありません。また、この後で move メソッドを実行して self.records の要素が変化しても、records の値が変化することはありません

records = deepcopy(self.records) でデータをコピーしてもプログラムは正しく動作しますが、copydeepcopy による データのコピー は、代入による データの共有の処理より も、大幅に処理に時間がかかる ので、必要がなければ deepcopycopy は利用しないほうが良いでしょう。

上記の修正後に gui_play を実行し、いくつかの着手を行った後でリプレイ機能の << や < ボタンをクリックすると、正しい局面が表示されるようになります。しかし、> と >> ボタンが灰色で表示されて操作できないという問題が発生します。

例えば、3 手目の局面で < をクリックすると、実行結果のような画面が表示されます。このような現象が起きる原因について少し考えてみて下さい。

gui_play()

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

新たな問題の検証

> と >> ボタンが灰色で表示されるということは、その局面が最後に着手が行われた局面 であることを意味します。そのことを念頭において、change_step で行われる処理を検証 することにします。下記の change_step では、下記のような処理が行われます。

  • ローカル変数 recordsself.records が代入される
  • self.restart() でゲームがリセットされる
  • 1 ~ step 手目の着手が records から順番に取り出され、move メソッドで着手が行われる
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    step = max(0, min(len(self.records) - 1, step))
    # self.records の値は restart メソッドを呼び出すと初期化されるので
    # ローカル変数 records に代入して取っておく
    records = self.records
    self.restart()
    for x, y in records[1:step+1]:
        self.move(x, y)

move メソッドの処理では、records 属性に、行われた 着手のデータが追加 されます。change_step ではゲーム 開始時の局面から step 回の着手の処理が行われる ので、records の要素には 0 ~ step 手目の着手のデータ が代入されます。従って、step 手目が最後に行われた着手 になるため、> と >> ボタンが灰色で表示されることになります。

この問題は、下記のプログラムの 8 行目のように self.records に、ゲームをリセットする前に取っておいた records を代入しなおして着手の記録を元に戻す ことで解決できます。

 1  def change_step(self, step):
 2      # step の範囲を正しい範囲に修正する
 3      step = max(0, min(len(self.records) - 1, step))
 4      records = self.records
 5      self.restart()
 6      for x, y in records[1:step+1]:
 7          self.move(x, y)
 8      self.records = records
 9
10  Marubatsu.change_step = change_step    
行番号のないプログラム
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    step = max(0, min(len(self.records) - 1, step))
    records = self.records
    self.restart()
    for x, y in records[1:step+1]:
        self.move(x, y)
    self.records = records

Marubatsu.change_step = change_step  
修正箇所
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    step = max(0, min(len(self.records) - 1, step))
    records = self.records
    self.restart()
    for x, y in records[1:step+1]:
        self.move(x, y)
+   self.records = records

Marubatsu.change_step = change_step  

上記の修正後に下記のプログラムを実行して ai1s どうしの対戦を 10000 回行うことで、処理時間が短縮されたことを確認することにします。

ai_match(ai=[ai1s, ai1s])

筆者のパソコンでは上記のプログラムは 約 45 秒 かかりました。board_records 属性を削除する前は 約 67 秒 だったので、board_records 属性を削除 することで、AI の処理の時間が約 2/3 になり、実際にかなり短縮された ことを確認することができました。

なお、処理時間が短縮された理由は、AI が着手を選択する際に行う deepcopy の処理の時間が短くなっただけではありません。Marubatsu クラスから board_records 属性を削除することで、それまで、move で行っていた board_records の要素にゲーム盤の局面のデータをコピーして追加するという処理が行われなくなったことも、処理時間の短縮の原因です。

board_records を削除することで、change_step の中で、特定の局面を再現するためには、ゲーム開始時の局面からその局面までの着手を行うという処理が必要になります。そのため、change_step が行う処理は、board_records を削除することで、処理に必要な時間が増加しています。そのことが AI の処理時間の増加につながるのではないかと心配する人がいるかもしれませんが、そのようなことはおきません。

その理由は、change_step が GUI のリプレイ機能のボタンを押した場合でのみ呼び出されるため、AI が着手を行う際に呼び出されることはないからです。

他の修正方法

他の修正方法として、restart メソッドと move メソッドに、True を代入することで records 属性を変更しない ようにする 仮引数を追加 するという方法が考えられますが、上記で紹介した方法よりも実装が面倒なので、本記事では採用しません。また、marubatsu.ipynb にも記述しないので、興味がある方は自分で記述してみて下さい。

具体的には、restart メソッドを下記のように修正します。

  • 1 行目:仮引数に、デフォルト値を True とする update_records を追加する。デフォルト値を True としたのは、これまでのプログラムとの互換性を保つためである
  • 8、9 行目update_recordsTrue の場合のみ、records 属性を変更する
 1  def restart(self, update_records=True):
 2      self.initialize_board()
 3      self.turn = Marubatsu.CIRCLE     
 4      self.move_count = 0
 5      self.status = Marubatsu.PLAYING
 6      self.last_move = -1, -1          
 7      self.last_turn = None
 8      if update_records:
 9          self.records = [self.last_turn]
10
11  Marubatsu.restart = change_restart
行番号のないプログラム
def restart(self, update_records=True):
    self.initialize_board()
    self.turn = Marubatsu.CIRCLE     
    self.move_count = 0
    self.status = Marubatsu.PLAYING
    self.last_move = -1, -1          
    self.last_turn = None
    if update_records:
        self.records = [self.last_turn]      

Marubatsu.restart = change_restart
修正箇所
-def restart(self):
+def restart(self, update_records=True):
    self.initialize_board()
    self.turn = Marubatsu.CIRCLE     
    self.move_count = 0
    self.status = Marubatsu.PLAYING
    self.last_move = -1, -1          
    self.last_turn = None
-   self.records = [self.last_turn]
+   if update_records:
+       self.records = [self.last_turn]

Marubatsu.restart = change_restart

プログラムは省略しますが、move メソッドも同様の方法で修正します。

次に、change_step を下記のプログラムの 4、6 行目のように修正し、restart メソッドと move メソッドを実行しても records 属性の値が変化しない ようにします。

1  def change_step(self, step):
2      # step の範囲を正しい範囲に修正する
3      step = max(0, min(len(self.records) - 1, step))
4      self.restart(update_records=True)
5      for x, y in self.records[1:step+1]:
6          self.move(x, y, update_records=True)
7
8  Marubatsu.change_step = change_step     
行番号のないプログラム
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    step = max(0, min(len(self.records) - 1, step))
    self.restart(update_records=True)
    for x, y in self.records[1:step+1]:
        self.move(x, y, update_records=True)

Marubatsu.change_step = change_step     
修正箇所
def change_step(self, step):
    # step の範囲を正しい範囲に修正する
    step = max(0, min(len(self.records) - 1, step))
-   self.restart()
+   self.restart(update_records=True)
    for x, y in self.records[1:step+1]:
-       self.move(x, y)
+       self.move(x, y, update_records=True)

Marubatsu.change_step = change_step     

他にも方法はあるかもしれませんので、本記事で紹介した方法よりも良い方法を思いついた方は実装し、コメントで知らせてくれるとうれしいです。

今回の記事のまとめ

今回の記事では、GUI の機能の分離の作業を完了しました。また、差分によって局面を再現することで、インスタンスの属性に代入されたデータ量を減らし、deepcopy によるコピーに必要な時間を減らす方法を紹介しました。

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

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

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

次回の記事

  1. set_button_status は静的メソッドとして定義されていますが、静的メソッドであるかどうかはこのエラーとは関係ありません。このエラーは set_button_status を通常のメソッドとして定義した場合でも発生します

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?