0
0

Pythonで〇×ゲームのAIを一から作成する その116 〇×ゲームの GUI とゲーム木の表示の連動

Last updated at Posted at 2024-09-15

目次と前回の記事

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

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

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

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

〇×ゲームの GUI とゲーム木の表示の連動

将棋や囲碁の中継などで、プロ棋士の対戦中の局面に対して下記のような表示が行われるのを見たことがある人はいないでしょうか?

  • 現在の局面に対して どちらが優勢であるかを表す評価値の表示
  • 現在の局面に対する 合法手を評価値の高い順に並べて表示 する
  • 現在の局面から 数手先までの最善手の一覧

下記は Abema TV で放映されている将棋の中継の画面の一部です1。上図がどちらが優勢かを表しており、下図の下部が現在の局面に対する合法手を評価値の高い順に並べたもの、下図の上部が現在の局面から 3 手先までの最善手を表しています。


このような表示は、ゲームに詳しくない人にとって どちらが優勢であるかが わかったり、今後の試合の展開が予想できる ため便利ですし、ゲームに詳しい人にとっては 局面の分析を行う ことができるので有用です。

そこで、〇×ゲームの GUI でもこのような表示を行うようにすることにします。具体的にどのような内容を表示すればよいかについて少し考えてみて下さい。

具体的な表示方法の検討

以前の記事aidata.mbtree というファイルに保存した〇×ゲームのゲーム木のデータには、全ての局面の評価値最善手が計算済 です。従って、現在の局面の評価値 はその ゲーム木のデータを使って表示 することができます。

将棋や囲碁のようなゲームは、ほとんどの局面では 合法手が多すぎて全てを表示することは困難です2。そのため、先程の図のように、合法手の中で評価値の高い順にいくつかの合法手のみを表示するのが一般的です。

一方、〇×ゲーム の場合は 合法手の数は最大でも 9 しかない ので すべての合法手に対する評価値を表示することが可能 です。また、そのような表示 はゲーム木の部分木を表示する Mbtree クラスの draw_subtree メソッドで定義済 です。そのことを以下に示します。

draw_subtree で表示するノードは下図のように 評価値ゲーム盤の色で表現 されます。

また、それぞれの色の意味は下記の表のようになります。

ゲーム盤の色 評価値 局面の状況
水色 1 〇 の必勝の局面
黄色 0 引き分けの局面
ピンク -1 × の必勝の局面

現在の局面に対応するノードを中心 とする、上図のような ゲーム木の部分木を表示 することで、以下の内容が表示されることになります。

  • 赤枠で囲まれた現在の局面を表すノードの色で、現在の局面の評価値がわかる
  • 赤枠の子ノードの色を見ることで、合法手を着手した局面の評価値がわかる

また、上記は先ほど言及した、下記の表示に対応します。

  • 現在の局面に対して どちらが優勢であるかを表す評価値の表示
  • 現在の局面に対する 合法手を評価値の高い順に並べて表示 する

先程言及した「現在の局面から数手先までの最善手」の表示方法については、次回の記事で取り扱うことにします。

次に、その部分木を どこに表示するかを決める 必要がありますが、本記事では ゲーム盤の下に表示する ことにします。

Mbtree_GUI クラスによる部分木の表示

現在の局面を中心とする部分木の表示は Mbtree クラスの draw_subtree メソッドで行うことができますが、draw_subtree で表示する部分木は、深さが浅い局面のノードを中心とする場合 は、表示範囲が大きくなりすぎないように、次の深さのノードまでしか表示できません。そのため、現在の局面から 2 手以上先の局面の状況がわからない という欠点があります。

そこで、本記事ではゲーム盤の下に表示するのは create_subtree による部分木ではなく、Mbtree_GUI クラスによる部分木を表示 することにします。Mbtree_GUI クラスで表示された部分木は、ノードをクリックするなどの操作によって 別のノードを中心として表示し直すことができる ので、2 手以上先の好きな局面を中心とする部分木を表示することができるようになります。また、過去に行った着手をさかのぼってが最善手であったかどうかの検討を行うこともできるようになります。

以降は、ゲーム盤の下に Mbtree_GUI クラスを使って表示する部分木のことを、GUI の部分木 と表記することにします。

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

ゲーム盤の下に GUI の部分木を表示する方法 は簡単で、下記のプログラムのように、Marubatsu_GUI クラスの __init__ メソッドで〇×ゲームの GUI のウィジェットを作成して表示する処理を 行った後 で、Mbtree_GUI クラスのインスタンスを作成 するだけで行えます。なお、GUI の部分木のデフォルトの表示サイズ(size=0.15)では大きすぎる気がしたので、下記のプログラムではキーワード属性 size=0.1 を記述して少し小さめに表示することにしました。大きさを変えたい人は自由に変更して下さい。

  • 9、10 行目__init__ メソッドで 〇×ゲームのウィジェットを作成して表示する処理は、7 行目の super の呼び出しで行われるので、その後で Mbtree_GUI クラスのインスタンスを作成する際に必要となるゲーム木のデータをファイルから読み込み、インスタンスを作成する。読み込んだゲーム木のデータと、作成したインスタンスは後で利用するので、mbtreembtree_gui という属性に代入する。7 行目の super の処理について忘れた方は、以前の記事を復習すること
 1  from marubatsu import Marubatsu_GUI
 2  from tree import Mbtree, Mbtree_GUI
 3  from tkinter import Tk, filedialog
 4  import os
 5
 6  def __init__(self, mb, params, names, ai_dict, seed, size):   
