0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonで〇×ゲームのAIを一から作成する その124 リプレイ機能を利用した際のバグの検証と修正

Last updated at Posted at 2024-10-13

目次と前回の記事

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

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

リンク 説明
marubatsu.py Marubatsu、Marubatsu_GUI クラスの定義
ai.py AI に関する関数
test.py テストに関する関数
util.py ユーティリティ関数の定義。現在は gui_play のみ定義されている
tree.py ゲーム木に関する Node、Mbtree クラスの定義
gui.py GUI に関する処理を行う基底クラスとなる GUI クラスの定義

AI の一覧とこれまでに作成したデータファイルについては、下記の記事を参照して下さい。

gui_play の実行速度についての補足

前回の記事で説明し忘れていた点を最初に補足します。前回の記事でMarubatsu_GUI クラスのインスタンスを作成する際に、ゲーム木のデータをファイルから読み込まなくなりました。そのため、gui_play メソッドを実行する際に待たされることが無くなっています。下記のプログラムを実行してそのことを確認して下さい。

from util import gui_play

gui_play()

リプレイ機能を利用した際のバグの検証と修正

前回の記事では、部分木を表示する際に利用する、局面と最善手・評価値の対応表を Dropdown で変更できるように Mbtree_GUI クラスを修正しましたが、その結果 バグが発生 しています。そのバグは、リプレイ機能を利用した際に発生 するバグで、例えば下記の手順で操作を行うと発生します。

  • gui_play() を実行し、(0, 0)、(1, 0) の順で着手を行う
  • 1 手前の局面を表示 する < ボタン1をクリックする と、下図のように ゲーム盤の表示 と、GUI の部分木の赤枠の 選択されたノードの局面一致しなくなる
  • さらに < ボタンをクリックすると下記のような エラーが発生 する
略
File c:\Users\ys\ai\marubatsu\124\tree.py:507, in Mbtree.create_subtree(self)
    505 self.selectednode = self.root
    506 for move in selectedmb.records[1:]:
--> 507     self.selectednode = self.selectednode.children_by_move[move]

KeyError: (1, 0)

上記の 2 種類のバグが発生する原因について少し考えてみて下さい。

バグの原因の検証

実は、このバグを 修正するのは簡単 ですが、このバグの 原因を理解して説明する のはそれほど 簡単ではない のでバグの原因の見つけ方を説明します。

バグは 2 つありますが、1 つ目のバグはエラーが発生しないバグなので、先にエラーが発生する 2 つ目のバグの原因について検証することにします。

エラーメッセージの検証

上記のエラーメッセージから、エラーが Mbtree クラスの create_subtree 内で発生 したことがわかります。また、エラーの原因が self.selectednode.children_by_move に代入された dict に (1, 0) というキーが存在しない 可能性が高いことがわかります。

エラーメッセージの付近で行われる下記のプログラムの処理は、create_subtree で作成した 部分木の中からselectedmb を使って 選択されたノードを探す というものです。

self.selectednode = self.root
for move in selectedmb.records[1:]:
    self.selectednode = self.selectednode.children_by_move[move]

上記のプログラムでは、create_subtree が作成した部分木の ルートノードを表す self.root と、選択された局面の 棋譜を表す selectedmb.records 属性の値を利用して処理を行っているので、それらの値がどのように計算されるかを検証 することにします。

< ボタンをクリックした時に行われる処理の検証

バグは < ボタンをクリックした時に発生する ので、< ボタンをクリックした時に、上記の処理が行われるまでの処理を検証することにします。

on_prev_button_clicked の処理

< ボタンをクリックすると、Marubatsu_GUI クラスの create_event_handler メソッド内で定義された on_prev_button_clicked が呼び出され、以下の手順で処理が行われます。

  • 9 行目の処理によって、実引数に 一手前の手数 を記述して 3 行目で定義された change_step を呼び出す
  • 4 行目で実引数に 一手前の手数 を記述して、Marubatsu クラスの change_step メソッドを呼び出すことで、self.mb を一手前の局面にする
  • 6 行目で GUI の部分木の描画を更新する
1  def create_event_handler(self):

2     # step 手目の局面に移動する
3     def change_step(step):
4         self.mb.change_step(step)
5         # 描画を更新する
6         self.update_gui()  
7            
8     def on_prev_button_clicked(b=None):
9         change_step(self.mb.move_count - 1)

Marubatsu クラスの change_step の処理

上記の 4 行目で呼び出される Marubatsu クラスの change_step は下記のプログラムのように定義されており、records 属性に記録された 棋譜に従ってゲーム開始時の局面から step 回の着手を行う ことで step 手目の局面にする 処理を行います。

change_step について忘れた方は、以前の記事を復習して下さい。

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

リプレイ機能 では change_step を使って 任意の手数の局面を計算 していますが、その際に上記の棋譜のデータが記録された records 属性を利用 しています。そのため、change_step を実行した際に records 属性の値が変化しないようにする 必要があり、その処理を上記の 4 行目と 8 行目 で行っています。

リプレイ機能 で局面を移動しても、棋譜を表す records 属性の値が変化しない ことを覚えておいてください。このことが、バグの原因になっているからです。

