0
1

Pythonで〇×ゲームのAIを一から作成する その85 キー入力による着手の処理の実装と inspect モジュールを利用したバグの修正

Last updated at Posted at 2024-05-30

目次と前回の記事

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

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

ルールベースの AI の一覧

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

キー入力による操作(続き)

前回の記事では、下記のキー入力による〇×ゲームの操作の実装のうち、着手を行う 1 ~ 9 以外のキー操作に対応する処理の実装を行いました。

キー 行われる操作
1 ~ 9 テンキーをゲーム盤のマスと見立て、対応するマスに着手を行う
ゲーム開始時まで戻す(<< ボタンに対応)
一手戻す(< ボタンに対応)
一手進める(> ボタンに対応)
最後に行われた着手まで進める(>> ボタンに対応)
0 待ったを行う
Enter ゲームをリセットする

着手を行うキーの座標の計算方法

残りの 1 ~ 9 のキーが押された時に着手を行う処理 を実装します。まず、押されたキーから 着手を行う ゲーム盤のマスの x、y 座標を計算 する必要があります。どのような式で計算できるかについて少し考えてみて下さい。

計算方法が分からない人は、以前の記事で説明した 数値座標 を思い出してください。以前の記事では、下図のようにそれぞれのマスに 0 ~ 8 までの整数の数値座標を割り当て、その 整数から x、y 座標を計算する方法 を紹介しました。

この数値座標から x、y 座標への変換は下記の計算で行うことができます。繰り返しになるので、下記の計算方法の詳細については以前の記事を復習して下さい。

  • x 座標:数値座標を 3 で割った余りを % 演算子で計算する
  • y 座標:数値座標を 3 で割った商を // 演算子で計算する

下記の num_to_xy以前の記事で定義した、仮引数 coord に代入された数値座標から x、y 座標を計算して返す関数です。

def num_to_xy(coord):
    x = coord % 3
    y = coord // 3
    return x, y

上記を参考に、押されたキーの数字から x、y 座標を計算する方法を考えることにします。以前の記事では 0 から 8 までの整数をそれぞれのマスに割り当てましたが、今回の場合は 1 から 9 の整数が割り当てられています。押された キーの数字から 1 を引く ことで、1 から 9 までの整数が 0 から 8 までの整数 になるので、先程の数値座標と同じ範囲の数値 になります。下図左は先ほどと同じ図で、下図右は押されたキーの数字から 1 を引いたもの です。

 

図を比べると、各数字に対応するマスの x 座標は同じ であることがわかります。従って、押されたキーの数字を num とすると、x 座標を以下の式で計算 することができます。

x = (num - 1) % 3  # 数値座標から 1 を引いた値を 3 で割った余りを計算する

y 座標に関しては、以下の表のような違いがあります。

数値座標 上図左の y 座標 上図右の y 座標
0 ~ 2 0 2
3 ~ 5 1 1
6 ~ 8 2 0

求める y 座標は、上図左の y 座標に対して 0 → 2、1 → 1、2 → 0 という変換を行う式 で計算することができます。この矢印の 左右の数値の合計はすべて 2 になる ので、矢印の 右の数字は 2 から左の数字を引き算する ことで計算できます。従って、押されたキーの数字を num とすると、y 座標を以下の式で計算 することができます。

num -= 1      # 押されたキーの数値から 1 を引いて上図右の整数に変換する
y = num // 3  # num を 3 で割った商を計算し、上図左の y 座標を計算する 
y = 2 - y     # 2 から y を引き算することで、上図右の y 座標を計算する

下記は、上記を 1 つの式にまとめたプログラムです。