元と同じなので省略
 7      super(Marubatsu_GUI, self).__init__()   
 8    
 9      self.mbtree = Mbtree.load("../data/aidata")
10      self.mbtree_gui = Mbtree_GUI(self.mbtree, size=0.1)
11    
12  Marubatsu_GUI.__init__ = __init__
行番号のないプログラム
from marubatsu import Marubatsu_GUI
from tree import Mbtree, Mbtree_GUI
from tkinter import Tk, filedialog
import os

def __init__(self, mb, params, names, ai_dict, 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.seed = seed
    self.size = size
    
    super(Marubatsu_GUI, self).__init__()   
    
    self.mbtree = Mbtree.load("../data/aidata")
    self.mbtree_gui = Mbtree_GUI(self.mbtree, size=0.1)
    
Marubatsu_GUI.__init__ = __init__
修正箇所
from marubatsu import Marubatsu_GUI
from tree import Mbtree, Mbtree_GUI
from tkinter import Tk, filedialog
import os

def __init__(self, mb, params, names, ai_dict, seed, size):   
元と同じなので省略
    super(Marubatsu_GUI, self).__init__()   
    
+   mbtree = Mbtree.load("../data/aidata")
+   self.mbtree_gui = Mbtree_GUI(self.mbtree, size=0.1)
    
Marubatsu_GUI.__init__ = __init__

上記の修正後に下記のプログラムで gui_play() を実行すると、実行結果のようにゲーム盤の下に Mbtree_GUI クラスで作成したゲーム木の部分木が表示されるようになります。なお、上記の __init__ メソッドの 9 行目でファイルから aidata.mbtree を読み込むため、GUI の部分木が表示されるまでに筆者のパソコンでは 約 10 秒ほど時間がかかりました。

from util import gui_play

gui_play()

実行結果

実行結果の GUI の部分木から下記の事がわかります。

  • 赤枠の現在の局面が黄色で表示されていることからこの局面が引き分けの局面である
  • 全ての子ノードの局面が黄色で表示されていることから、全ての合法手が最善手である

また、例えば (0, 0) に着手を行った場合の局面の最善手が知りたい場合は、上に表示されたその局面をクリックすることで、下図のようにその局面を中心とする部分木に表示が変化します。赤枠の局面は × の手番の局面で、青色が 〇 の必勝の局面、黄色が引き分けの局面を表すことから、この局面では (1, 1) のみが最善手であることが確認できます。

このように、GUI の部分木を表示することで、現在の局面から先の展開をクリック操作で知ることができるようになりました。

局面の状況の変化に合わせた GUI の部分木の表示の連動

上記のプログラムでは、ゲーム盤の下に GUI の部分木を表示する処理しか行っていない ので、ゲーム盤に着手を行って 局面の状況が変化してもGUI の部分木の表示は変化しません。実際に確かめてみて下さい。

そこで、局面の状況の変化に合わせて GUI の部分木の中心となるノードを更新する 処理を実装することにします。Marubatsu_GUI クラスでは、局面が変化した場合 は、update_gui メソッドを呼び出して 表示の更新を行います。そのため、GUI の部分木の表示の更新処理 は、下記のプログラムのように update_gui メソッドの中に記述します。

  • 6 行目update_gui の処理の最後に、GUI の部分木の 中心となるノード3を表す selectednode 属性に 現在の局面を表すゲーム木のノードを計算して代入する。現在の局面を表すゲーム木のノードの計算の方法について忘れた方は以前の記事を復習すること
  • 7 行目:GUI の部分木の表示を更新するために、Mbtree_GUI クラスの update_gui メソッド を呼び出す
1  from marubatsu import Marubatsu
2
3  def update_gui(self):
元と同じなので省略
4      self.update_widgets_status()
5      
6      self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]
7      self.mbtree_gui.update_gui()
8    
9  Marubatsu_GUI.update_gui = update_gui 
行番号のないプログラム
from marubatsu import Marubatsu

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=20, 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=20)
    
    self.draw_board(ax, self.mb)
    
    self.update_widgets_status()
    
    self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]
    self.mbtree_gui.update_gui()
    
Marubatsu_GUI.update_gui = update_gui 
修正箇所
from marubatsu import Marubatsu

def update_gui(self):
元と同じなので省略
    self.update_widgets_status()
    
+   self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]
+   self.mbtree_gui.update_gui()
    
Marubatsu_GUI.update_gui = update_gui 

部分木の中心となるノードを探す際には、それまでに行った着手を考慮する必要があるので records 属性の値を使って探す必要があります。ゲーム盤の状況を表す board 属性にはそれまでに行った着手の情報が含まれないので、board 属性の情報を使って中心となるノードを探すことはできません。

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

gui_play()

実行結果

略
Cell In[1], line 36
     33 self.seed = seed
     34 self.size = size
---> 36 super(Marubatsu_GUI, self).__init__()   
     38 self.mbtree = Mbtree.load("../data/aidata")
     39 self.mbtree_gui = Mbtree_GUI(self.mbtree, size=0.1)

File c:\Users\ys\ai\marubatsu\116\gui.py:16, in GUI.__init__(self)
     14 self.create_event_handler()
     15 self.display_widgets() 
---> 16 self.update_gui()

Cell In[3], line 47
     43 self.draw_board(ax, self.mb)
     45 self.update_widgets_status()
---> 47 self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]
     48 self.mbtree_gui.update_gui()