Marubatsu_GUI クラスの update_gui の処理

次に、ゲーム盤の表示を更新 するために Marubatsu_GUI クラスの update_gui メソッドが呼び出されます。update_gui メソッドでは self.mbゲーム盤の表示の更新 を行いますが、先ほど行った処理によって self.mb は一手前の局面 になっているので 一手前の局面が表示 されます。そのことは、< ボタンをクリックすることで実際に一手前の局面のゲーム盤が表示されることからも確認できます。

その後で、下記の 3 ~ 7 行目のプログラムで GUI の部分木の表示の更新 の処理を行います。、下記のプログラムの 5 行目で 一手前の局面を GUI の部分木の 選択されたノード とし、6 行目で GUI の部分木の表示を更新 する処理を行います。

1  def update_gui(self):
(ゲーム盤の表示の更新の処理)
2
3          if hasattr(self, "mbtree_gui"):
4              from tree import Node
5  
6              self.mbtree_gui.selectednode = Node(self.mb, depth=self.mb.move_count)
7              self.mbtree_gui.update_gui()

Mbtree_GUI クラスの update_gui の処理

上記の 7 行目で呼び出された Mbtree_GUI クラスの update_gui メソッドでは、下記の手順で 部分木の作成の処理 が行われます。

  • 2 ~ 7 行目:計算する子孫ノードの深さの最大値を表す maxdepth を計算する
  • 8 ~ 13 行目:中心となるノードの局面を表す centermb を計算する
  • 14、15 行目:上記で計算したデータを使って、部分木を作成する処理を呼び出す
 1  def update_gui(self):

 2          if self.selectednode.depth <= 4:
 3              maxdepth = self.selectednode.depth + 1
 4          elif self.selectednode.depth == 5:
 5              maxdepth = 7
 6          else:
 7              maxdepth = 9
 8          if self.selectednode.depth <= 6:
 9              centermb = self.selectednode.mb
10          else:
11              centermb = Marubatsu()
12              for x, y in self.selectednode.mb.records[1:7]:
13                  centermb.move(x, y)
14          self.mbtree = Mbtree(subtree={"centermb": centermb, "selectedmb": self.selectednode.mb, "maxdepth": maxdepth, 
15                               "bestmoves_and_score_by_board": self.bestmoves_and_score_by_board})

selectednode に代入された 一手前の局面のノード は、(0, 0) の着手が行われた 1 手目の局面 なので、上記の処理によって maxdepth には 2 が、centermb には選択されたノードと同じ局面 が代入されて、部分木が作成されます。

下図は < ボタンを一回クリック した際に表示される GUI の部分木 で、確かに (0, 0) の着手が行われた局面を中心とする部分木が作成 されていることが確認できます。

上記の表示で 間違っている のは 赤枠の選択されたノード なので、部分木を作成する処理の中で 選択されたノードを計算する処理を探す と、create_subtree の下記のプログラムでその処理が行われていることがわかります。この処理は、< を 2 回クリックした際にエラーが発生 したプログラムなので、1 つ目のバグの原因もこの処理である可能性が高い ことがわかりました。

1  def create_subtree():

2      self.selectednode = self.root
3      for move in selectedmb.records[1:]:
4          self.selectednode = self.selectednode.children_by_move[move]

create_subtree の処理

バグの原因の可能性が高いプログラムを絞り込む ことができたので、上記のプログラムで行われる処理を検証することにします。

selectedmb は、(0, 0)、(1, 0) の 2 回の着手を行った後 で、Marubatsu クラスの change_step メソッドによって (0, 0) の着手が行われた 1 手目の局面を計算 したものです。先程説明したように、change_step を実行しても、その棋譜を表す records 属性の値は変化しない ので、selectedmb.records には (0, 0) と (1, 0) の 2 手分の着手が記録 されています。上記の 3 行目の for 文では、records 属性に記録された すべての着手に対する繰り返し処理 が行われるので、selectednode には、(0, 0) と (1, 0) の 2 手分の着手を行った局面に対するノードが計算 されて代入されることになります。

これが < ボタンを 1 回クリックした際に、上部のゲーム盤には一手前の局面が表示されるが、下部の GUI の部分木の赤枠の 選択された局面が変化しない ことの原因です。

バグの修正

従って、1 つ目のバグは、下記のプログラムの 7 行目のように、棋譜に記録された着手 を、現在の手数を表す move_count 属性の数だけ行う ようにすることで修正することができます2。また、実は 2 つ目のエラーが発生するバグの原因 は、1 つ目のバグと同じ なので、この修正によって 両方のバグを修正 することができます。

 1  from tree import Mbtree, Node
 2  from marubatsu import Marubatsu
 3  from copy import deepcopy
 4
 5  def create_subtree(self):   
元と同じなので省略
 6      self.selectednode = self.root
 7      for move in selectedmb.records[1:selectedmb.move_count+1]:
 8          self.selectednode = self.selectednode.children_by_move[move]
 9    
10  Mbtree.create_subtree = create_subtree
行番号のないプログラム
from tree import Mbtree, Node
from marubatsu import Marubatsu
from copy import deepcopy