y = 2 - ((num - 1) // 3)  

押されたキーに対応する座標の表示

上記の式が正しいことを確認するために、1 ~ 9 のキーが押された場合に、着手を行うマスの x、y 座標を計算して表示する処理を、下記のプログラムのように実装します。

  • 8 行目:リセットボタンなどに対応するキーが押されていないことを判定する
  • 9 行目event.key に代入されているのは 文字列型のデータ なので、割り算などを計算する際は、組み込み関数 int を使って整数型のデータに変換する必要がある。また、その際に この後の計算で必要となる 1 を引く計算を行っておく
  • 10、11 行目:先ほどの式を使って x、y 座標を計算する
  • 12 行目:計算した x、y 座標を print で表示する
 1  from marubatsu import Marubatsu, Marubatsu_GUI
 2  import math
 3
 4  def create_event_handler(self):
元と同じなので省略
 5      def on_key_press(event):
元と同じなので省略
 6          if event.key in keymap:
 7              keymap[event.key]()
 8          else:
 9              num = int(event.key) - 1
10              x = num % 3
11              y = 2 - (num // 3)
12              print(x, y)
元と同じなので省略
13    
14  Marubatsu_GUI.create_event_handler = create_event_handler
行番号のないプログラム
from marubatsu import Marubatsu, Marubatsu_GUI
import math

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=None):
        self.mb.restart()
        on_change_button_clicked(b)

    # 待ったボタンのイベントハンドラを定義する
    def on_undo_button_clicked(b=None):
        if self.mb.move_count >= 2 and self.mb.move_count == len(self.mb.records) - 1:
            self.mb.move_count -= 2
            self.mb.records = self.mb.records[0:self.mb.move_count+1]
            self.mb.change_step(self.mb.move_count)
            self.draw_board()
        
    # イベントハンドラをボタンに結びつける
    self.change_button.on_click(on_change_button_clicked)
    self.reset_button.on_click(on_reset_button_clicked)   
    self.undo_button.on_click(on_undo_button_clicked)   
    
    # step 手目の局面に移動する
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.draw_board()        

    def on_first_button_clicked(b=None):
        change_step(0)

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

    def on_next_button_clicked(b=None):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b=None):
        change_step(len(self.mb.records) - 1)

    def on_slider_changed(changed):
        change_step(changed["new"])
            
    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)
    self.slider.observe(on_slider_changed, names="value")
    
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    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)

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    def on_key_press(event):
        keymap = {
            "up": on_first_button_clicked,
            "left": on_prev_button_clicked,
            "right": on_next_button_clicked,
            "down": on_last_button_clicked,
            "0": on_undo_button_clicked,
            "enter": on_reset_button_clicked,            
        }
        if event.key in keymap:
            keymap[event.key]()
        else:
            num = int(event.key) - 1
            x = num % 3
            y = 2 - (num // 3)
            print(x, y)
            
    # fig の画像イベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    self.fig.canvas.mpl_connect("key_press_event", on_key_press)     
    
Marubatsu_GUI.create_event_handler = create_event_handler
修正箇所
from marubatsu import Marubatsu, Marubatsu_GUI
import math

def create_event_handler(self):
元と同じなので省略
    def on_key_press(event):
元と同じなので省略
        if event.key in keymap:
            keymap[event.key]()
+       else:
+           num = int(event.key) - 1
+           x = num % 3
+           y = 2 - (num // 3)
+           print(x, y)
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler

上記の修正後に、下記のプログラムで gui_play を実行し、ゲーム盤の画像の上でマウスをクリックして Figure を選択後 に 1 ~ 9 キーを押す と、実行結果のように、押したキーに対応するマスの x、y 座標が表示 されます。下記の実行結果は 1 ~ 9 の順番でキーを押した場合のものです。なお、ボタンやゲーム盤の画像は以前と同じなので省略します。

from util import gui_play

gui_play()

実行結果

0 2
1 2
2 2
0 1
1 1
2 1
0 0
1 0
2 0

上記のプログラムには大きな問題があります。それが何かを少し考えてみて下さい。

数値以外のキーを押した場合の対処

上記のプログラムは、数字以外のキーを押すとエラーが発生する という問題があります。例えば a キーを押すと下記のようなエラーが発生します。

略
Cell In[1], line 80
     78     keymap[event.key]()
     79 else:
---> 80     num = int(event.key) - 1
     81     x = num % 3
     82     y = 2 - (num // 3)

ValueError: invalid literal for int() with base 10: 'a'

このエラーは、組み込み関数 int によって、整数に変換することができない "a" という文字列を 整数に変換しようとしたことが原因 です。この問題は、以前の記事で説明した 例外処理 を使って対処するのが最も簡単でしょう。例外処理は、エラーが発生する可能性がある処理try のブロックの中に記述 することで、エラーが発生した場合プログラムを停止せずexcept のブロックの処理を実行する というものです。

これまでのプログラムで利用した例外処理は、play メソッドで キーボードから入力した文字列で指定された座標 に着手を行う際に、下記のプログラムのように記述することで、xy整数に変換できない文字列が代入 されていた場合に、プログラムを停止する代わり に、"整数の座標を入力して下さい" という エラーメッセージを表示する というものです。

try:
    self.move(int(x), int(y))
except:
    print("整数の座標を入力して下さい")

下記は例外処理を利用するように create_event_handler を修正したプログラムです。

  • 5 ~ 9 行目:1 ~ 9 のキーが押された場合の処理を try のブロック内に記述する
  • 10、11 行目try のブロック内でエラーが発生した場合の例外処理を行う except のブロックを記述する。1 ~ 9 以外のキーが押された場合にメッセージを表示するのは見た目が煩わしいので、ブロックの中で何の処理も行わないことを表す pass を記述している
 1  def create_event_handler(self):
元と同じなので省略
 2          if event.key in keymap:
 3              keymap[event.key]()
 4          else:
 5              try:
 6                  num = int(event.key) - 1
 7                  x = num % 3
 8                  y = 2 - (num // 3)
 9                  print(x, y)
10              except:
11                  pass
元と同じなので省略
12    
13  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=None):
        self.mb.restart()
        on_change_button_clicked(b)

    # 待ったボタンのイベントハンドラを定義する
    def on_undo_button_clicked(b=None):
        if self.mb.move_count >= 2 and self.mb.move_count == len(self.mb.records) - 1:
            self.mb.move_count -= 2
            self.mb.records = self.mb.records[0:self.mb.move_count+1]
            self.mb.change_step(self.mb.move_count)
            self.draw_board()
        
    # イベントハンドラをボタンに結びつける
    self.change_button.on_click(on_change_button_clicked)
    self.reset_button.on_click(on_reset_button_clicked)   
    self.undo_button.on_click(on_undo_button_clicked)   
    
    # step 手目の局面に移動する
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.draw_board()        

    def on_first_button_clicked(b=None):
        change_step(0)

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

    def on_next_button_clicked(b=None):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b=None):
        change_step(len(self.mb.records) - 1)

    def on_slider_changed(changed):
        change_step(changed["new"])
            
    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)
    self.slider.observe(on_slider_changed, names="value")
    
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    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)

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    def on_key_press(event):
        keymap = {
            "up": on_first_button_clicked,
            "left": on_prev_button_clicked,
            "right": on_next_button_clicked,
            "down": on_last_button_clicked,
            "0": on_undo_button_clicked,
            "enter": on_reset_button_clicked,            
        }
        if event.key in keymap:
            keymap[event.key]()
        else:
            try:
                num = int(event.key) - 1
                x = num % 3
                y = 2 - (num // 3)
                print(x, y)
            except:
                pass
            
    # fig の画像イベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    self.fig.canvas.mpl_connect("key_press_event", on_key_press)     
    
Marubatsu_GUI.create_event_handler = create_event_handler
修正箇所
def create_event_handler(self):
元と同じなので省略
        if event.key in keymap:
            keymap[event.key]()
        else:
-           num = int(event.key) - 1
-           x = num % 3
-           y = 2 - (num // 3)
-           print(x, y)
+           try:
+               num = int(event.key) - 1
+               x = num % 3
+               y = 2 - (num // 3)
+               print(x, y)
+           except:
+               pass
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler

実行結果は省略しますが、上記の修正後に、下記のプログラムで gui_play を実行し、1 ~ 9 以外のキーを押した場合にエラーが発生しなくなったことを確認して下さい。

gui_play()

押されたキーに対応する着手を行う処理の実装

前回の記事では、下記のプログラムのように、一手戻す処理などの キーが押された場合の処理 を、対応する処理 を行うボタンがクリックされた場合に呼び出される イベントハンドラを直接呼び出すことで実装 しました。

def on_key_press(event):
    if event.key == "left":
        on_prev_button_clicked(None)

前回の記事では、on_key_press の処理を改良したため、上記のようなプログラムではなくなっていますが、on_key_press が行う処理に変わりはありません。

GUI で マウスをクリックして着手を行う処理は 、下記のプログラムのように、create_event_handler 内で ローカル関数として定義された on_mouse_down で行われます

# ゲーム盤の上でマウスを押した場合のイベントハンドラ
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)

そこで、1 ~ 9 のキーが押された場合に着手を行う処理も、上記の on_mouse_down を呼び出すことで行うことにします。そのようにすることで、GUI で着手を行う処理一か所にまとめる ことができるという利点が得られます。

GUI で x、y のマスに着手する処理を行う 別の関数を定義 し、on_mouse_downon_key_press からその関数を呼び出すという方法もあります。

上記のプログラムから、on_mouse_down では、event.inaxesTrue の場合 に、x、y 座標が event.xdataevent.ydata のマスに着手 を行います。マウスをクリックした場合のイベントハンドラの 仮引数 event に代入されたオブジェクト は、イベントハンドラ以外では使われない ので その属性の値を変更してもかまいません。従って、下記のようにプログラムを修正することで、1 ~ 9 のキーが押された場合に着手を行う処理を行うことができます。

  • 8 行目event.inaxesTrue を代入する
  • 9、10 行目event.xdataevent.ydata に、計算した x、y 座標を代入する
  • 11 行目on_mouse_down(event) を呼び出して着手を行う
 1  def create_event_handler(self):
元と同じなので省略
 2      def on_key_press(event):
元と同じなので省略
 3          if event.key in keymap:
 4              keymap[event.key]()
 5          else:
 6              try:
 7                  num = int(event.key) - 1
 8                  event.inaxes = True
 9                  event.xdata = num % 3
10                  event.ydata = 2 - (num // 3)
11                  on_mouse_down(event)
12              except:
13                  pass
元と同じなので省略            
14    
15  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=None):
        self.mb.restart()
        on_change_button_clicked(b)

    # 待ったボタンのイベントハンドラを定義する
    def on_undo_button_clicked(b=None):
        if self.mb.move_count >= 2 and self.mb.move_count == len(self.mb.records) - 1:
            self.mb.move_count -= 2
            self.mb.records = self.mb.records[0:self.mb.move_count+1]
            self.mb.change_step(self.mb.move_count)
            self.draw_board()
        
    # イベントハンドラをボタンに結びつける
    self.change_button.on_click(on_change_button_clicked)
    self.reset_button.on_click(on_reset_button_clicked)   
    self.undo_button.on_click(on_undo_button_clicked)   
    
    # step 手目の局面に移動する
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.draw_board()        

    def on_first_button_clicked(b=None):
        change_step(0)

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

    def on_next_button_clicked(b=None):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b=None):
        change_step(len(self.mb.records) - 1)

    def on_slider_changed(changed):
        change_step(changed["new"])
            
    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)
    self.slider.observe(on_slider_changed, names="value")
    
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    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)

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    def on_key_press(event):
        keymap = {
            "up": on_first_button_clicked,
            "left": on_prev_button_clicked,
            "right": on_next_button_clicked,
            "down": on_last_button_clicked,
            "0": on_undo_button_clicked,
            "enter": on_reset_button_clicked,            
        }
        if event.key in keymap:
            keymap[event.key]()
        else:
            try:
                num = int(event.key) - 1
                event.inaxes = True
                event.xdata = num % 3
                event.ydata = 2 - (num // 3)
                on_mouse_down(event)
            except:
                pass
            
    # fig の画像イベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    self.fig.canvas.mpl_connect("key_press_event", on_key_press)     
    
Marubatsu_GUI.create_event_handler = create_event_handler
修正箇所
def create_event_handler(self):
元と同じなので省略
    def on_key_press(event):
元と同じなので省略
        if event.key in keymap:
            keymap[event.key]()
        else:
            try:
                num = int(event.key) - 1
+               event.inaxes = True
-               x = num % 3
+               event.xdata = num % 3
-               y = 2 - (num // 3)
+               event.ydata = 2 - (num // 3)
-               print(x, y)
+               on_mouse_down(event)
            except:
                pass
元と同じなので省略            
    
Marubatsu_GUI.create_event_handler = create_event_handler

仮引数 event の属性の値を変更してよいかどうかの判断がつかない場合は、下記のプログラムのように、自分で inaxesxdataydata 属性を持つ dict を作成 して on_mouse_down の実引数に記述するという方法があります。

try:
    num = int(event.key) - 1
    evt = {
        "inaxes": True,
        "xdata": num % 3,
        "ydata": 2 - (num // 3),
    }
    on_mouse_down(evt)

実行結果は省略しますが、上記の修正後に、下記のプログラムで gui_play を実行し、1 ~ 9 キーによって着手を行うことができることを確認して下さい。

gui_play()

AI と対戦した場合に発生するバグの検証とその対処法

上記の実装後に、確認のため様々な操作を行って、プログラムが正しく動作するかを検証した所、AI と対戦した場合にバグが発生する ことに気が付きました。

具体的には 下記のプログラムで、人間 VS ai1s の対戦を開始し、2 回着手を行う と、下図のように 2 回目の着手が画面に表示されず、リプレイ中であることを表す水色の画面になります。なお、下図は 1 回目で (1, 1) に、2 回目に (2, 2) に着手を行った場合のものです。

from ai import ai1s

gui_play(ai=[None, ai1s])

また、上図で > や >> ボタンをクリック すると、下図のように 2 回目の着手後の局面が表示される ので、2 回目の着手は確かに行われている ことが確認できますが、その後の ai1s の着手が行われていない ことがわかります。

このバグは、原因がわかれば修正は簡単 に行えますが、バグの原因を見つけることは、初心者には難しいかもしれません。また、バグの原因がわかった場合でも、1 回目の着手では不具合が起きない1 のに、2 回目の着手ではバグが発生 する原因を 正しく理解することはかなり困難 です。そこで、筆者がこのバグの原因を見つけた手順とバグの原因について詳しく解説することにします。初心者にとっては意味がわかりづらいかもしれませんが、バグの修正を行う方法の一つとして参考になるのではないかと思います。

draw_board 内での move_count の値の表示

2 回目の着手を行った際に、表示される 局面の手数が変化しない ので、手数を表す move_count 属性の値がおかしい ことが推測されます。そこで、着手を行った際move_count 属性の値 がどのようになっているかを 確認するため に、下記のプログラムの 2 行目のように、ゲーム盤を描画 する draw_board が呼び出された際に、move_count 属性の値を表示 するようにします。

def draw_board(self):
    print("draw_board. move_count =", self.mb.move_count)
元と同じなので省略
    
Marubatsu_GUI.draw_board = draw_board
行番号のないプログラム
def draw_board(self):
    print("draw_board. move_count =", self.mb.move_count)
    
    ax = self.ax
    ai = self.mb.ai
    
    # Axes の内容をクリアして、これまでの描画内容を削除する
    ax.clear()
    
    # y 軸を反転させる
    ax.invert_yaxis()
    
    # 枠と目盛りを表示しないようにする
    ax.axis("off")   
    
    # リプレイ中、ゲームの決着がついていた場合は背景色を変更する
    is_replay =  self.mb.move_count < len(self.mb.records) - 1 
    if self.mb.status == Marubatsu.PLAYING:
        facecolor = "lightcyan" if is_replay else "white"
    else:
        facecolor = "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
    # リプレイ中の場合は "Replay" を表示する
    if is_replay:
        text += " Replay"
    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):
+    print("draw_board. move_count =", self.mb.move_count)
元と同じなので省略
    
Marubatsu_GUI.draw_board = draw_board

ゲーム盤の画像は省略しますが、上記の修正後に下記のプログラムを実行すると、ゲーム開始時の局面を表示するために draw_board が 1 回呼び出され、move_count の値が 0 である ことが確認できます。この動作は特に問題はない でしょう。

gui_play(ai=[None, ai1s])

実行結果

draw_board. move_count = 0

次に、(1, 1) のマスをクリックして着手を行うと、下記のような表示が追加されます。

draw_board. move_count = 1
draw_board. move_count = 1
draw_board. move_count = 1
draw_board. move_count = 2

表示から、draw_board4 回呼び出され最初の 3 回は move_count の値が 1 で、最後の 1 回は move_count の値が 2 になっていることがわかります。

inspect モジュールの stack 関数による呼び出し元の関数の表示

draw_board はプログラムの 様々な場所から呼び出されています が、上記のメッセージからは、draw_boardどこから呼び出されたかはわかりません。プログラムの 処理の流れを辿る 際には、関数が どこから呼び出されたかの情報が表示されると便利 です。そこで draw_board がどこから呼び出されたかの情報を表示することにします。

関数がどこから呼び出されたかは inspect という組み込みモジュールの stack という関数 で調べることができます。stack は、stack を実行した行 のプログラムが、どこから呼び出されたか の情報を取得する関数で、以下の処理を行います。

  • 呼び出した関数の情報新しい順 に要素に代入された list の形式 で返す
  • ただし、0 番の要素 には、その行が記述されている関数の情報 が代入される
  • 呼び出した 関数の名前 は、それぞれの要素の function という属性に代入 される2
  • 関数ではない場所 から呼び出した場合は、モジュールから呼び出された ことになるので、モジュールの情報が代入 される

stack を利用することで、エラーが発生した際に表示される エラーメッセージのように、処理の流れを辿る ことができます。

stack(スタック)とは、複数のデータを格納することができるデータ構造で、格納した データを取り出す 際に、最後に入れたデータから順番に取り出す ことができるという性質があります。stack の返り値として、呼び出した関数が 新しい順で要素に代入された list が返される のはそのためです。

言葉の説明では意味が非常にわかりづらいと思いますので、具体例を挙げます。下記のプログラムは、a から b を、b から c を呼び出す という 関数を定義 しています。また、最後の行の a() はどの関数からも呼び出されていません。従って a を呼び出すと モジュール → abc の順で関数が呼び出されます

import inspect

def a():
    b()

def b():
    c()
    
def c():
    stack = inspect.stack()
    print(stack[0].function) # この関数の名前
    print(stack[1].function) # 1 つ前に呼び出した関数の名前
    print(stack[2].function) # 2 つ前に呼び出した関数の名前
    print(stack[3].function) # 3 つ前に呼び出した関数の名前
    
a()

実行結果

c
b 
a
<module>

c のブロックの中inspect.stack() を実行すると、c を実行するまでに呼び出した関数の情報 が、新しい順list の形式 で格納されたデータが返り値として得られます。ただし、最初の 0 番の要素 には inspect.stack() が記述された関数の情報 が格納されるので、下記のような list が返されます3

[関数 c の情報, c を呼び出した関数 b の情報, b を呼び出した関数 a の情報, a を呼び出したモジュールの情報]

それぞれの要素に代入された関数の情報の function 属性 にはその 関数の名前が代入されます。また、モジュールの場合は function 属性に "<module>" という文字列が代入されるので、上記の実行結果には cba<module> の順で関数の名前が表示されます。

上記から、関数を 直接呼び出した関数 の情報は、inspect.stack()[1] に代入されているので、その名前を表示するプログラム は、下記のように記述すればよいことがわかります。

inspect.stack()[1].function

上記のプログラムのコメントにも記しましたが、inspect.stack() の返り値の list の i 番の要素 には、i 個前に呼び出した関数の情報 が代入されると考えれば良いでしょう。ただし、0 個前の関数は、自分自身の関数を表します。

下記は 5 行目で stack を利用して、draw_board呼び出し元(caller)の関数(function)を表示 するように修正したプログラムです。

1  import inspect
2
3  def draw_board(self):
4      print("draw_board. move_count =", self.mb.move_count, 
5            "caller function =", inspect.stack()[1].function)
元と同じなので省略
6
7  Marubatsu_GUI.draw_board = draw_board
行番号のないプログラム
import inspect

def draw_board(self):
    print("draw_board. move_count =", self.mb.move_count, 
          "caller function =", inspect.stack()[1].function)
    
    ax = self.ax
    ai = self.mb.ai
    
    # Axes の内容をクリアして、これまでの描画内容を削除する
    ax.clear()
    
    # y 軸を反転させる
    ax.invert_yaxis()
    
    # 枠と目盛りを表示しないようにする
    ax.axis("off")   
    
    # リプレイ中、ゲームの決着がついていた場合は背景色を変更する
    is_replay =  self.mb.move_count < len(self.mb.records) - 1 
    if self.mb.status == Marubatsu.PLAYING:
        facecolor = "lightcyan" if is_replay else "white"
    else:
        facecolor = "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
    # リプレイ中の場合は "Replay" を表示する
    if is_replay:
        text += " Replay"
    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
修正箇所
import inspect

def draw_board(self):
-   print("draw_board. move_count =", self.mb.move_count) 
+   print("draw_board. move_count =", self.mb.move_count, 
+         "caller function =", inspect.stack()[1].function)
元と同じなので省略

Marubatsu_GUI.draw_board = draw_board

ゲーム盤の画像は省略しますが、上記の修正後に下記のプログラムを実行すると、実行結果のように、draw_boardplay_loop から呼び出された ことが確認できます。

gui_play(ai=[None, ai1s])

実行結果

draw_board. move_count = 0 caller function = play_loop

次に、(1, 1) のマスをクリックして着手を行うと、下記のような表示が追加されます。

draw_board. move_count = 1 caller function = on_mouse_down
draw_board. move_count = 1 caller function = play_loop
draw_board. move_count = 1 caller function = change_step
draw_board. move_count = 2 caller function = play_loop

表示から、move_count が 1 の場合にdraw_boardon_mouse_downplay_loopchange_step から 3 回呼び出される ことがわかります。また、その後で、 play_loop から呼び出されている draw_board は、move_count の値が 2 になっている ので、2 手目の AI が行った着手 に対して呼び出された draw_board の処理であることが 推測できます

move_count が 1 の場合に draw_board が 3 回も呼び出されるのは無駄なのですが、1 回目の着手ではバグは発生していない3ので、その検証は後回しにし、続けて (2, 2) のマスをクリックして 2 回目の着手を行う と、下記のような表示が追加されます。

draw_board. move_count = 3 caller function = on_mouse_down
draw_board. move_count = 2 caller function = change_step
draw_board. move_count = 2 caller function = play_loop

実行結果から、draw_board が 先程と同様に 3 回呼び出されている ことがわかりますが、よく見ると、先程とは異なりon_mouse_downchange_stepplay_loop の順で呼び出されていることがわかります。おそらく この違いはバグの原因と何か関係がありそう です。

また、1 回目の draw_board の呼び出しでは、move_count が 3 になっているので問題はありませんが、2 回目change_step から呼び出された場合に move_count が 2 に変化 し、3 回目の draw_board でも move_count が 2 のままになっています。おそらくこれが 2 回目の着手 を行った際のゲーム盤の表示で 手数が更新されないバグの原因 でしょう。

さらに、1 回目の着手の場合は表示されていた 4 回目の draw_board の呼び出し が、今回は行われていません。先ほどの 4 回目の draw_board の呼び出し は、おそらく AI の着手に対応したもの だったので、今回は AI が着手を行っていない ことが推測できます。

draw_board の呼び出し元の関数の処理の検証

draw_board が、on_mouse_downplay_loopchange_step という 3 つの関数から呼び出されている ことが確認できたので、それぞれが行う処理について検証する ことにします。

on_mouse_down は、マウスのクリック(またはキー入力)で 着手が行われた際に呼び出される関数 です。下記は、on_mouse_down の定義で、6 行目で move メソッドで着手を行った後に、ゲーム盤の描画を更新するため に 7 行目で draw_board が呼び出されています

1  def on_mouse_down(event):
2      # Axes の上でマウスを押していた場合のみ処理を行う
3      if event.inaxes and self.mb.status == Marubatsu.PLAYING:
4          x = math.floor(event.xdata)
5          y = math.floor(event.ydata)
6          self.mb.move(x, y)                
7          self.draw_board()
8          # 次の手番の処理を行うメソッドを呼び出す
9          self.mb.play_loop(self)

play_loop は、次の手番の処理を行うメソッドで、下記のプログラムの 11 行目のように、手番の処理を行う際 に、最初に draw_board を呼び出して ゲーム盤の描画を更新します4

 1  def play_loop(self, mb_gui):

 2      # ゲームの決着がついていない間繰り返す
 3      while self.status == Marubatsu.PLAYING:
 4          # 現在の手番を表す ai のインデックスを計算する
 5          index = 0 if self.turn == Marubatsu.CIRCLE else 1
 6          # ゲーム盤の表示
 7          if verbose:
 8              if gui:
 9                  # AI どうしの対戦の場合は画面を描画しない
10                  if ai[0] is None or ai[1] is None:
11                      mb_gui.draw_board()
12          # この後で手番の処理が行われる

change_step は、手数を変更する関数 で、下記のプログラムのように、手数を変更した後で、draw_board を呼び出してゲーム盤の描画を更新します。

def change_step(step):
    self.mb.change_step(step)
    # 描画を更新する
    self.draw_board() 

また、change_step は下記の状況になった場合に呼び出されます。

  • リプレイボタンと待ったボタンをクリックした場合
  • IntSlider の値が変化した場合

今回の操作では、リプレイボタンなどの操作は行っていないので、IntSlider の value 属性の値が変化したことchange_step が呼び出されている可能性が高いことがわかります。

本記事では行いませんが、そのことを実際に確認したい人は、change_step 内に、print(inspect.stack()[1].function) を記述して下さい。

IntSlidervalue 属性の値を変更する処理 は、下記のプログラムのように、update_widgets_status の中で行われます。また、update_widgets_statusdraw_board メソッド内から呼び出される ので、draw_board メソッドを呼び出すことで、IntSlidervalue 属性の値が変更される ことがわかります。

def update_widgets_status(self):

    self.slider.value = self.mb.move_count
    self.slider.max = len(self.mb.records) - 1

そこで、この後の処理の検証で IntSlider の属性の値 が上記で どのように変更されたかがわかる ように、下記のプログラムの 2、5 行目のように、update_widgets_status 内で IntSlider の属性の値がどのように変更されたかを表示するようにします。

1  def update_widgets_status(self):
元と同じなので省略
2      print(f"before: value = {self.slider.value} max = {self.slider.max}")
3      self.slider.value = self.mb.move_count
4      self.slider.max = len(self.mb.records) - 1
5      print(f"after:  value = {self.slider.value} max = {self.slider.max}")
6
7  Marubatsu_GUI.update_widgets_status = update_widgets_status
行番号のないプログラム
def update_widgets_status(self):
    self.set_button_status(self.undo_button, self.mb.move_count < 2 or self.mb.move_count != len(self.mb.records) - 1)
    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)    
    print(f"before: value = {self.slider.value} max = {self.slider.max}")
    self.slider.value = self.mb.move_count
    self.slider.max = len(self.mb.records) - 1
    print(f"after:  value = {self.slider.value} max = {self.slider.max}")

Marubatsu_GUI.update_widgets_status = update_widgets_status
修正箇所
def update_widgets_status(self):
元と同じなので省略
+   print(f"before: value = {self.slider.value} max = {self.slider.max}")
    self.slider.value = self.mb.move_count
    self.slider.max = len(self.mb.records) - 1
+   print(f"after:  value = {self.slider.value} max = {self.slider.max}")

Marubatsu_GUI.update_widgets_status = update_widgets_status

2 回の着手を行った際の処理の検証

次に、バグの原因を調べるために、2 回の着手を行った際の処理を検証することにします。

ゲーム開始時の処理の検証

ゲーム盤の画像は省略しますが、上記の修正後に下記のプログラムを実行すると、実行結果のように、draw_boardplay_loop から呼び出され、IntSlider の max 属性が 0 に変化したことが確認できます。これは正常な処理です。

gui_play(ai=[None, ai1s])

実行結果

draw_board. move_count = 0 caller function = play_loop
before: value = 0 max = 100
after:  value = 0 max = 0

1 回目の着手を行った後の最初の draw_board の検証

次に、(1, 1) のマスをクリックすると、下記のような表示が追加されます。

 1  draw_board. move_count = 1 caller function = on_mouse_down
 2  before: value = 0 max = 0
 3  after:  value = 0 max = 1
 4  draw_board. move_count = 1 caller function = play_loop
 5  before: value = 0 max = 1
 6  draw_board. move_count = 1 caller function = change_step
 7  before: value = 1 max = 1
 8  after:  value = 1 max = 1
 9  after:  value = 1 max = 1
10  draw_board. move_count = 2 caller function = play_loop
11  before: value = 1 max = 1
12  after:  value = 1 max = 2

上記の 1 ~ 3 行目から、1 回目の draw_board は、1 回目の着手を行うためにゲーム盤の上でマウスを押したことによって呼び出された on_mouse_down から呼び出された ことがわかります。また、2、3 行目から、その際に IntSlidervalue 属性は 0 のまま変化しない が、max 属性は 0 から 1 に変化する ことがわかりますが、これは おかしくないでしょうか

下記の update_widgets_status で行われる処理では、IntSlider の value 属性に move_count の値が代入 されます。上記の 1 行目のメッセージから move_count には 1 が代入されている ので、value 属性には 1 が代入されるはず なのですが、実際には上記の 3 行目のメッセージから、value 属性の値は 0 のまま変化していません

def update_widgets_status(self):

    self.slider.value = self.mb.move_count
    self.slider.max = len(self.mb.records) - 1

このような一見すると不可思議な処理が行われる理由は、IntSlider の max 属性 にあります。max 性は、IntSlider の value 属性の最大値 を表す属性なので、value 属性に、max 属性よりも大きな値を代入 しようとすると、自動的に max 属性の値に修正 されます5

具体例を挙げます。下記のプログラムは、max 属性に 10 を設定 した IntSlider を作成し、その value 属性に 20 を代入 するプログラムですが、実行結果からわかるように、slider.value 属性の値 は、max 属性の値自動的に修正 されます。

import ipywidgets as widgets

slider = widgets.IntSlider(max=10)
slider.value = 20
print(slider.value)

実行結果

10

今回の記事では説明しませんが、クラスには インスタンスの属性に値を代入した際 に、上記のように 属性の値を自動的に修正 することができる プロパティという仕組み があります。興味がある方は下記のリンク先を参照して下さい。

このように、オブジェクトの属性 に値を代入した際に、代入しようとした値と異なる値が代入される場合がある 点に注意が必要です。また、そのようなことが起きるかどうかを知るには、利用するオブジェクトのクラスを定義する モジュールのマニュアル(説明書)などを読む 必要があります。

先程の 1 回目の draw_board は、1 手目の着手が行われた後 で呼び出されるので、move_countlen(self.mb.records) - 1 の値は 1 になっています。一方、IntSlider はゲーム開始時に valuemax 属性に 0 が代入された後は 更新されていません。従って、update_widgets_status によって、以下のように valuemax 属性の値が変化します。

value max
処理を行う前 0 0
self.slider.value = 1 0 0
self.slider.max = 1 0 1

上記で問題となるのは、self.slider.value = 1 を実行してもmax 属性の値が 0 であるため、value 属性の値が 0 のまま変化しない ことです。この問題は、下記のプログラムのように、max 属性の値の変更を value 属性よりも前に記述する ことで、max 属性の値に 1 を代入した後に、value 属性に 1 が代入されるようになるため解決できます。

def update_widgets_status(self):

    self.slider.max = len(self.mb.records) - 1
    self.slider.value = self.mb.move_count
修正箇所
def update_widgets_status(self):

-   self.slider.value = self.mb.move_count
+   self.slider.max = len(self.mb.records) - 1
-   self.slider.max = len(self.mb.records) - 1
+   self.slider.value = self.mb.move_count

このバグは、max 属性と value 属性の関係を正しく理解 していないと、原因をみつけて修正することが困難 なバグです。また、そのことを理解していたとしても、うっかり間違えてしまう可能性が高い バグで、実際に筆者も完全にうっかり間違えてしまいました。

実は、上記の修正を行うことで、バグを修正できます が、今すぐに修正してしまうと、この後で どのような処理が行われていたためバグが発生していたかがわからなくなる ので、バグを修正せずに、このまま検証を続ける ことにします。

1 回目の着手を行った後の 2 回目の draw_board の検証

下記は、1 回目の着手を行った際の表示の再掲です

 1  draw_board. move_count = 1 caller function = on_mouse_down
 2  before: value = 0 max = 0
 3  after:  value = 0 max = 1
 4  draw_board. move_count = 1 caller function = play_loop
 5  before: value = 0 max = 1
 6  draw_board. move_count = 1 caller function = change_step
 7  before: value = 1 max = 1
 8  after:  value = 1 max = 1
 9  after:  value = 1 max = 1
10  draw_board. move_count = 2 caller function = play_loop
11  before: value = 1 max = 1
12  after:  value = 1 max = 2

上記の 4 行目から、2 回目の draw_board の呼び出しは、play_loop の中から行われている ことがわかりますが、その play_loop の呼び出しは、下記のプログラムの 9 行目のように、on_mouse_down の 7 行目で draw_board を呼び出した後 で行われています。

1  def on_mouse_down(event):
2      # Axes の上でマウスを押していた場合のみ処理を行う
3      if event.inaxes and self.mb.status == Marubatsu.PLAYING:
4          x = math.floor(event.xdata)
5          y = math.floor(event.ydata)
6          self.mb.move(x, y)                
7          self.draw_board()
8          # 次の手番の処理を行うメソッドを呼び出す
9          self.mb.play_loop(self)

このことから、on_mouse_down によって人間が着手を行った場合は、on_mouse_downplay_loop の中でそれぞれ 1 回ずつ、合計 2 回 draw_board が呼びだされる ことがわかります。これは 本来は無駄な処理 なのですが、この 無駄な処理が行われた結果IntSlidervalue 属性の値 が、下記のように 正しい値になります

下記は、2 回目の draw_board で IntSlider に対して行われる処理です。1 回目draw_board の処理によって、value 属性と max 属性の値が 0 と 1 になっている ので、今度 は、self.slider.value = 1 によって、value 属性の値が 1 になります

value max
処理を行う前 0 1
self.slider.value = 1 1 1
self.slider.max = 1 1 1

これが 1 回目の着手 を行った際に、2 回目の着手を行った際のような バグが発生しなかった原因 です。ただし、バグが発生しなかったのは、本来は 2 回行う必要のない draw_board の処理が たまたま 2 回行われていた ためで、バグの原因が修正されたわけではありません。また、この後で検証しますが、実際に 2 回目の着手を行った際にはバグが発生する ので、1 回目の着手でバグが発生しなかったのは、単なる偶然にすぎません

また、このように、実際には バグが存在するにも関わらず特定の条件が満たされない 場合はその バグが表に出てこない ことがあります。そのようなバグは、一般的に みつけづらく、見つけるためには プログラムの詳細な処理の検証が必要になる 場合があります。

1 回目の着手を行った後の 3 回目の draw_board の検証

ところで、下記の 2 回目の draw_board で表示されるメッセージがおかしいと思った人はいないでしょうか?下記では、2 行目で before が表示された後 で、after が表示される前次の draw_board の表示 が行われています。

これは、observe によってウィジェットに結びつけられた イベントハンドラ が、指定した属性の値が変化 した時に 即座に実行される という仕組みになっているためです。

略
1  draw_board. move_count = 1 caller function = play_loop
2  before: value = 0 max = 1
3  draw_board. move_count = 1 caller function = change_step
4  before: value = 1 max = 1
5  after:  value = 1 max = 1
6  after:  value = 1 max = 1
略

上記の 2 行目のメッセージの表示の後では、下記の手順で処理が行われます。

  1. self.slider.value = 1 によって value 属性の値が 0 から 1 に変更される
  2. その次の self.slider.max = 1実行される前 に、observe によって IntSlider に結びつけられた、value 属性の値が変更された際に呼び出される イベントハンドラである on_slider_changed即座に呼び出される
  3. on_slider_changed は、下記のプログラムのように change_step を呼び出すので、その中から draw_board が呼び出され、3 ~ 5 行目のメッセージが表示される
  4. on_slider_changed の処理が終了し、play_loop から呼び出された draw_board の処理が再開 され、6 行目 で、2 行目の before に対応する after の表示 が行われる
def on_slider_changed(changed):
    change_step(changed["new"])

下記のメッセージがどの関数から呼び出された draw_board であるかを右に示します。

略
1  draw_board. move_count = 1 caller function = play_loop      # play_loop
2  before: value = 0 max = 1                                   # play_loop
3  draw_board. move_count = 1 caller function = change_step    # change_step
4  before: value = 1 max = 1                                   # change_step
5  after:  value = 1 max = 1                                   # change_step
6  after:  value = 1 max = 1                                   # play_loop
略

3 ~ 5 行目のメッセージは、change_step から呼び出された draw_board で行われた処理です。2 行目の処理の後 で、value 属性の値が 1 に変化する ので、4、5 行目のメッセージからわかるように、change_step から呼び出された draw_board では、IntSlider の value 属性の値は変化しません。そのため、4 行目と 5 行目の間で、新たに on_slider_changed の処理が呼び出されることはありません。

また、2、6 行目のメッセージから、play_loop から呼び出された draw_board で、IntSlider の value 属性の値が 0 から 1 に変化したことがわかります

下記の話は細かい話なので、意味が分からない場合は読み飛ばしてください。

以前の記事で、イベントハンドラは、イベントループから JupyterLab のセルなどのや、イベントハンドラの プログラムの処理が完了した後で呼び出される と説明しましたが、observe によって結び付けられた、ウィジェットの属性が変化すると呼び出されるイベントハンドラは、別の仕組みで 属性の値が変化した時点即座に呼び出される ようです。これは筆者も勘違いていましたので注意して下さい。

なお、on_button_clicked などの、ボタンやキーの操作に対するイベントハンドラは、以前の記事で説明した通り、イベントループから呼び出されるので、プログラムの処理が完了した後で呼び出されます。

1 回目の着手を行った後の 4 回目の draw_board の検証

下記は、4 回目の draw_board が表示するメッセージです。

略
draw_board. move_count = 2 caller function = play_loop
before: value = 1 max = 1
after:  value = 1 max = 2

move_count が 2 になっており、play_loop から呼び出されている ので、これは AI が 2 手目の着手を行った後play_loop から呼び出されたものです。また、1 回目の draw_board と同じ理由 で、IntSlider の value 属性が 1 から 2 に変化しない という バグが発生している ことがわかります。このバグは、画面の表示に現れていないと筆者は最初は思っていたのですが、よく見ると 1 手目の着手を行った後 で、AI が着手を行い 2 手目の局面になっている にも関わらず、下図のように、現在の手数を表す IntSlider の値が 2 ではなく、1 になっている という点で、バグの存在が画面に反映されています

このことから、1 手目の着手を行った後は 、一見すると バグが発生していないように見えていました が、実際にはバグが発生していた ことがわかります。このような、一見しただけではバグが発生していることがわからないようなバグは、見つけることはかなり困難です。

2 回目の着手を行った後の最初の draw_board の検証

次に、(2, 2) のマスをクリックすると、下記のような表示が追加されます。

1  draw_board. move_count = 3 caller function = on_mouse_down
2  before: value = 1 max = 2
3  draw_board. move_count = 2 caller function = change_step
4  before: value = 2 max = 2
5  after:  value = 2 max = 3
6  after:  value = 2 max = 3
7  draw_board. move_count = 2 caller function = play_loop
8  before: value = 2 max = 3
9  after:  value = 2 max = 3

自分の 2 回目 の着手では、3 手目 の着手が行われるので、move_count には 3 が代入 されますが、IntSlider の max 属性には 2 が代入 されているので、self.slider.value = 3 を実行しても value 属性 には max 属性の値である 2 が代入 されます。

ここまでは、1 回目の着手の場合と同じですが、上記の 2 行目からわかるように、value 属性の元の値 は先ほど説明したように、直前の 2 手目で AI の着手が行われても 2 にはならず、1 のまま です。そのため、self.slider.value = 3 によって、value 属性の値 は 1 から 2 に 変化する ことになるため、1 回目の着手の場合と異なり on_slider_changed が呼び出され、その中から change_step(changed["new"]) が呼び出されることになります。

2 回目の着手を行った後の 2 回目の draw_board の検証

下記は、2 回目の着手を行った後の表示の右に、どの関数から draw_board が呼び出されたかを記載したものです。

1  draw_board. move_count = 3 caller function = on_mouse_down   # on_mouse_down
2  before: value = 1 max = 2                                    # on_mouse_down
3  draw_board. move_count = 2 caller function = change_step     # change_step
4  before: value = 2 max = 2                                    # change_step
5  after:  value = 2 max = 3                                    # change_step
6  after:  value = 2 max = 3                                    # on_mouse_down
略

change_step から呼び出される 2 回目の draw_board では、上記の 3 行目から move_count に 2 が代入 されていることがわかります。このようなことが起きる原因は以下の通りです。

  • 1 回目の draw_board の中で、IntSlider の value 属性の値が 1 から 2 に変化する
  • on_slider_changed が呼び出され、change_step(changed["new"]) が呼び出される
  • changed["new"] には、IntSlider の変化後の値である 2 が代入されているので 2 手目の局面に移動する処理 が行われる
  • 2 手目の局面に移動した後 で、change_step の中から draw_board が呼び出されるので、move_count には 2 が代入 されている。また、表示される局面も、2 手目の局面になる

バグの原因をまとめると、以下のようになります。

  • 直前の AI の着手 によって IntSlider の value 属性の値 が 2 になるはずだったが、max 属性の値のせいで 1 になってしまった
  • 本当は、on_mouse_down から呼び出された draw_board の処理で、IntSlider の value 属性の値を 3 にするはず だったが、max 属性の値のせいで 2 になってしまった
  • value 属性が 1 から 2 に変化した ので、on_slider_changed が呼ばれた
  • on_slider_changed の中で、change_step によって 3 手目の局面に移動するはずだった が、IntSlider の value 属性の値が バグで 2 になってしまったせい で、2 手目の局面に移動してしまった

また、1 回目の着手でこのバグが発生しなかった理由は、以下の通りです。

  • 1 回目の着手では、直前の AI の着手が行われていない ため、value 属性の値が 正しい 0 の値 であった
  • 本当は、on_mouse_down から呼び出された draw_board の処理で、IntSlider の value 属性の値を 1 にするはず だったが、max 属性の値のせいで 0 になってしまった
  • 元の value 属性 の値が バグのせいで 0 だった ので、上記の処理によって 偶然 value 属性が変化しなかった ため on_slider_changed は呼び出されず、そのことによるバグは発生しなかった

上記からわかるように、1 回目の着手で バグが表面に現れなかった理由 は、たまたま 直前の手番で AI の着手が行われなかっため value 属性の値が変化しなかったからに 過ぎません

2 回目の着手を行った後の 3 回目の draw_board の検証

下記は、3 回目の draw_board による表示です。下記の 1 行目から、3 回目の draw_boardmove_count が 2 の状態で play_loop から呼ばれますが、move_count が 2 の場合 は、人間が担当 する 〇 の手番なので、play_loop では AI の着手は行われません。そのため、draw_board では、2 手目の着手が行われた後の局面が描画 されます。

略
1  draw_board. move_count = 2 caller function = play_loop
2  before: value = 2 max = 3
3  after:  value = 2 max = 3

以上が、プログラムの処理の検証による、バグの原因の解明の手順でした。

ちなみに先程説明した、IntSlider の max 属性に値を代入する前に value 属性に値を代入する というバグは、そのようなバグの例を示すためにわざと発生させたものではなく、完全に筆者のうっかりミスです。ただし、このようなうっかりミスは 慣れていても完全に避けることは難しい と思いますので、バグの発見の手順も含めて詳しく解説しました。

実際にはバグを見つけるまでに、いくつか別の場所に print で変数の値などを表示する作業を行いましたが、それらをすべて記述すると長くなるので省略しました。

バグの修正

バグの修正は、先程説明したように update_widgets_status 内で、下記のプログラムの 3、4 行目のように、value 属性より先に max 属性の値を変更する ことで行うことができます。

1  def update_widgets_status(self):
元と同じなので省略
2      print(f"before: value = {self.slider.value} max = {self.slider.max}")
3      self.slider.max = len(self.mb.records) - 1
4      self.slider.value = self.mb.move_count
5      print(f"after:  value = {self.slider.value} max = {self.slider.max}")
6
7  Marubatsu_GUI.update_widgets_status = update_widgets_status
行番号のないプログラム
def update_widgets_status(self):
    self.set_button_status(self.undo_button, self.mb.move_count < 2 or self.mb.move_count != len(self.mb.records) - 1)
    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)    
    print(f"before: value = {self.slider.value} max = {self.slider.max}")
    self.slider.max = len(self.mb.records) - 1
    self.slider.value = self.mb.move_count
    print(f"after:  value = {self.slider.value} max = {self.slider.max}")

Marubatsu_GUI.update_widgets_status = update_widgets_status
修正箇所
def update_widgets_status(self):
元と同じなので省略
    print(f"before: value = {self.slider.value} max = {self.slider.max}")
-   self.slider.value = self.mb.move_count
+   self.slider.max = len(self.mb.records) - 1
-   self.slider.max = len(self.mb.records) - 1
+   self.slider.value = self.mb.move_count
    print(f"after:  value = {self.slider.value} max = {self.slider.max}")

Marubatsu_GUI.update_widgets_status = update_widgets_status

上記の修正後に、下記のプログラムを実行し、(1, 1) に着手を行うと実行結果のような表示が行われ、メッセージから、draw_board で、IntSlidervalue 属性の値move_count の値に正しく変化し、図からも IntSlider の値が正しく 2 になる ことが確認できます。

gui_play(ai=[None, ai1s])

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

draw_board. move_count = 1 caller function = on_mouse_down
before: value = 0 max = 0
draw_board. move_count = 1 caller function = change_step
before: value = 1 max = 1
after:  value = 1 max = 1
after:  value = 1 max = 1
draw_board. move_count = 1 caller function = play_loop
before: value = 1 max = 1
after:  value = 1 max = 1
draw_board. move_count = 2 caller function = play_loop
before: value = 1 max = 1
draw_board. move_count = 2 caller function = change_step
before: value = 2 max = 2
after:  value = 2 max = 2
after:  value = 2 max = 2

続けて (2, 2) に 2 回目の着手を行うと、図のように 4 手目の局面が表示され、バグが修正されたことが確認できます。なお、メッセージは 1 回目の着手と同様なので省略します。

value 属性と max 属性への値の 代入の順序の間違いだけ で、これほど見つけづらく、わかりづらいバグが発生することに驚いた人がいるかもしれませんが、そのような 一見すると些細な違いから このような 見つけづらいバグが発生 することは 実際に良くあります。このようなバグの原因を見つけて修正するためには、豊富なデバッグ作業の経験 と、今回の記事で行ったような 地道なデバッグの作業がどうしても必要になります ので参考にして下さい。

無駄な処理の削除

先程のメッセージから下記の手順で処理が行われていることがわかります。

  1. on_mouse_down が呼び出されて着手が行われ、draw_board が呼び出される
  2. draw_board で 1 手目の局面が描画され、IntSlider の valuemax 属性が 0 から 1 になる
  3. value 属性の値が変更されたので、on_slider_changed が呼び出され、その中で change_step(1) が呼び出される
  4. change_step の中で 1 手目の局面に移動する処理と draw_board が呼び出される
  5. draw_board で 1 手目の局面が描画される。IntSlider の属性の値は変化しない
  6. on_mouse_down から play_loop が呼び出され、その中で draw_board が呼び出される
  7. draw_board で 1 手目の局面が描画される。IntSlider の属性の値は変化しない
  8. AI の手番なので play_loop の中で AI の着手が行われ、draw_board が呼び出される
  9. draw_board で 2 手目の局面が描画され、IntSlider の valuemax 属性が 1 から 2 になる
  10. value 属性の値が変更されたので、on_slider_changed が呼び出され、その中で change_step(2) が呼び出される
  11. change_step の中で 2 手目の局面に移動する処理と draw_board が呼び出される
  12. draw_board で 2 手目の局面が描画される。IntSlider の属性の値は変化しない

下記はメッセージがどの関数から呼び出されたかを右に記したものです。

draw_board. move_count = 1 caller function = on_mouse_down  # on_mouse_down
before: value = 0 max = 0                                   # on_mouse_down
draw_board. move_count = 1 caller function = change_step    # change_step(1 回目)
before: value = 1 max = 1                                   # change_step(1 回目)
after:  value = 1 max = 1                                   # change_step(1 回目)
after:  value = 1 max = 1                                   # on_mouse_down
draw_board. move_count = 1 caller function = play_loop      # play_loop(1 回目)
before: value = 1 max = 1                                   # play_loop(1 回目)
after:  value = 1 max = 1                                   # play_loop(1 回目)
draw_board. move_count = 2 caller function = play_loop      # play_loop(2 回目)
before: value = 1 max = 1                                   # play_loop(2 回目)
draw_board. move_count = 2 caller function = change_step    # change_step(2 回目)
before: value = 2 max = 2                                   # change_step(2 回目)
after:  value = 2 max = 2                                   # change_step(2 回目)
after:  value = 2 max = 2                                   # play_loop(2 回目)

2 回目の着手を行った場合に行われる処理は同様なので省略します。興味がある方はクリックして表示されるメッセージを確認して下さい。

上記の処理で、無駄な処理が行われている と思った人はいないでしょうか?具体的にどの処理が無駄であるかについて少し考えてみて下さい。

上記の 手順 4 では、change_step の中で 1 手目の局面に移動する処理 が行われていますが、手順 1 によって、現在の局面は 既に 1 手目の局面になっています。従って、手順 4 の処理は、既に 1 手目の局面であるにも関わらず、change_step で 1 手目の局面に移動するという 無駄な処理 が行われています。また、1 手目の局面は 手順 2 で描画済 なので、改めて描画し直す必要はありません。これは AI が着手を行った場合の 手順 11 でも同様 です。

change_step で局面を 移動する必要がある のは 現在の手数と異なる手数に移動する場合 です。手順 4 は、on_slider_changed から呼び出されている ので、必要な場合だけ change_step を呼び出す ように on_slider_changed を修正することで、無駄な change_stepdraw_board を呼び出さないようにする ことができます。

もう一つの無駄は、上記の手順 2、5、7 と 9、12 です。それぞれ 1 手目と 2 手目の局面を draw_board表示する処理が複数回行われています が、この処理は 1 回だけ行えば十分 です。処理 5 と 12 は change_step から呼ばれる処理で、上記の on_slider_changed の修正を行うことで行われなくなります。そこで、残りの on_mouse_down 内で draw_board を呼び出す 処理 2 を行わないようにする ことにします。

下記は、そのように create_event_handler を修正したプログラムです。

  • 3、4 行目move_count と、変化した IntSlider の value 属性の値が異なる場合だけ、change_step を呼び出すように修正する
  • 12 行目の下にあった、draw_board を呼び出す処理を削除する
 1  def create_event_handler(self):
元と同じなので省略
 2      def on_slider_changed(changed):
 3          if self.mb.move_count != changed["new"]:
 4              change_step(changed["new"])
元と同じなので省略
 5      # ゲーム盤の上でマウスを押した場合のイベントハンドラ
 6      def on_mouse_down(event):
 7          # Axes の上でマウスを押していた場合のみ処理を行う
 8          if event.inaxes and self.mb.status == Marubatsu.PLAYING:
 9              x = math.floor(event.xdata)
10              y = math.floor(event.ydata)
11              self.mb.move(x, y)                
12              # この下にあった draw_board を呼び出す処理を削除する
13              # 次の手番の処理を行うメソッドを呼び出す
14              self.mb.play_loop(self)
元と同じなので省略
15   
16  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=None):
        self.mb.restart()
        on_change_button_clicked(b)

    # 待ったボタンのイベントハンドラを定義する
    def on_undo_button_clicked(b=None):
        if self.mb.move_count >= 2 and self.mb.move_count == len(self.mb.records) - 1:
            self.mb.move_count -= 2
            self.mb.records = self.mb.records[0:self.mb.move_count+1]
            self.mb.change_step(self.mb.move_count)
            self.draw_board()
        
    # イベントハンドラをボタンに結びつける
    self.change_button.on_click(on_change_button_clicked)
    self.reset_button.on_click(on_reset_button_clicked)   
    self.undo_button.on_click(on_undo_button_clicked)   
    
    # step 手目の局面に移動する
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.draw_board()        

    def on_first_button_clicked(b=None):
        change_step(0)

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

    def on_next_button_clicked(b=None):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b=None):
        change_step(len(self.mb.records) - 1)

    def on_slider_changed(changed):
        if self.mb.move_count != changed["new"]:
            change_step(changed["new"])
            
    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)
    self.slider.observe(on_slider_changed, names="value")
    
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    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.mb.play_loop(self)

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    def on_key_press(event):
        keymap = {
            "up": on_first_button_clicked,
            "left": on_prev_button_clicked,
            "right": on_next_button_clicked,
            "down": on_last_button_clicked,
            "0": on_undo_button_clicked,
            "enter": on_reset_button_clicked,            
        }
        if event.key in keymap:
            keymap[event.key]()
        else:
            try:
                num = int(event.key) - 1
                event.inaxes = True
                event.xdata = num % 3
                event.ydata = 2 - (num // 3)
                on_mouse_down(event)
            except:
                pass
            
    # fig の画像イベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    self.fig.canvas.mpl_connect("key_press_event", on_key_press)     
    
Marubatsu_GUI.create_event_handler = create_event_handler
修正箇所
def create_event_handler(self):
元と同じなので省略
    def on_slider_changed(changed):
-       change_step(changed["new"])
+       if self.mb.move_count != changed["new"]:
+           change_step(changed["new"])
元と同じなので省略
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    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)
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler

上記の修正後に、下記のプログラムを実行し、(1, 1) に着手を行うと、実行結果のように、draw_boardplay_loop のみから呼び出されるようになり、無駄な draw_board の呼び出しが行われなくなった ことが確認できます。

gui_play(ai=[None, ai1s])

実行結果

draw_board. move_count = 1 caller function = play_loop
before: value = 0 max = 0
after:  value = 1 max = 1
draw_board. move_count = 2 caller function = play_loop
before: value = 1 max = 1
after:  value = 2 max = 2

以上でキー操作による GUI の処理の実装は完了です。

なお、draw_board などに記述したデバッグのメッセージはもう必要がなくなったので、marubatsu.py のほうに今回のプログラムを反映する際は削除することにします。

リプレイ中の着手の禁止について

これで、筆者が思いついたリプレイ機能に関連する実装は完了です。

なお、リプレイ中に別の着手を行うことができる点に違和感を感じている人がいるかもしれません。そのような場合は、cretate_event_handler 内の on_mouse_down を下記のプログラムのように修正して下さい。なお、本記事では下記のプログラムは採用しません。

  • 4 行目:最後の着手が行われた局面であることを条件式に加える
 1  # ゲーム盤の上でマウスを押した場合のイベントハンドラ
 2  def on_mouse_down(event):
 3      # Axes の上でマウスを押していた場合で、最後の着手が行われた局面でのみ処理を行う
 4      if event.inaxes and self.mb.status == Marubatsu.PLAYING and self.mb.move_count == len(self.mb.records) - 1:
 5          x = math.floor(event.xdata)
 6          y = math.floor(event.ydata)
 7          self.mb.move(x, y)                
 8          self.draw_board()
 9          # 次の手番の処理を行うメソッドを呼び出す