AttributeError: 'Marubatsu_GUI' object has no attribute 'mbtree'

エラーの検証

エラーメッセージから、先程記述した下記のプログラムを実行した際に、Marubatsu_GUI クラスのインスタンスが代入されている self.mbtree_guimbtree という属性が存在しない ことがわかります。

self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]

また、エラーメッセージをさかのぼると下記の手順で処理が行われたことが確認できます。

  • Marubatsu_GUI クラスの __init__ メソッドの super(Marubatsu_GUI, self).__init__() を呼び出す
  • Marubatsu_GUI クラスの親クラスである GUI クラスの __init__ メソッドが呼び出され、その中で self.update_gui() が実行される
  • Marubatsu_GUI クラスの update_gui メソッドが呼び出され、エラーが発生する

下記のプログラムは、エラーメッセージの中の Marubatsu_GUI クラスの __init__ メソッドの super(Marubatsu_GUI, self).__init__() を呼び出した後の処理を記述したものです。

---> 36 super(Marubatsu_GUI, self).__init__()   
     38 self.mbtree = Mbtree.load("../data/aidata")
     39 self.mbtree_gui = Mbtree_GUI(self.mbtree, size=0.1)

上記から、super の呼び出しの 処理が行われた後 で ファイルから読み込んだデータを mbtree 属性に代入 し、Mbtree_GUI クラスのインスタンスを作成して mbtree_gui 属性に値を代入していることがわかります。このことから、エラーの原因が mbtree 属性に 値を代入する前 に、mbtree 属性の値を使って計算を行おうとした ことであることが確認できます。

行われる処理の流れを別の言葉で説明すると以下のようになります。

  • super は 〇×ゲームの GUI のウィジェットに関する処理を行い、その中で update_gui メソッドが呼び出され、先程記述した GUI の部分木の表示の更新処理が行われる
  • しかし、Mbtree_GUI のインスタンスは、上記のプログラムのようにその後で作成されるので、その時点で GUI の部分木の表示を更新することはできない

この問題を解決する方法の一つは、上記の super の処理より前Mbtree_GUI クラスのインスタンスを作成する というものですが、そのように修正すると〇×ゲームの GUI よりも 前に GUI の部分木が表示されてしまう ことになるため、 ゲーム盤より上に GUI の部分木が表示されてしまう という問題が発生します。

もう一つの方法に、update_gui での GUI の部分木の表示の更新処理 を、Mbtree_GUI クラスのインスタンスが 作成されていない場合は行わない ようにするというものがあります。そのためには Marubatsu_GUI クラスのインスタンスに mbtree_gui 属性が存在するか どうかを 判定する 必要があります。

組み込み関数 hasattr による属性の存在判定

オブジェクトに属性が存在するか どうかは、組み込み関数 hasattr を使って調べることができます。hasattr最初の実引数にオブジェクト を、次の実引数に属性の名前 を記述して呼び出すことで、そのオブジェクトにその 属性が存在する場合は True を、存在しない場合は False を返す 処理を行う関数です。

hasattr の詳細については、下記のリンク先を参照して下さい。

下記は hasattr を利用するように update_gui を修正したプログラムです。

  • 2 ~ 4 行目selfmbtree_gui 属性を持つ場合のみ、GUI の部分木の中心となるノードを変更し、表示を更新する処理を行うように修正する
1  def update_gui(self):
元と同じなので省略
2      if hasattr(self, "mbtree_gui"):
3          self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]
4          self.mbtree_gui.update_gui()
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=20, 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=20)
    
    self.draw_board(ax, self.mb)
    
    self.update_widgets_status()
    
    if hasattr(self, "mbtree_gui"):
        self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]
        self.mbtree_gui.update_gui()
    
Marubatsu_GUI.update_gui = update_gui 
修正箇所
def update_gui(self):
元と同じなので省略
-   self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]
-   self.mbtree_gui.update_gui()
+   if hasattr(self, "mbtree_gui"):
+       self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]
+       self.mbtree_gui.update_gui()
    
Marubatsu_GUI.update_gui = update_gui 

上記の修正後に下記のプログラムで gui_play() を実行し、(1, 1) のマスをクリックすると、実行結果のように GUI の部分木の選択されたノードが (1, 1) に着手を行った局面に変化するようになります。しかし、一方で ゲーム盤と GUI の部分木の表示がおかしくなる という問題が発生します。このような問題が発生する原因について少し考えてみて下さい。

gui_play()

実行結果

表示がおかしくなる原因の検証

実行結果の表示がおかしな点は以下の通りです。

  • GUI の部分木に表示する ノードとノードを結ぶ線 が、ゲーム盤の方に表示されてしまう
  • ゲーム盤の表示がつぶれてしまう

まず、ノードとノードを結ぶ線の表示から検討 することにします。その処理がどこに記述されているかを探してみた所、下記の Node クラスの draw_node メソッドのプログラムの 5、13、15、19 行目に記述されていました。

 1  def draw_node(self, ax, maxdepth=None, emphasize=False, size=0.25, lw=0.8, dx=0, dy=0):

 2      # 子ノードが存在する場合に、エッジの線と子ノードを描画する
 3      if len(self.children) > 0:
 4          if maxdepth != self.depth:   
 5              plt.plot([dx + 3.5, dx + 4], [y + 1.5, y + 1.5], c="k", lw=lw)
 6              prevy = None
 7              for childnode in self.children:
 8                  childnodey = dy + (childnode.height - 3) / 2
 9                  if maxdepth is None:
10                      Marubatsu_GUI.draw_board(ax, childnode.mb, show_result=True,
11                                              score=getattr(childnode, "score", None), dx=dx+5, dy=childnodey, lw=lw)
12                  edgey = childnodey + 1.5
13                  plt.plot([dx + 4 , dx + 4.5], [edgey, edgey], c="k", lw=lw)
14                  if prevy is not None:
15                      plt.plot([dx + 4 , dx + 4], [prevy, edgey], c="k", lw=lw)
16                  prevy = edgey
17                  dy += childnode.height
18          else:
19              plt.plot([dx + 3.5, dx + 4.5], [y + 1.5, y + 1.5], c="k", lw=lw)
20               
21      return rect

いずれの処理も plt.plot によって線を表示していますが、これがバグの原因 です。

以前の記事で説明したように matplotlib では、pyplotAxes の 2 種類の方法で描画を行うことができます。この 2 つの違いは以下の通りです。

  • plt.plot のような pyplot による描画 は、Axes が複数存在する場合 はその中の 1 つの current Axes という 特定の Axes に対して 描画が行われる
  • ax.plot のような Axes による描画 は、指定した Axes に対して 描画が行われる

Axes が 1 つしか存在しない場合はどちらを使っても同じ結果になりますが、上記の ゲーム盤と GUI の部分木 のように 複数の Axes に対して 同じプログラムで描画する場合 は、pyplot を使って描画を行うと、先程のおかしな描画のように 意図しない Axes に対して描画が行われてしまうことがある ため、Axes を使って描画を行ったほうが良い でしょう。

筆者もついうっかり plt.plot を使ってしまいましたが、これまでは複数の Axes を使って描画を行っていなかった ためバグの存在が 明るみに出ませんでした

draw_node メソッドの修正

下記のプログラムのように、draw_node メソッドで Axes を使って線を描画するようにすることで、バグを修正することができます。

  • 5、13、15、19 行目plt.plotax.plot に修正する
 1  def draw_node(self, ax, maxdepth=None, emphasize=False, size=0.25, lw=0.8, dx=0, dy=0):
元と同じなので省略
 2      # 子ノードが存在する場合に、エッジの線と子ノードを描画する
 3      if len(self.children) > 0:
 4          if maxdepth != self.depth:   
 5              ax.plot([dx + 3.5, dx + 4], [y + 1.5, y + 1.5], c="k", lw=lw)
 6              prevy = None
 7              for childnode in self.children:
 8                  childnodey = dy + (childnode.height - 3) / 2
 9                  if maxdepth is None:
10                      Marubatsu_GUI.draw_board(ax, childnode.mb, show_result=True,
11                                              score=getattr(childnode, "score", None), dx=dx+5, dy=childnodey, lw=lw)
12                  edgey = childnodey + 1.5
13                  ax.plot([dx + 4 , dx + 4.5], [edgey, edgey], c="k", lw=lw)
14                  if prevy is not None:
15                      ax.plot([dx + 4 , dx + 4], [prevy, edgey], c="k", lw=lw)
16                  prevy = edgey
17                  dy += childnode.height
18          else:
19              ax.plot([dx + 3.5, dx + 4.5], [y + 1.5, y + 1.5], c="k", lw=lw)
20               
21      return rect
行番号のないプログラム
from tree import Node, Rect
import matplotlib.pyplot as plt

def draw_node(self, ax, maxdepth=None, emphasize=False, size=0.25, lw=0.8, dx=0, dy=0):
    width = 8
    if ax is None:
        height = len(self.children) * 4
        fig, ax = plt.subplots(figsize=(width * size, height * size))
        ax.set_xlim(0, width)
        ax.set_ylim(0, height)   
        ax.invert_yaxis()
        ax.axis("off")
        for childnode in self.children:
            childnode.height = 4
        self.height = height         
        
    # 自分自身のノードを真ん中の位置になるように (dx, dy) からずらして描画する
    y = dy + (self.height - 3) / 2
    Marubatsu_GUI.draw_board(ax, self.mb, show_result=True, 
                            score=getattr(self, "score", None), emphasize=emphasize, lw=lw, dx=dx, dy=y)
    rect = Rect(dx, y, 3, 3)
    # 子ノードが存在する場合に、エッジの線と子ノードを描画する
    if len(self.children) > 0:
        if maxdepth != self.depth:   
            ax.plot([dx + 3.5, dx + 4], [y + 1.5, y + 1.5], c="k", lw=lw)
            prevy = None
            for childnode in self.children:
                childnodey = dy + (childnode.height - 3) / 2
                if maxdepth is None:
                    Marubatsu_GUI.draw_board(ax, childnode.mb, show_result=True,
                                            score=getattr(childnode, "score", None), dx=dx+5, dy=childnodey, lw=lw)
                edgey = childnodey + 1.5
                ax.plot([dx + 4 , dx + 4.5], [edgey, edgey], c="k", lw=lw)
                if prevy is not None:
                    ax.plot([dx + 4 , dx + 4], [prevy, edgey], c="k", lw=lw)
                prevy = edgey
                dy += childnode.height
        else:
            ax.plot([dx + 3.5, dx + 4.5], [y + 1.5, y + 1.5], c="k", lw=lw)
            
    return rect

Node.draw_node = draw_node
修正箇所
from tree import Node, Rect
import matplotlib.pyplot as plt

