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

# 目次と前回の記事

https://qiita.com/ysgeso/items/2381dd4e3283cbed49a0

https://qiita.com/ysgeso/items/53147dbb56f83be32edc

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

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

| リンク | 説明 |
|:--|:--|
| [marubatsu.py](https://github.com/ysgeso/marubatsu/blob/master/116/marubatsu.py)  | Marubatsu、Marubatsu_GUI クラスの定義|
| [ai.py](https://github.com/ysgeso/marubatsu/blob/master/116/ai.py)  |  AI に関する関数 |
| [test.py](https://github.com/ysgeso/marubatsu/blob/master/116/test.py)  |  テストに関する関数 |
|  [util.py](https://github.com/ysgeso/marubatsu/blob/master/116/util.py) |  ユーティリティ関数の定義。現在は `gui_play` のみ定義されている |
| [tree.py](https://github.com/ysgeso/marubatsu/blob/master/116/tree.py)  | ゲーム木に関する Node、Mbtree クラスの定義  |
| [gui.py](https://github.com/ysgeso/marubatsu/blob/master/116/gui.py)  |  GUI に関する処理を行う基底クラスとなる GUI クラスの定義  |

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

https://qiita.com/ysgeso/items/10d1d01192c014173e4b

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

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/f247af44-ed42-4a66-2c49-1c26fd19e810.png" width="300"><br><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/39119a07-375e-63d3-11da-22d48f5b6780.png" width="300">

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

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

## 具体的な表示方法の検討

[以前の記事](https://qiita.com/ysgeso/items/d2c309058e2c60d2e476#%E3%83%87%E3%83%BC%E3%82%BF%E3%81%AE%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%B5%E3%82%A4%E3%82%BA%E3%81%AE%E6%AF%94%E8%BC%83)で __aidata.mbtree__ というファイルに保存した〇×ゲームのゲーム木のデータには、__全ての局面の評価値__ と __最善手が計算済__ です。従って、__現在の局面の評価値__ はその __ゲーム木のデータを使って表示__ することができます。

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/b16a6b93-c940-6679-bccf-bcde82f0b868.png" width="100">

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

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

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

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

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

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

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


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

## 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 クラスのインスタンスを作成する際に必要となるゲーム木のデータをファイルから読み込み、インスタンスを作成する。読み込んだゲーム木のデータと、作成したインスタンスは後で利用するので、`mbtree` と `mbtree_gui` という属性に代入する。7 行目の `super` の処理について忘れた方は、[以前の記事](https://qiita.com/ysgeso/items/2acd8c5ceb8e9cda7233#gui-%E3%82%AF%E3%83%A9%E3%82%B9%E3%81%AE%E5%AE%9A%E7%BE%A9)を復習すること

```python
 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__
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
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__
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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__
```
</details>

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

```python
from util import gui_play

gui_play()
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/d082cee1-4bbe-0662-31e3-67fb97a23325.png" width="200">

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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/a70bdcbd-51f9-4557-9cdf-09ba18fe968a.png" width="100">

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

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

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

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

* __6 行目__：`update_gui` の処理の最後に、GUI の部分木の __中心となるノード[^3]を表す `selectednode`__ 属性に __現在の局面を表すゲーム木のノードを計算して代入する__。現在の局面を表すゲーム木のノードの計算の方法について忘れた方は[以前の記事](https://qiita.com/ysgeso/items/d2c309058e2c60d2e476#ai_gt3-%E3%81%AE%E5%AE%9A%E7%BE%A9%E3%81%A8-ai_match-%E3%81%AB%E3%82%88%E3%82%8B%E5%AF%BE%E6%88%A6)を復習すること
* __7 行目__：GUI の部分木の表示を更新するために、__Mbtree_GUI クラスの `update_gui` メソッド__ を呼び出す

```python
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 
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
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 
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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 
```
</details>

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

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

```python
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_gui` に `mbtree` という属性が存在しない__ ことがわかります。

```python
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` の詳細については、下記のリンク先を参照して下さい。

https://docs.python.org/ja/3/library/functions.html#hasattr

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

* __2 ~ 4 行目__：`self` が `mbtree_gui` 属性を持つ場合のみ、GUI の部分木の中心となるノードを変更し、表示を更新する処理を行うように修正する

```python
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 
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
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 
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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 
```
</details>

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

```python
gui_play()
```

実行結果


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/587024b0-b0f3-6d93-f7f3-bcc6968016a4.png" width="200">

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

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

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

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

```python
 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`__ によって線を表示していますが、__これがバグの原因__ です。

[以前の記事](https://qiita.com/ysgeso/items/ab2770693c947c108dd0#pyplot-%E3%81%AE%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89%E3%81%A8-axes-%E3%81%AE%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89)で説明したように matplotlib では、__pyplot__ と __Axes__ の 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.plot` を `ax.plot` に修正する

```python
 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
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
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
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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
```
</details>

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

```python
gui_play()
```

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

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

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

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

```python
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 行目__：検索するノードを表すキーを[スライス表記](https://qiita.com/ysgeso/items/6ca248133ff4d59a81d4#%E3%82%B9%E3%83%A9%E3%82%A4%E3%82%B9%E8%A1%A8%E8%A8%98-%E3%81%9D%E3%81%AE-1start-%E3%81%A8-end)を使って `records` 属性の先頭から `move_count` 番までの要素の値を使って計算する。`move_count` 番の要素を含むのでスライス表記の `:` の後には `move_count + 1` を記述する必要がある点に注意する事

```python
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 
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
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 
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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 
```
</details>

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

```python
gui_play()
```

# プログラムの改良

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

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

Mbtree_GUI クラスの `__init__` メソッドでは、__毎回 `aidata.mbtree`__ というファイルからデータを __読み込んでいるため時間がかかってしまいます__。そこで、[Check_Solved クラスの `mbtree` 属性](https://qiita.com/ysgeso/items/847598556d7e49fe2d87#is_weakly_solved-%E3%81%AE%E6%94%B9%E8%89%AF)と同様に、一度読み込んだファイルを Mbtree_GUI クラスのクラス属性に記録することで、次からは読み込まなくても済むように修正することにします。

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

* __2、3 行目__：`mbtree` のクラス属性が `None` の場合のみ、ファイルからデータを読み込んで `mbtree` に代入する。クラス属性に代入するので、`self.mbtree` ではない点に注意する事
* __4 行目__：実引数にクラス属性 `Marubatsu_GUI.mbtree` を記述するように修正する

```python
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__
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
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__
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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__
```
</details>

次に、__クラス属性 `mbtree` を初期化する処理__ を、下記のプログラムのように __クラスの定義の直後に記述__ する必要がありますが、その際に他のメソッドも含めてすべて定義し直す必要があるので、[以前の記事](https://qiita.com/ysgeso/items/847598556d7e49fe2d87#is_weakly_solved-%E3%81%AE%E6%94%B9%E8%89%AF)と同様にその下のプログラムを実行することで初期化処理を行うことにします。なお、下記のプログラムは marubatsu.py の方に反映させます。

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

```python
Marubatsu_GUI.mbtree = None
```

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

```python
gui_play()
```

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

```python
gui_play()
```

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


```python
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__
```

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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__
```
</details>

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

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

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

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

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

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

[以前の記事](https://qiita.com/ysgeso/items/51842efc6193ed456ebc#layout)で説明したように、__ipywidgets のウィジェット__ は、__`layout`__ 属性によって __表示のレイアウトの設定を行うことができます__[^4]。以前の記事では紹介しませんでしたが、`layout` 属性には __ウィジェットの表示（display）の仕方を設定__ する __`display`__ 属性があり、display 属性に __`"none"` という文字列__ を設定することで、そのウィジェットの __表示を行わないようにする__ ことができます。また、__元に戻すには `None` を代入__ します[^5]。

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

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

https://ipywidgets.readthedocs.io/en/8.1.5/examples/Widget%20Layout.html#id1

具体例を示します。

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

```python
import ipywidgets as widgets

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

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/800985ef-79d3-7a1e-7ba2-5ab0482f3b13.png" width="100">

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

```python
button.layout.display = "none"
```

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

```python
button.layout.display = None
```

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

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

```python
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` を表示するように修正する

```python
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 
```    

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
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 
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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 
```
</details>


### ボタンの作成と表示

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

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

* __5 行目__：IntText の幅を 80px に減らす
* __9、10 行目__：開くと保存ボタンの幅を 50 に減らす
* __11、12 行目__：ゲーム木の表示の切り替えとリセットのボタンを作成する

```python
 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
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
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
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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
```
</details>

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

* __4 行目__：ヘルプボタンの左に表示の切り替えと表示のリセットボタンを配置する

```python
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 
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
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 
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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 
```
</details>

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

```python
gui_play()
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/7a9697e9-bbfa-79ae-4598-ea16cf0b7ef5.png" width="200">

### `create_event_handler` の修正

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

* __4、5 行目__：表示の切り替えボタンをクリックした際のイベントハンドラを定義する。行う処理は、Mbtree_GUI クラスの全てのウィジェットが配置された`vbox` 属性の `layout.display` 属性が None の場合は `"none"` を、そうでなければ `None` を代入することで、表示の切り替えを行う
* __7、8 行目__：表示のリセットのボタンをクリックした際のイベントハンドラを定義する。行う処理は、ゲーム木が選択するノードを表示されているの局面に戻すという処理で、その処理は `update_gui` 内で行われるので、`update_gui` を呼び出している
* __12、13 行目__：それぞれのボタンとイベントハンドラを結び付ける
 
```python
 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
```

<details><summary><strong>行番号のないプログラム</strong></summary>

```python
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
```
</details>

<details><summary><strong>修正箇所</strong></summary>

```diff_python
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
```
</details>

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

```python
gui_play()
```

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

# 今回の記事のまとめ

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

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

| リンク | 説明 |
|:--|:--|
| [marubatsu.ipynb](https://github.com/ysgeso/marubatsu/blob/master/116/marubatsu.ipynb)  | 本記事で入力して実行した JupyterLab のファイル |
| [marubatsu_new.py](https://github.com/ysgeso/marubatsu/blob/master/116/marubatsu_new.py)  | 今回の記事で更新した marubatsu.py |
| [tree_new.py](https://github.com/ysgeso/marubatsu/blob/master/116/tree_new.py)  | 今回の記事で更新した tree.py |

# 次回の記事

https://qiita.com/ysgeso/items/6d1430a88e251279d77a

[^1]:著作権を考慮して将棋盤や対局者の情報は削除しました
[^2]:将棋の場合は持ち駒が 1 種類につき（いくつかの例外を除いて）空いているマスの数だけ合法手が存在します。囲碁の場合はゲーム開始時の局面ではすべてのマスが合法手なので 19 × 19 = 381 もの合法手が存在します
[^3]:Mbtree_GUI クラスで表示される部分木では、中心となるノード（centernode）と選択されたノード（selectednode）は常に同じノードになります
[^4]:以前の記事では Dropdown の属性として `layout` 属性を紹介しましたが、`layout` 属性は ipywidgets の全てのウィジェットに共通する属性です
[^5]:紛らわしいですが、こちらは文字列ではなく __None 型のデータである点に注意__ して下さい