def create_subtree(self):   
    bestmoves_and_score_by_board = self.subtree["bestmoves_and_score_by_board"]
    self.root = Node(Marubatsu(), bestmoves_and_score_by_board=bestmoves_and_score_by_board)
    
    depth = 0
    nodelist = [self.root]
    centermb = self.subtree["centermb"]
    centerdepth = centermb.move_count
    if centerdepth == 0:
        self.centernode = self.root
    records = centermb.records
    maxdepth = self.subtree["maxdepth"]
    while len(nodelist) > 0:
        childnodelist = []
        for node in nodelist:
            if depth < centerdepth - 1:
                childmb = deepcopy(node.mb)
                x, y = records[depth + 1]
                childmb.move(x, y)
                childnode = Node(childmb, parent=node, depth=depth+1, 
                                bestmoves_and_score_by_board=bestmoves_and_score_by_board)   
                node.insert(childnode)
                childnodelist.append(childnode)
            elif depth < maxdepth:
                node.calc_children(bestmoves_and_score_by_board=bestmoves_and_score_by_board)                   
                if depth == centerdepth - 1:
                    for move, childnode in node.children_by_move.items():
                        if move == records[depth + 1]:
                            self.centernode = childnode
                            childnodelist.append(self.centernode)
                        else:
                            if childnode.mb.status == Marubatsu.PLAYING:
                                childnode.children.append(None)
                else:
                    childnodelist += node.children
            else:
                if node.mb.status == Marubatsu.PLAYING:
                    childmb = deepcopy(node.mb)
                    board_str = node.mb.board_to_str()               
                    x, y = bestmoves_and_score_by_board[board_str]["bestmoves"][0]
                    childmb.move(x, y)
                    childnode = Node(childmb, parent=node, depth=depth+1, 
                                    bestmoves_and_score_by_board=bestmoves_and_score_by_board)   
                    node.insert(childnode)
                    childnodelist.append(childnode)
        nodelist = childnodelist
        depth += 1

    selectedmb = self.subtree["selectedmb"]
    self.selectednode = self.root
    for move in selectedmb.records[1:selectedmb.move_count+1]:
        self.selectednode = self.selectednode.children_by_move[move]
    
Mbtree.create_subtree = create_subtree
修正箇所
from tree import Mbtree, Node
from marubatsu import Marubatsu
from copy import deepcopy

def create_subtree(self):   
元と同じなので省略
    self.selectednode = self.root
-   for move in selectedmb.records[1:]:
+   for move in selectedmb.records[1:selectedmb.move_count+1]:
        self.selectednode = self.selectednode.children_by_move[move]
    
Mbtree.create_subtree = create_subtree

上記の修正後に下記のプログラムを実行し、先程と同じ手順で操作を行ってもバグが発生しなくなったことを確認して下さい。また、様々な着手とリプレイ機能のボタンの操作を行い、プログラムが正しく動作することを確認して下さい。

gui_play()

2 つ目のバグでエラーが発生する原因

1 つ目のバグ では エラーが発生しない が、2 つ目のバグエラーが発生する 点が気になっている人がいるかもしれません。その理由について少し考えてみて下さい。

2 つ目のバグは、(0, 0)、(1, 0) の着手を行った後で、< ボタンを 2 回クリック しているので、ゲーム開始時深さ 0 のノード が GUI の部分木の 選択されたノード になります。

その場合は、Mbtree_GUI クラスの update_gui の下記の処理によって、maxdepth には 1 が、centermb にはゲーム開始時の局面 が代入されて部分木が計算されます。

 1  def update_gui(self):

 2          if self.selectednode.depth <= 4:
 3              maxdepth = self.selectednode.depth + 1
 4          elif self.selectednode.depth == 5:
 5              maxdepth = 7
 6          else:
 7              maxdepth = 9
 8          if self.selectednode.depth <= 6:
 9              centermb = self.selectednode.mb
10          else:
11              centermb = Marubatsu()
12              for x, y in self.selectednode.mb.records[1:7]:
13                  centermb.move(x, y)
14          self.mbtree = Mbtree(subtree={"centermb": centermb, "selectedmb": self.selectednode.mb, "maxdepth": maxdepth, 
15                               "bestmoves_and_score_by_board": self.bestmoves_and_score_by_board})

その結果、作成される部分木は、下図のように 深さ 1 までの子孫ノードはすべて計算 されますが、深さ 2 以降の子孫ノード最善手を計算し続けた場合の局面のノードしか計算されません。また、下図からわかるように (0, 0) に着手を行ったノードの子ノード には、(0, 0)、(1, 1) に着手 を行ったノード しか存在しません

そのため、修正前の下記のプログラムを実行すると、ルートノードから (0, 0)、(1, 0) の順で着手を行ったノードを計算しようとした結果、(0, 0) に着手を行ったノードchildren_by_move 属性に代入された dict には (0, 1) というキーは存在しない ので、KeyError: (1, 0) というエラーが発生することになります。

1  def create_subtree():

2      self.selectednode = self.root
3      for move in selectedmb.records[1:]:
4          self.selectednode = self.selectednode.children_by_move[move]