def draw_node(self, ax, maxdepth=None, emphasize=False, size=0.25, lw=0.8, dx=0, dy=0):
元と同じなので省略
    if len(self.children) > 0:
        if maxdepth != self.depth:   
-           plt.plot([dx + 3.5, dx + 4], [y + 1.5, y + 1.5], c="k", lw=lw)
+           ax.plot([dx + 3.5, dx + 4], [y + 1.5, y + 1.5], c="k", lw=lw)
            prevy = None
            for childnode in self.children:
                childnodey = dy + (childnode.height - 3) / 2
                if maxdepth is None:
                    Marubatsu_GUI.draw_board(ax, childnode.mb, show_result=True,
                                            score=getattr(childnode, "score", None), dx=dx+5, dy=childnodey, lw=lw)
                edgey = childnodey + 1.5
-               plt.plot([dx + 4 , dx + 4.5], [edgey, edgey], c="k", lw=lw)
+               ax.plot([dx + 4 , dx + 4.5], [edgey, edgey], c="k", lw=lw)
                if prevy is not None:
-                   plt.plot([dx + 4 , dx + 4], [prevy, edgey], c="k", lw=lw)
+                   ax.plot([dx + 4 , dx + 4], [prevy, edgey], c="k", lw=lw)
                prevy = edgey
                dy += childnode.height
        else:
-           plt.plot([dx + 3.5, dx + 4.5], [y + 1.5, y + 1.5], c="k", lw=lw)
+           ax.plot([dx + 3.5, dx + 4.5], [y + 1.5, y + 1.5], c="k", lw=lw)
            
    return rect

Node.draw_node = draw_node

実行結果は省略しますが、上記の修正後に下記のプログラムを実行し、ゲーム盤に着手を行うと GUI の部分木の表示が正しく変化することが確認できます。また、ゲーム盤の表示が正しく表示されるようになっていることから、ゲーム盤がつぶれて表示されるバグの原因も plt.plot によるものであったことがわかります。

gui_play()

一方で、リプレイ機能を利用して局面を移動した場合 は、GUI の部分木の表示が変化しない という問題があることが確認できます。その理由について少し考えてみて下さい。

リプレイ機能によって GUI の部分木の表示が更新されないバグの検証

リプレイ機能 の操作によってゲーム盤に表示されるのは、現在の局面に至るまでの 過去の局面 です。また、表示される局面はゲーム開始時の局面から move_count 属性に代入された 回数の着手が行われた局面 です。

一方で、update_gui メソッドでは下記のプログラムの 2 行目のように、records 属性に記録されたすべての着手が行われた局面のノード を探して、GUI の部分木の 中心となるノードに設定 しています。これが、リプレイ機能の操作を行っても、GUI の部分木の中心となるノードが変化しない原因です。

1  if hasattr(self, "mbtree_gui"):
2      self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]
3      self.mbtree_gui.update_gui()

このバグを修正するためには、下記のプログラムのように、recored 属性の先頭から move_count 番まで の要素を使って検索したノードを、GUI の部分木の中心となるノードに設定する必要があります。

  • 3 行目:検索するノードを表すキーをスライス表記を使って records 属性の先頭から move_count 番までの要素の値を使って計算する。move_count 番の要素を含むのでスライス表記の : の後には move_count + 1 を記述する必要がある点に注意する事
1  def update_gui(self):
元と同じなので省略
2      if hasattr(self, "mbtree_gui"):
3          key = tuple(self.mb.records[:self.mb.move_count + 1])
4          self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[key]
5          self.mbtree_gui.update_gui()
6    
7  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=20, 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=20)
    
    self.draw_board(ax, self.mb)
    
    self.update_widgets_status()
    
    if hasattr(self, "mbtree_gui"):
        key = tuple(self.mb.records[:self.mb.move_count + 1])
        self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[key]
        self.mbtree_gui.update_gui()
    
Marubatsu_GUI.update_gui = update_gui 
修正箇所
def update_gui(self):
元と同じなので省略
    if hasattr(self, "mbtree_gui"):
+       key = tuple(self.mb.records[:self.mb.move_count + 1])
-       self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[tuple(self.mb.records)]
+       self.mbtree_gui.selectednode = self.mbtree.nodelist_by_mb[key]
        self.mbtree_gui.update_gui()
    
Marubatsu_GUI.update_gui = update_gui 

実行結果は省略しますが、上記の修正後に下記のプログラムを実行し、リプレイ機能を利用して局面を移動した場合に、GUI の部分木の表示が正しく変化するようになったことを確認して下さい。

gui_play()

プログラムの改良

今回の記事で実装したプログラムにはいくつかの改良の余地があるので改良を行うことにします。どのような改良の余地があるかについて少し考えてみて下さい。

ファイルの読み込みに関する改良

Mbtree_GUI クラスの __init__ メソッドでは、毎回 aidata.mbtree というファイルからデータを 読み込んでいるため時間がかかってしまいます。そこで、Check_Solved クラスの mbtree 属性と同様に、一度読み込んだファイルを Mbtree_GUI クラスのクラス属性に記録することで、次からは読み込まなくても済むように修正することにします。

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

  • 2、3 行目mbtree のクラス属性が None の場合のみ、ファイルからデータを読み込んで mbtree に代入する。クラス属性に代入するので、self.mbtree ではない点に注意する事
  • 4 行目:実引数にクラス属性 Marubatsu_GUI.mbtree を記述するように修正する