10          self.mb.play_loop(self)
行番号のないプログラム
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    def on_mouse_down(event):
        # Axes の上でマウスを押していた場合で、最後の着手が行われた局面でのみ処理を行う
        if event.inaxes and self.mb.status == Marubatsu.PLAYING and self.mb.move_count == len(self.mb.records) - 1:
            x = math.floor(event.xdata)
            y = math.floor(event.ydata)
            self.mb.move(x, y)                
            self.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
            self.mb.play_loop(self)
修正箇所
    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    def on_mouse_down(event):
        # Axes の上でマウスを押していた場合で、最後の着手が行われた局面でのみ処理を行う
-       if event.inaxes and self.mb.status == Marubatsu.PLAYING and self.mb.move_count == len(self.mb.records) - 1:
+       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)

本記事では採用しませんが、次回の記事で説明する CheckBox を利用して、リプレイ中の着手を行えるかどうかを切り替えることができるようにすることもできます。

このように、アイディア次第で〇×ゲームの GUI の機能をいくらでも拡張することができる ので、良いアイディアを思いついた方は実装してみて下さい。

今回の記事のまとめ

今回の記事では、最初にキー入力による GUI の実装を完了しました。

また、思わぬバグが発生したので、そのバグの原因と修正方法について説明しました。

今回の記事で GUI の実装を完了する予定だったのですが、予定外のバグが発生し、その説明を行いましたので、GUI の実装の完了は次回に持ち越すことにします。

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

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

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

次回の記事

  1. 後で説明しますが、実際には 1 回目の着手でバグは発生 しています。一見するとバグが発生していないように見える のでこのように表記しました

  2. 他にも関数に関する様々な情報が代入された属性があります。詳細は、こちらのリンク先を参照して下さい

  3. 実際には 4 番以降の要素にも値が代入されますが、今回の説明では必要がない要素なので説明は省略します 2

  4. AI どうしの対戦の場合は draw_board を呼び出さないという工夫を行っていますが、先程の例の場合は AI どうしの対戦ではないので、手番の処理を行う前に draw_board が呼び出されます

  5. min 属性も同様です

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