このことから、バグを修正する前に、(0, 0)、(1, 1) の順で着手 を行い、< ボタンを 2 回クリックした場合は、作成した部分木に (0, 0)、(1, 1) の着手を行ったノードが 偶然存在する ので エラーは発生しない ことがわかります。興味がある方は、バグを修正する前のプログラムに戻して実際に試してみて下さい。

また、(0, 0)、(1, 1) の順で着手 を行った後で < ボタンを 1 回クリック した際に作成される部分木は、下図のように 深さ 2 までの子孫ノードがすべて計算される ので、(0, 0) に着手を行ったノードの子ノードには (1, 0) に着手を行ったノードが存在します。そのため 2 手目までの着手 を行った局面で < ボタンを 1 回クリック した場合は、2 手目までにどのような着手を行っても エラーは発生しません

このような、バグがあるにも関わらす特定の条件ではエラーが発生しない バグは、< ボタンを 1 回だけクリックした場合のように、偶然その条件を満たしてしまう とバグの存在が みづけづらい ので 非常に厄介なバグ です。このようなバグは、今回の記事のように実際によく発生することがあるので、詳しく紹介しました。

Marubatsu_GUI クラスの改良

Marubatsu_GUI クラスの 改良 をいくつか思いついたので実装することにします。

行う改良は以下の通りです。最初の 3 つは同様の処理を別の所で実装済なので、過去で行った実装を参考に実装することにします。

4 つ目以降の改良については次回の記事で行います。

  • FloatSlider でゲーム盤の表示の大きさを変更できるようにする
  • GUI の部分木の表示の有無を切り替える「木」ボタンを、「評価値の表示」ボタンのように ON/OFF の状態がわかるようにする
  • GUI の部分木の選択されたノードを、ゲーム盤に表示されている局面のノードにリセットする「リ」ボタンを、GUI の部分木の表示が OFF になっている場合は操作できないようにする
  • 現在の局面の状況がわかるようにする
  • ゲーム盤のマスに、そのマスに着手を行った場合の局面の状況を表示する
  • ゲーム盤のマスに、そのマスに着手を行った際の AI の評価値を表示できるようにする

最初の 3 つの改良はこれまでに同様の処理を行っているのでまとめて行うことにします。

Marubatsu_GUI クラス の __init__ メソッドの修正

Mbtree_GUI クラスでは、部分木に評価値を表示するかどうかを show_score という属性に代入しましたので、Marubatsu_GUI クラスでも GUI の部分木を表示するか どうかを show_subtree という属性に代入することにします。

まず、最初に GUI の部分木を表示するか どうかを Marubatsu_GUI クラスの __init__ メソッドの仮引数 show_subtree に代入することにします。

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

  • 5 行目:仮引数 show_subtree を追加する
  • 8 行目show_subtree を同名の属性に代入する
  • 10 行目show_subtree の値に応じて、部分木の 表示の有無を設定する
 1  from marubatsu import Marubatsu_GUI
 2  from tkinter import Tk
 3  import os
 4
 5  def __init__(self, mb, params, names, ai_dict,
 6               scoretable_dict, show_subtree, seed, size):   
元と同じなので省略
 7      self.names = names
 8      self.show_subtree = show_subtree
元と同じなので省略
 9      self.mbtree_gui = Mbtree_GUI(scoretable_dict, size=0.1)
10      self.mbtree_gui.vbox.layout.display = None if self.show_subtree else "None"
11   
12  Marubatsu_GUI.__init__ = __init__
行番号のないプログラム
from marubatsu import Marubatsu_GUI
from tkinter import Tk
import os

def __init__(self, mb, params, names, ai_dict,
             scoretable_dict, show_subtree, seed, size):   
    if params is None:
        params = [{}, {}]
    if ai_dict is None:
        ai_dict = {}
    if names is None:
        names = [None, None]
    for i in range(2):
        if names[i] is None:
            if mb.ai[i] is None:
                names[i] = "人間"
            else:
                names[i] = mb.ai[i].__name__
    
    # JupyterLab からファイルダイアログを開く際に必要な前処理
    root = Tk()
    root.withdraw()
    root.call('wm', 'attributes', '.', '-topmost', True)  

    # save フォルダが存在しない場合は作成する
    if not os.path.exists("save"):
        os.mkdir("save")        
    
    self.mb = mb
    self.ai_dict = ai_dict
    self.params = params
    self.names = names
    self.show_subtree = show_subtree
    self.seed = seed
    self.size = size
    
    super(Marubatsu_GUI, self).__init__()
    
    from tree import Mbtree_GUI

    self.mbtree_gui = Mbtree_GUI(scoretable_dict, size=0.1)
    self.mbtree_gui.vbox.layout.display = None if self.show_subtree else "None"    

Marubatsu_GUI.__init__ = __init__
修正箇所
from marubatsu import Marubatsu_GUI
from tkinter import Tk
import os

def __init__(self, mb, params, names, ai_dict,
-            scoretable_dict, seed, size):   
+            scoretable_dict, show_subtree, seed, size):   
元と同じなので省略
    self.names = names
+   self.show_subtree = show_subtree
元と同じなので省略
    self.mbtree_gui = Mbtree_GUI(scoretable_dict, size=0.1)
+   self.mbtree_gui.vbox.layout.display = None if self.show_subtree else "None"
    