1  def __init__(self, mb, params, names, ai_dict, seed, size):   
元と同じなので省略
2      if Marubatsu_GUI.mbtree is None:
3          Marubatsu_GUI.mbtree = Mbtree.load("../data/aidata")
4      self.mbtree_gui = Mbtree_GUI(Marubatsu_GUI.mbtree, size=0.1)
5
6  Marubatsu_GUI.__init__ = __init__
行番号のないプログラム
def __init__(self, mb, params, names, ai_dict, 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.seed = seed
    self.size = size
    
    super(Marubatsu_GUI, self).__init__()
    
    if Marubatsu_GUI.mbtree is None:
        Marubatsu_GUI.mbtree = Mbtree.load("../data/aidata")
    self.mbtree_gui = Mbtree_GUI(Marubatsu_GUI.mbtree, size=0.1)

Marubatsu_GUI.__init__ = __init__
修正箇所
def __init__(self, mb, params, names, ai_dict, seed, size):   
元と同じなので省略
-   self.mbtree = Mbtree.load("../data/aidata")
+   if if Marubatsu_GUI.mbtree is None:
+       Marubatsu_GUI.mbtree = Mbtree.load("../data/aidata")
-   self.mbtree_gui = Mbtree_GUI(self.mbtree, size=0.1)
+   self.mbtree_gui = Mbtree_GUI(Marubatsu_GUI.mbtree, size=0.1)

Marubatsu_GUI.__init__ = __init__

次に、クラス属性 mbtree を初期化する処理 を、下記のプログラムのように クラスの定義の直後に記述 する必要がありますが、その際に他のメソッドも含めてすべて定義し直す必要があるので、以前の記事と同様にその下のプログラムを実行することで初期化処理を行うことにします。なお、下記のプログラムは marubatsu.py の方に反映させます。

class Marubatsu_GUI(GUI):
    mbtree = None
元と同じなので略
Marubatsu_GUI.mbtree = None

実行結果は省略しますが、上記の修正後に gui_play() を実行すると、これまでと同様にファイルからデータを読み込むので処理に時間がかかります。

gui_play()

その後で下記のプログラムでもう一度 gui_play() を実行すると、今度は Marubatsu_GUI クラスの mbtree 属性にファイルから読み込んだデータが代入されているのですぐに処理が終了するようになります。実際に確認して下さい。

gui_play()

別の方法として、下記のプログラムのように、 __init__ メソッドの中で、先ほど紹介した hasattr を使ってクラス属性 mbtree が存在しない場合にファイルからデータを読み込むようにするという方法がありますが、個人的には mbtree の場合はクラスの定義の先頭に mbtree = None を記述したほうが良いと思いますので本記事ではこの方法は採用しません。

1  def __init__(self, mb, params, names, ai_dict, seed, size):   
元と同じなので省略
2      if not hasattr(self, "mbtree"):
3          Marubatsu_GUI.mbtree = Mbtree.load("../data/aidata")
4      self.mbtree_gui = Mbtree_GUI(Marubatsu_GUI.mbtree, size=0.1)
5
6  Marubatsu_GUI.__init__ = __init__
修正箇所
def __init__(self, mb, params, names, ai_dict, seed, size):   
元と同じなので省略
    if not hasattr(self, "mbtree"):
        Marubatsu_GUI.mbtree = Mbtree.load("../data/aidata")
    self.mbtree_gui = Mbtree_GUI(Marubatsu_GUI.mbtree, size=0.1)

Marubatsu_GUI.__init__ = __init__

この方法を採用しない理由は、クラス属性の初期化処理クラスの定義の先頭に記述 した場合は、その部分を見ることでそのクラスに どのようなクラス属性が存在するかが一目でわかる のに対し、hasattr を使ってクラス属性の初期化処理を行うと、そのクラス属性の存在がわかりづらくなる からです。

mbtree のように、あらかじめ必要になることがわかっている クラス属性の初期化処理は、クラスの定属の先頭に記述 し、hasattr によるクラス属性の初期化処理はそうでない場合に利用すると良いでしょう。

GUI の部分木の表示の切り替えと表示のリセット

GUI の部分木の表示は便利ですが、最善手が常に表示される のは ゲームを純粋に楽しむ上では邪魔 です。そこで、GUI の部分木の表示の有無をボタンで切り替える ことができるように改良することにします。以後はその操作を「表示の切り替え」と表記します。

また、GUI の部分木のノードをクリックして 他の局面を中心として表示する機能 は、他の局面の状況を分析する際には便利 ですが、現状ではその後で 現在の局面を中心とする表示に戻す ことが 簡単にできない点が不便 です。そこで、現在の局面を中心とする表示に戻すためのボタンを用意する ことにします。以後はこの操作を「表示のリセット」と表記します。

ipywidgets のウィジェットの表示を切り替える方法

以前の記事で説明したように、ipywidgets のウィジェット は、layout 属性によって 表示のレイアウトの設定を行うことができます4。以前の記事では紹介しませんでしたが、layout 属性には ウィジェットの表示(display)の仕方を設定 する display 属性があり、display 属性に "none" という文字列 を設定することで、そのウィジェットの 表示を行わないようにする ことができます。また、元に戻すには None を代入 します5

本記事では詳しくは説明しませんが、display 属性に代入できる値は、ウェブページの表示のレイアウトを設定する際に使われる CSS(cascading style sheet)という言語の display プロパティと同じです。また、ipywidgets では display 属性に None を代入すると、diplay 属性に対するデフォルト値が設定されたことになるようです。

ipywidgets の display 属性の詳細については下記のリンク先を参照して下さい。

具体例を示します。

下記は test が表示されるボタンのウィジェットを作成して表示するプログラムです。

import ipywidgets as widgets

button = widgets.Button(description="test")
display(button)

実行結果

上記の実行後に、下記のプログラムを実行するとボタンの表示が消えます。

button.layout.display = "none"

その後に下記のプログラムを実行するとボタンが再び表示されるようになります。

button.layout.display = None

Mbtree_GUI クラスの display_widgets メソッドの修正

GUI の部分木の表示を切り替えるためには、Mbtree_GUI クラスが表示する ウィジェットの display 属性に None または "none" を代入する 必要があります。Mbtree_GUI クラスのウィジェットは、下記の display_widgets メソッド内の 6 行目で作成した VBox ウィジェットの中に全て配置 されて表示されます。そのため、この VBox ウィジェットの display 属性の値を変更する ことで、GUI の部分木の表示をまとめて切り替える ことができます。

1  def display_widgets(self):
2      hbox1 = widgets.HBox([self.label, self.up_button, self.label])
3      hbox2 = widgets.HBox([self.left_button, self.label, self.right_button,
4                            self.label, self.help_button])
5      hbox3 = widgets.HBox([self.label, self.down_button, self.label])
6      display(widgets.VBox([self.output, hbox1, hbox2, hbox3, self.fig.canvas]))  

VBox ウィジェットの display 属性を後から編集できるようにする ためには、下記のプログラムのように Mbtree_GUI クラスのインスタンスの vbox 属性に作成した VBox ウィジェットを代入する 必要があります。

  • 6 行目:作成した VBox を vbox 属性に代入するように修正する
  • 7 行目self.vbox を表示するように修正する
1  def display_widgets(self):
2      hbox1 = widgets.HBox([self.label, self.up_button, self.label])
3      hbox2 = widgets.HBox([self.left_button, self.label, self.right_button,
4                            self.label, self.help_button])
5      hbox3 = widgets.HBox([self.label, self.down_button, self.label])
6      self.vbox = widgets.VBox([self.output, hbox1, hbox2, hbox3, self.fig.canvas])
7      display(self.vbox)  
8   
9  Mbtree_GUI.display_widgets = display_widgets 
行番号のないプログラム
def display_widgets(self):
    hbox1 = widgets.HBox([self.label, self.up_button, self.label])
    hbox2 = widgets.HBox([self.left_button, self.label, self.right_button,
                          self.label, self.help_button])
    hbox3 = widgets.HBox([self.label, self.down_button, self.label])
    self.vbox = widgets.VBox([self.output, hbox1, hbox2, hbox3, self.fig.canvas])
    display(self.vbox)  
    
Mbtree_GUI.display_widgets = display_widgets 
修正箇所
def display_widgets(self):
    hbox1 = widgets.HBox([self.label, self.up_button, self.label])
    hbox2 = widgets.HBox([self.left_button, self.label, self.right_button,
                          self.label, self.help_button])
    hbox3 = widgets.HBox([self.label, self.down_button, self.label])
+   self.vbox = widgets.VBox([self.output, hbox1, hbox2, hbox3, self.fig.canvas])
-   display(widgets.VBox([self.output, hbox1, hbox2, hbox3, self.fig.canvas]))  
+   display(self.vbox)  
    
Mbtree_GUI.display_widgets = display_widgets 

ボタンの作成と表示

次に、表示の切り替えと、表示のリセットを行う ボタンの表示と配置を決める 必要があります。本記事では開く、保存ボタンなどの幅を少し狭くし、ヘルプボタンの左に表示の切り替えボタンと表示のリセットボタンを配置 することにします。また、それぞれのボタンには「木」と「リ」を表示することにします。異なる場所にボタンを配置したり、異なる文字をボタンに表示したい方は自由に変更して下さい。

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

  • 5 行目:IntText の幅を 80px に減らす
  • 9、10 行目:開くと保存ボタンの幅を 50 に減らす
  • 11、12 行目:ゲーム木の表示の切り替えとリセットのボタンを作成する
 1  def create_widgets(self):
 2      # 乱数の種の Checkbox と IntText を作成する
 3      self.checkbox = widgets.Checkbox(value=self.seed is not None, description="乱数の種",
 4                                      indent=False, layout=widgets.Layout(width="100px"))
 5      self.inttext = widgets.IntText(value=0 if self.seed is None else self.seed,
 6                                     layout=widgets.Layout(width="80px"))   
 7
 8      # 読み書き、ヘルプのボタンを作成する
 9      self.load_button = self.create_button("開く", 50)
10      self.save_button = self.create_button("保存", 50)
11      self.show_tree_button = self.create_button("", 34)
12      self.reset_tree_button = self.create_button("", 34)
13      self.help_button = self.create_button("", 34)
元と同じなので省略
14    
15 Marubatsu_GUI.create_widgets = create_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)
    
    # 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()    
    
