目次と前回の記事
これまでに作成したモジュール
以下のリンクから、これまでに作成したモジュールを見ることができます。
ルールベースの 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
メソッドで キーボードから入力した文字列で指定された座標 に着手を行う際に、下記のプログラムのように記述することで、x
と y
に 整数に変換できない文字列が代入 されていた場合に、プログラムを停止する代わり に、"整数の座標を入力して下さい" という エラーメッセージを表示する というものです。
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_down
と on_key_press
からその関数を呼び出すという方法もあります。
上記のプログラムから、on_mouse_down
では、event.inaxes
が True
の場合 に、x、y 座標が event.xdata
、event.ydata
のマスに着手 を行います。マウスをクリックした場合のイベントハンドラの 仮引数 event
に代入されたオブジェクト は、イベントハンドラ以外では使われない ので その属性の値を変更してもかまいません。従って、下記のようにプログラムを修正することで、1 ~ 9 のキーが押された場合に着手を行う処理を行うことができます。
-
8 行目:
event.inaxes
にTrue
を代入する -
9、10 行目:
event.xdata
とevent.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
の属性の値を変更してよいかどうかの判断がつかない場合は、下記のプログラムのように、自分で inaxes
、xdata
、ydata
属性を持つ 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_board
が 4 回呼び出され、最初の 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
を呼び出すと モジュール → a
→ b
→ c
の順で関数が呼び出されます。
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>"
という文字列が代入されるので、上記の実行結果には c
、b
、a
、<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_board
が play_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_board
が on_mouse_down
、play_loop
、change_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_down
、change_step
、play_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_down
、play_loop
、change_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)
を記述して下さい。
IntSlider の value
属性の値を変更する処理 は、下記のプログラムのように、update_widgets_status
の中で行われます。また、update_widgets_status
は draw_board
メソッド内から呼び出される ので、draw_board
メソッドを呼び出すことで、IntSlider の value
属性の値が変更される ことがわかります。
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_board
が play_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 行目から、その際に IntSlider の value
属性は 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_count
と len(self.mb.records) - 1
の値は 1 になっています。一方、IntSlider はゲーム開始時に value
と max
属性に 0
が代入された後は 更新されていません。従って、update_widgets_status
によって、以下のように value
と max
属性の値が変化します。
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_down
と play_loop
の中でそれぞれ 1 回ずつ、合計 2 回 draw_board
が呼びだされる ことがわかります。これは 本来は無駄な処理 なのですが、この 無駄な処理が行われた結果 、IntSlider の value
属性の値 が、下記のように 正しい値になります。
下記は、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 行目のメッセージの表示の後では、下記の手順で処理が行われます。
-
self.slider.value = 1
によってvalue
属性の値が 0 から 1 に変更される - その次の
self.slider.max = 1
が 実行される前 に、observe
によって IntSlider に結びつけられた、value
属性の値が変更された際に呼び出される イベントハンドラであるon_slider_changed
が 即座に呼び出される -
on_slider_changed
は、下記のプログラムのようにchange_step
を呼び出すので、その中からdraw_board
が呼び出され、3 ~ 5 行目のメッセージが表示される -
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_board
は move_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
で、IntSlider の value
属性の値 が 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
属性への値の 代入の順序の間違いだけ で、これほど見つけづらく、わかりづらいバグが発生することに驚いた人がいるかもしれませんが、そのような 一見すると些細な違いから このような 見つけづらいバグが発生 することは 実際に良くあります。このようなバグの原因を見つけて修正するためには、豊富なデバッグ作業の経験 と、今回の記事で行ったような 地道なデバッグの作業がどうしても必要になります ので参考にして下さい。
無駄な処理の削除
先程のメッセージから下記の手順で処理が行われていることがわかります。
-
on_mouse_down
が呼び出されて着手が行われ、draw_board
が呼び出される -
draw_board
で 1 手目の局面が描画され、IntSlider のvalue
、max
属性が 0 から 1 になる -
value
属性の値が変更されたので、on_slider_changed
が呼び出され、その中でchange_step(1)
が呼び出される -
change_step
の中で 1 手目の局面に移動する処理とdraw_board
が呼び出される -
draw_board
で 1 手目の局面が描画される。IntSlider の属性の値は変化しない -
on_mouse_down
からplay_loop
が呼び出され、その中でdraw_board
が呼び出される -
draw_board
で 1 手目の局面が描画される。IntSlider の属性の値は変化しない - AI の手番なので
play_loop
の中で AI の着手が行われ、draw_board
が呼び出される -
draw_board
で 2 手目の局面が描画され、IntSlider のvalue
、max
属性が 1 から 2 になる -
value
属性の値が変更されたので、on_slider_changed
が呼び出され、その中でchange_step(2)
が呼び出される -
change_step
の中で 2 手目の局面に移動する処理とdraw_board
が呼び出される -
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_step
と draw_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_board
が play_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 です。
次回の記事