Marubatsu_GUI.__init__ = __init__

Marubatsu クラスの play メソッドの修正

次に、Marubatsu_GUI クラスのインスタンスを作成する Marubatsu クラスの play メソッドを以下のプログラムのように修正します。

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

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

Marubatsu.play = play
修正箇所
def play(self, ai, ai_dict=None, params=None, names=None, scoretable_dict=None,
-        verbose=True, seed=None, gui=False, size=3):
+        show_subtree=True, verbose=True, seed=None, gui=False, size=3):
元と同じなので省略
    if gui:
        mb_gui = Marubatsu_GUI(self, params=params, names=names, ai_dict=ai_dict, scoretable_dict=scoretable_dict,
-                              seed=seed, size=size)
+                              show_subtree=show_subtree, seed=seed, size=size)
元と同じなので省略

Marubatsu.play = play

Marubatsu_GUI クラスの create_widgets メソッドの修正

FloatSlider を作成する必要がある ので、下記のプログラムの 6、7 行目のように create_widgets メソッドを修正します。この処理は以前の記事と同様ですが、ゲーム盤の表示サイズを代入する Marubatsu_GUI クラスの __init__ メソッドの 仮引数 size のデフォルト値は 3 なので、min1.0max5.0step0.1 としました。

1  import ipywidgets as widgets
2
3  def create_widgets(self):
元と同じなので省略
4      self.help_button = self.create_button("", 34)
5        
6      self.size_slider = widgets.FloatSlider(min=1.0, max=5.0, step=0.1,
7                                             description="size", value=self.size)
元と同じなので省略
8    
9  Marubatsu_GUI.create_widgets = create_widgets
行番号のないプログラム
import ipywidgets as widgets

def create_widgets(self):
    # 乱数の種の Checkbox と IntText を作成する
    self.checkbox = widgets.Checkbox(value=self.seed is not None, description="乱数の種",
                                     indent=False, layout=widgets.Layout(width="100px"))
    self.inttext = widgets.IntText(value=0 if self.seed is None else self.seed,
                                   layout=widgets.Layout(width="80px"))   

    # 読み書き、ヘルプのボタンを作成する
    self.load_button = self.create_button("開く", 50)
    self.save_button = self.create_button("保存", 50)
    self.show_tree_button = self.create_button("", 34)
    self.reset_tree_button = self.create_button("", 34)
    self.help_button = self.create_button("", 34)
        
    self.size_slider = widgets.FloatSlider(min=1.0, max=5.0, step=0.1,
                                           description="size", value=self.size)
    
    # AI を選択する Dropdown を作成する
    self.create_dropdown()
    # 変更、リセット、待ったボタンを作成する
    self.change_button = self.create_button("変更", 50)
    self.reset_button = self.create_button("リセット", 80)
    self.undo_button = self.create_button("待った", 60)    
    
    # リプレイのボタンとスライダーを作成する
    self.first_button = self.create_button("<<", 50)
    self.prev_button = self.create_button("<", 50)
    self.next_button = self.create_button(">", 50)
    self.last_button = self.create_button(">>", 50)     
    self.slider = widgets.IntSlider(layout=widgets.Layout(width="200px"))
    # ゲーム盤の画像を表す figure を作成する
    self.create_figure()

    # print による文字列を表示する Output を作成する
    self.output = widgets.Output()       
    
    # ヘルプを表示する Output を作成し、表示の設定を行う
    self.help = widgets.Output()
    self.print_helpmessage()
    self.help.layout.display = "none"   
    
Marubatsu_GUI.create_widgets = create_widgets
修正箇所
import ipywidgets as widgets

def create_widgets(self):
元と同じなので省略
    self.help_button = self.create_button("", 34)
        
+   self.size_slider = widgets.FloatSlider(min=1.0, max=5.0, step=0.1,
+                                          description="size", value=self.size)
元と同じなので省略
    
Marubatsu_GUI.create_widgets = create_widgets

display_widgets メソッドの修正

上記で作成した FloatSlider は、下記のプログラムの 6 行目のように、新しく作成した HBox に配置することにします。なお、次回の記事で別のウィジェットを配置する予定なので、この HBox の内容が少ない点は気にする必要はありません。

  • 6 行目:FloatSlider を配置した HBox を作成する
  • 8、10 行目hbox の名前の末尾の数字を一つずつずらす
  • 12 行目:6 行目で作成した HBox を表示するように修正する
 1  def display_widgets(self):
 2      # 乱数の種のウィジェット、読み書き、ヘルプのボタンを横に配置した HBox を作成する
 3      hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button, self.save_button, 
 4                            self.show_tree_button, self.reset_tree_button, self.help_button])
 5      # ゲーム盤のサイズの変更の FloatSlider を配置した HBox を作成する
 6      hbox2 = widgets.HBox([self.size_slider])
 7      # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
 8      hbox3 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
 9      # リプレイ機能のボタンを横に配置した HBox を作成する