Marubatsu_GUI.create_widgets = create_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="100px"))   
+                                  layout=widgets.Layout(width="80px"))   

    # 読み書き、ヘルプのボタンを作成する
-   self.load_button = self.create_button("開く", 78)
+   self.load_button = self.create_button("開く", 50)
-   self.save_button = self.create_button("保存", 78)
+   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)
元と同じなので省略
    
Marubatsu_GUI.create_widgets = create_widgets

次に、ウィジェットを配置する display_widgets を下記のプログラムのように修正します。

  • 4 行目:ヘルプボタンの左に表示の切り替えと表示のリセットボタンを配置する
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
6  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])
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
    hbox2 = 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]) 
    # hbox1 ~ hbox3、Figure、Output を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2, hbox3, self.fig.canvas, self.output]))  

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.help_button])
+   hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button, self.save_button,
+                         self.show_tree_button, self.reset_tree_button, self.help_button])
元と同じなので省略

Marubatsu_GUI.display_widgets = display_widgets 

上記の修正後に下記のプログラムを実行すると、実行結果のように表示の切り替えと表示のリセットボタンが表示されます。ただし、イベントハンドラの定義をまだ行っていないので、これらのボタンをクリックしても何もおきません。

gui_play()

実行結果

create_event_handler の修正

次に、それぞれのボタンをクリックした際のイベントハンドラを下記のプログラムのように定義します。

  • 4、5 行目:表示の切り替えボタンをクリックした際のイベントハンドラを定義する。行う処理は、Mbtree_GUI クラスの全てのウィジェットが配置されたvbox 属性の layout.display 属性が None の場合は "none" を、そうでなければ None を代入することで、表示の切り替えを行う
  • 7、8 行目:表示のリセットのボタンをクリックした際のイベントハンドラを定義する。行う処理は、ゲーム木が選択するノードを表示されているの局面に戻すという処理で、その処理は update_gui 内で行われるので、update_gui を呼び出している
  • 12、13 行目:それぞれのボタンとイベントハンドラを結び付ける
 1  import math
 2
 3  def create_event_handler(self):