10      hbox4 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
11      # hbox1 ~ hbox4、Figure、Output を縦に配置した VBox を作成し、表示する
12      display(widgets.VBox([hbox1, hbox2, hbox3, hbox4, self.fig.canvas, self.output, self.help])) 
13
14  Marubatsu_GUI.display_widgets = display_widgets
行番号のないプログラム
def display_widgets(self):
    # 乱数の種のウィジェット、読み書き、ヘルプのボタンを横に配置した HBox を作成する
    hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button, self.save_button, 
                          self.show_tree_button, self.reset_tree_button, self.help_button])
    # ゲーム盤のサイズの変更の FloatSlider を配置した HBox を作成する
    hbox2 = widgets.HBox([self.size_slider])
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
    hbox3 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
    hbox4 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
    # hbox1 ~ hbox4、Figure、Output を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2, hbox3, hbox4, self.fig.canvas, self.output, self.help])) 

Marubatsu_GUI.display_widgets = display_widgets
修正箇所
def display_widgets(self):
    # 乱数の種のウィジェット、読み書き、ヘルプのボタンを横に配置した HBox を作成する
    hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button, self.save_button, 
                          self.show_tree_button, self.reset_tree_button, self.help_button])
    # ゲーム盤のサイズの変更の FloatSlider を配置した HBox を作成する
+   hbox2 = widgets.HBox([self.size_slider])
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
-   hbox2 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
+   hbox3 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
-   hbox3 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
+   hbox4 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
    # hbox1 ~ hbox4、Figure、Output を縦に配置した VBox を作成し、表示する
-   display(widgets.VBox([hbox1, hbox2, hbox3, self.fig.canvas, self.output, self.help])) 
+   display(widgets.VBox([hbox1, hbox2, hbox3, hbox4, self.fig.canvas, self.output, self.help])) 

Marubatsu_GUI.display_widgets = display_widgets

上記の修正後に下記のプログラムを実行すると、実行結果のように FloatSlider が表示されるようになります。

gui_play()

実行結果

create_event_handler の修正

次に、create_event_handler を以下のプログラムのように修正します。

  • 4 ~ 7 行目:「木」ボタンをクリックした際に呼び出されるイベントハンドラを定義する。行う処理は Mbtree_GUI クラスの on_score_button_clicked と同様だが、GUI の部分木の表示の有無の切り替えは update_gui では行えないので 6 行目で show_subtree 属性の値に応じて部分木の表示の有無の切り替える処理を行う点が異なる
  • 11 ~ 15 行目:FloatSlider の値を変更した際に呼び出されるイベントハンドラを定義する。行う処理は Mbtree_GUI クラスの on_size_slider_changed と同様だが、self.size を幅と高さに設定している点が異なる
  • 17 行目:上記のイベントハンドラと FloatSlider を結び付ける
 1  import math
 2
 3  def create_event_handler(self):
元と同じなので省略
 4      def on_show_tree_button_clicked(b=None):
 5          self.show_subtree = not self.show_subtree
 6          self.mbtree_gui.vbox.layout.display = None if self.show_subtree else "none"
 7          self.update_gui()
 8
元と同じなので省略 
 9      self.help_button.on_click(on_help_button_clicked)
10    
11      def on_size_slider_changed(changed):
12          self.size = changed["new"]
13          self.fig.set_figwidth(self.size)
14          self.fig.set_figheight(self.size)
15          self.update_gui()
16
17    self.size_slider.observe(on_size_slider_changed, names="value")
元と同じなので省略
18    
19  Marubatsu_GUI.create_event_handler = create_event_handler
行番号のないプログラム
import math

def create_event_handler(self):
    # 乱数の種のチェックボックスのイベントハンドラを定義する
    def on_checkbox_changed(changed):
        self.update_widgets_status()
        
    self.checkbox.observe(on_checkbox_changed, names="value")

    # 開く、保存ボタンのイベントハンドラを定義する
    def on_load_button_clicked(b=None):
        path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
                                        initialdir="save")
        if path != "":
            with open(path, "rb") as f:
                data = pickle.load(f)
                self.mb.records = data["records"]
                self.mb.ai = data["ai"]
                self.params = data["params"] if "params" in data else [ {}, {} ]
                if "names" in data:
                    names = data["names"]
                else:
                    names = [ "人間" if mb.ai[i] is None else mb.ai[i].__name__ for i in range(2)]                       
                options = self.dropdown_list[0].options.copy()
                for i in range(2):
                    value = (self.mb.ai[i], self.params[i]) 
                    if not value in options.values():
                        options[names[i]] = value
                for i in range(2):
                    self.dropdown_list[i].options = options
                    self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            
                change_step(data["move_count"])
                if data["seed"] is not None:                   
                    self.checkbox.value = True
                    self.inttext.value = data["seed"]
                else:
                    self.checkbox.value = False
                    
    def on_save_button_clicked(b=None):
        names = [ self.dropdown_list[i].label for i in range(2) ]     
        timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
        fname = f"{names[0]} VS {names[1]} {timestr}"
        path = filedialog.asksaveasfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
                                            initialdir="save", initialfile=fname,
                                            defaultextension="mbsav")
        if path != "":
            with open(path, "wb") as f:
                data = {
                    "records": self.mb.records,
                    "move_count": self.mb.move_count,
                    "ai": self.mb.ai,
                    "params": self.params,
                    "names": names,
                    "seed": self.inttext.value if self.checkbox.value else None
                }
                pickle.dump(data, f)
                
    def on_show_tree_button_clicked(b=None):
        self.show_subtree = not self.show_subtree
        self.mbtree_gui.vbox.layout.display = None if self.show_subtree else "none"
        self.update_gui()
        
    def on_reset_tree_button_clicked(b=None):
        self.update_gui()
                
    def on_help_button_clicked(b=None):
        self.help.layout.display = "none" if self.help.layout.display is None else None
    
    self.load_button.on_click(on_load_button_clicked)
    self.save_button.on_click(on_save_button_clicked)
    self.show_tree_button.on_click(on_show_tree_button_clicked)
    self.reset_tree_button.on_click(on_reset_tree_button_clicked)
    self.help_button.on_click(on_help_button_clicked)
    
    def on_size_slider_changed(changed):
        self.size = changed["new"]
        self.fig.set_figwidth(self.size)
        self.fig.set_figheight(self.size)
        self.update_gui()

    self.size_slider.observe(on_size_slider_changed, names="value")
    
    # 変更ボタンのイベントハンドラを定義する
    def on_change_button_clicked(b):
        for i in range(2):
            self.mb.ai[i], self.params[i] = self.dropdown_list[i].value
        self.mb.play_loop(self, self.params)

    # リセットボタンのイベントハンドラを定義する
    def on_reset_button_clicked(b=None):
        # 乱数の種のチェックボックスが ON の場合に、乱数の種の処理を行う
        if self.checkbox.value:
            random.seed(self.inttext.value)
        self.mb.restart()
        self.output.clear_output()
        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.update_gui()
        
    # イベントハンドラをボタンに結びつける
    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.update_gui()        

    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)
            with self.output:
                self.mb.move(x, y)                
            # 次の手番の処理を行うメソッドを呼び出す
                self.mb.play_loop(self, self.params)

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    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,            
            "-": on_load_button_clicked,            
            "l": on_load_button_clicked,            
            "+": on_save_button_clicked,            
            "s": on_save_button_clicked,            
            "*": on_help_button_clicked,            
            "h": on_help_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
修正箇所
import math

def create_event_handler(self):
元と同じなので省略
    def on_show_tree_button_clicked(b=None):
-       self.mbtree_gui.vbox.layout.display = "none" if self.mbtree_gui.vbox.layout.display is None else None
+       self.show_subtree = not self.show_subtree
+       self.mbtree_gui.vbox.layout.display = None if self.show_subtree else "none"
+       self.update_gui()
元と同じなので省略 
    self.help_button.on_click(on_help_button_clicked)
    
+   def on_size_slider_changed(changed):
+       self.size = changed["new"]
+       self.fig.set_figwidth(self.size)
+       self.fig.set_figheight(self.size)
+       self.update_gui()

+   self.size_slider.observe(on_size_slider_changed, names="value")
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler

update_widgets_status メソッドの修正

「木」ボタンと「リ」ボタンが、部分木の表示の有無によって表示が変わるように update_widgets_status メソッドを下記のプログラムのように修正します。

1  def update_widgets_status(self):
2  	  self.inttext.disabled = not self.checkbox.value
3     self.set_button_color(self.show_tree_button, self.show_subtree)    
4     self.set_button_status(self.reset_tree_button, not self.show_subtree)
元と同じなので省略
5
6  Marubatsu_GUI.update_widgets_status = update_widgets_status
行番号のないプログラム
def update_widgets_status(self):
    self.inttext.disabled = not self.checkbox.value
    self.set_button_color(self.show_tree_button, self.show_subtree)    
    self.set_button_status(self.reset_tree_button, not self.show_subtree)    
    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)    
    # value 属性よりも先に max 属性に値を代入する必要がある点に注意!
    self.slider.max = len(self.mb.records) - 1
    self.slider.value = self.mb.move_count

Marubatsu_GUI.update_widgets_status = update_widgets_status
修正箇所
def update_widgets_status(self):
    self.inttext.disabled = not self.checkbox.value
+   self.set_button_color(self.show_tree_button, self.show_subtree)    
+   self.set_button_status(self.reset_tree_button, not self.show_subtree)    
元と同じなので省略
 
Marubatsu_GUI.update_widgets_status = update_widgets_status

実行結果は省略しますが、上記の修正後に下記のプログラムを実行し、以下の確認を行ってください。

  • FloatSlider を操作することで ゲーム盤の表示の大きさが変更される
  • 「木」ボタンをクリックして GUI の部分木の表示の有無を変更すると、「木」ボタンの色が変わる
  • 「木」ボタンをクリックして GUI の部分木の表示を消すと、「リ」ボタンが操作できなくなる
update_gui()

文字の表示の大きさの修正

上記の修正によって、ゲーム盤の表示の大きさを変更できるようになりましたが、ゲーム盤の大きさを変更しても表示される 文字の大きさが変わらない という問題があります。そのため、例えばゲーム盤のサイズを 1.5 にすると、下図のように 文字がはみ出て表示される ようになります。

この問題を修正するためには、以前の記事で説明したように、文字の大きさゲーム盤の大きさ比例した大きさで表示する ように修正する必要があります。