元と同じなので省略
 4      def on_show_tree_button_clicked(b=None):
 5          self.mbtree_gui.vbox.layout.display = "none" if self.mbtree_gui.vbox.layout.display is None else None
 6       
 7      def on_reset_tree_button_clicked(b=None):
 8         self.update_gui()
 9
元と同じなので省略
10      self.load_button.on_click(on_load_button_clicked)
11      self.save_button.on_click(on_save_button_clicked)
12      self.show_tree_button.on_click(on_show_tree_button_clicked)
13      self.reset_tree_button.on_click(on_reset_tree_button_clicked)
14      self.help_button.on_click(on_help_button_clicked)
元と同じなので省略  
15    
16  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.mbtree_gui.vbox.layout.display = "none" if self.mbtree_gui.vbox.layout.display is None else None
        
    def on_reset_tree_button_clicked(b=None):
        self.update_gui()
                
    def on_help_button_clicked(b=None):
        self.output.clear_output()
        with self.output:
            print("""操作説明

マスの上でクリックすることで着手を行う。
下記の GUI で操作を行うことができる。
()が記載されているものは、キー入力で同じ操作を行うことができることを意味する。
なお、キー入力の操作は、ゲーム盤をクリックして選択状態にする必要がある。

乱数の種\tチェックボックスを ON にすると、右のテキストボックスの乱数の種が適用される
開く(-,L)\tファイルから対戦データを読み込む
保存(+,S)\tファイルに対戦データを保存する
?(*,H)\t\tこの操作説明を表示する
手番の担当\tメニューからそれぞれの手番の担当を選択する
\t\tメニューから選択しただけでは担当は変更されず、変更またはリセットボタンによって担当が変更される
変更\t\tゲームの途中で手番の担当を変更する
リセット\t手番の担当を変更してゲームをリセットする
待った(0)\t1つ前の自分の着手をキャンセルする
<<(↑)\t\t最初の局面に移動する
<(←)\t\t1手前の局面に移動する
>(→)\t\t1手後の局面に移動する
>>(↓)\t\t最後の着手が行われた局面に移動する
スライダー\t現在の手数を表す。ドラッグすることで任意の手数へ移動する

手数を移動した場合に、最後の着手が行われた局面でなければ、リプレイモードになる。
リプレイモード中に着手を行うと、リプレイモードが解除され、その着手が最後の着手になる。""")
            
    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_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
        
+   def on_reset_tree_button_clicked(b=None):
+       self.update_gui()

元と同じなので省略
    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)
元と同じなので省略  
    
Marubatsu_GUI.create_event_handler = create_event_handler

実行結果は省略しますが、下記のプログラムを実行し、新たに作成した 2 つのボタンをクリックして正しい動作が行われることを確認して下さい。

gui_play()

長くなったので今回の記事はここまでとし、残りの改良は次回の記事で行うことにします。

今回の記事のまとめ

今回の記事では、ゲーム盤の下にゲーム木の部分木を表示し、ゲーム盤の表示と部分木の中心となるノードを連動する処理を実装しました。また、いくつかの改良を行いました。

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

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

次回の記事

  1. 著作権を考慮して将棋盤や対局者の情報は削除しました

  2. 将棋の場合は持ち駒が 1 種類につき(いくつかの例外を除いて)空いているマスの数だけ合法手が存在します。囲碁の場合はゲーム開始時の局面ではすべてのマスが合法手なので 19 × 19 = 381 もの合法手が存在します

  3. Mbtree_GUI クラスで表示される部分木では、中心となるノード(centernode)と選択されたノード(selectednode)は常に同じノードになります

  4. 以前の記事では Dropdown の属性として layout 属性を紹介しましたが、layout 属性は ipywidgets の全てのウィジェットに共通する属性です

  5. 紛らわしいですが、こちらは文字列ではなく None 型のデータである点に注意 して下さい

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