ゲーム盤の文字の表示は Marubatsu_GUI クラスの update_gui メソッドで行われるので、文字を表示する処理を下記のプログラムのように修正します。

  • 3、4 行目:元のプログラムでは fontsize は 20 に設定 されており、self.size には 初期設定では 3 が代入 されるので、fontsize20 / 3 * self.size という式で計算することができるが、20 / 3 は割り切れないので、7 * self.size に修正した
1  def update_gui(self):
元と同じなので省略
2      ax.text(1.5, 3.5, f"{self.dropdown_list[0].label} VS {self.dropdown_list[1].label}", 
3              fontsize=7*self.size, ha="center")   
元と同じなので省略
4      ax.text(0, -0.2, text, fontsize=7*self.size)
元と同じなので省略
5        
6  Marubatsu_GUI.update_gui = update_gui
行番号のないプログラム
def update_gui(self):
    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)
        
    # 上部のメッセージを描画する
    # 対戦カードの文字列を計算する
    ax.text(1.5, 3.5, f"{self.dropdown_list[0].label} VS {self.dropdown_list[1].label}", 
            fontsize=7*self.size, ha="center")   

    # ゲームの決着がついていない場合は、手番を表示する
    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=7*self.size)

    self.draw_board(ax, self.mb)

    self.update_widgets_status()

    if hasattr(self, "mbtree_gui"):
        from tree import Node

        self.mbtree_gui.selectednode = Node(self.mb, depth=self.mb.move_count)
        self.mbtree_gui.update_gui()
        
Marubatsu_GUI.update_gui = update_gui
修正箇所
def update_gui(self):
元と同じなので省略
    # 上部のメッセージを描画する
    # 対戦カードの文字列を計算する
    ax.text(1.5, 3.5, f"{self.dropdown_list[0].label} VS {self.dropdown_list[1].label}", 
-           fontsize=20, ha="center")   
+           fontsize=7*self.size, ha="center")   
元と同じなので省略
-   ax.text(0, -0.2, text, fontsize=20)
+   ax.text(0, -0.2, text, fontsize=7*self.size)
元と同じなので省略
        
Marubatsu_GUI.update_gui = update_gui

上記の修正後に下記のプログラムを実行し、ゲーム盤の大きさを変更すると実行結果のように、文字の大きさも変更されることが確認できます。

gui_play()

枠の太さの表示の修正

ゲーム盤の大きさを小さくすると、上図のように 枠線が太くなるように見える という現象が発生します。これは、matplotlib では、線の太さ は文字の大きさと同様に、Figure の大きさや Axes の表示範囲を変えても 変わらないことが原因 です。

ゲーム盤に表示する 線の太さdraw_board メソッドの 仮引数 lw に代入 することで指定できるので、下記のプログラムのように修正することで、ゲーム盤の表示の大きさにあわせて線の太さが変わるようになります。

  • 2 行目draw_board にキーワード引数 lw=0.7*self.size を記述する。仮引数 lw のデフォルト値が 2 なので、このような式にした
1  def update_gui(self):
元と同じなので省略
2      self.draw_board(ax, self.mb, lw=0.7*self.size)
元と同じなので省略
3        
4  Marubatsu_GUI.update_gui = update_gui
行番号のないプログラム
def update_gui(self):
    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)
        
    # 上部のメッセージを描画する
    # 対戦カードの文字列を計算する
    ax.text(1.5, 3.5, f"{self.dropdown_list[0].label} VS {self.dropdown_list[1].label}", 
            fontsize=7*self.size, ha="center")   

    # ゲームの決着がついていない場合は、手番を表示する
    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=7*self.size)

    self.draw_board(ax, self.mb, lw=0.7*self.size)

    self.update_widgets_status()

    if hasattr(self, "mbtree_gui"):
        from tree import Node

        self.mbtree_gui.selectednode = Node(self.mb, depth=self.mb.move_count)
        self.mbtree_gui.update_gui()
        
Marubatsu_GUI.update_gui = update_gui
修正箇所
def update_gui(self):
元と同じなので省略
    self.draw_board(ax, self.mb, lw=0.7*self.size)
元と同じなので省略
        
Marubatsu_GUI.update_gui = update_gui

上記の修正後に下記のプログラムを実行し、ゲーム盤の大きさを変更すると実行結果のように、文字の大きさも変更されることが確認できます。

gui_play()

今回の記事のまとめ

今回の記事では、リプレイ機能を利用した際のバグの検証と修正を行いました。また、Marubatsu_GUI クラスのいくつかの改良を行いました。

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

リンク 説明
marubatsu.ipynb 本記事で入力して実行した JupyterLab のファイル
marubatsu_new.py 今回の記事で更新した marubatsu.py
tree_new.py 今回の記事で更新した tree.py

次回の記事

  1. ゲーム盤の上にあるリプレイ機能の < ボタンです。ゲーム盤の下にある GUI の部分木の ← ボタンではない点に注意して下さい

  2. records 属性に代入された list には、0 番ではなく 1 番の要素から着手のデータが記録されるので、move_count 手目までの着手を行うためには、move_count+1 と記述する必要があります

0
0
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?