Pythonで〇×ゲームのAIを一から作成する　その115　ファイルの保存と読み込みに関するバグの修正と改良

# 目次と前回の記事

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

https://qiita.com/ysgeso/items/0894a987b51323a4a9b4

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

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

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

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

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

# 対戦カードに表示される AI の名前に関する改良

前回の記事の最後で、Marubatsu_GUI クラスにはゲームの保存と読み込みに関するバグが残っていると説明しましたが、先に別の改良を行うことにします。

それは、__対戦カードに表示される AI の名前__ です。現状ではゲーム盤の下に表示される対戦カードの AI の名前は、下記の `update_widgets` のプログラムのように、__Dropdown の項目名が表示される__ ようになっています。

```python
def update_gui(self):
略
    # 対戦カードの文字列を計算する
    ax.text(1.5, 3.5, f"{self.dropdown_list[0].label}　VS　{self.dropdown_list[1].label}", fontsize=20, ha="center")
略
```

また、`play` メソッドから `play(ai=[ai1s, ai2s], gui=True)` のように AI を指定して GUI で対戦を行った場合は、下記の `create_dropdown` の 5 ~ 8、13 ~ 15 行目の処理で Dropdown の項目名が以下のように設定されます。

* 人間が担当する場合は `"人間"` という文字列が設定される
* AI が担当する場合は AI の関数名が設定される

```python
 1  def create_dropdown(self):
略
 2      # ai に代入されている内容を ai_dict に追加する
 3      for i in range(2):
 4          # ラベルと項目の値を計算する
 5          if self.mb.ai[i] is None:
 6              label = "人間"
 7          else:
 8              label = self.mb.ai[i].__name__        
 9          value = ( self.mb.ai[i], self.params[i] )
10          # value を select_values に常に登録する
11          select_values.append(value)
12          # value が ai_values に登録済かどうかを判定する
13          if value not in self.ai_dict.values():
14              # 項目を登録する
15              self.ai_dict[label] = value
16
17          for i in range(2):
18              # Dropdown の description を計算する
19              description = "〇" if i == 0 else "×"
20              self.dropdown_list.append(
21                  widgets.Dropdown(
22                      options=self.ai_dict,
23                      description=description,
24                      layout=widgets.Layout(width="100px"),
25                      style={"description_width": "20px"},
26                      value=select_values[i],
27                  )
28              ) 
```

このように、AI が担当した場合の Dropdown の項目名は AI の関数名が設定されますが、これでは __同じ AI の関数に対して異なるパラメータを設定した場合__ の Dropdown の項目名が同じになってしまい、__区別が付けられない__ という問題が発生します。

そこで、対戦カードに表示する __AI の名前を自由に設定できる__ ように改良することにします。どのようにすれば良いかについて少し考えてみて下さい。

なお、`gui_play` で対戦を行う場合は、`ai_dict` のキーによって Dropdown の項目名が設定されるので、対戦カードに表示される AI の名前を変えるのは簡単です。

## 仮引数 `names` の追加

本記事では、対戦カードに表示する __それぞれの AI の名前を要素とする list__ を `play` メソッドの __仮引数 `names` に代入__ して設定できるようにすることにします。また、`names` の要素の値が __`None` の場合__ は、対応する AI の名前は __これまでと同じ方法で設定する__ ようにします。

他の方法としては、例えば仮引数 `params` の `names` というキーの値で設定するという方法が考えられます。この方法には、`play` メソッドなどに新しい仮引数 `names` を追加する必要がないという利点があります。一方で、__`params` は AI の関数が処理を行う際に必要となるパラメーター__ という意味があるので、その中に __AI の関数が処理を行う際に利用しない関数の名前のデータを入れるのは変__ という問題点があるので、本記事ではこの方法は採用しません。

### `play` メソッドの修正

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

* __3 行目__：デフォルト値を `None` とする仮引数 `names` を追加する
* __6 行目__：Marubatsu_GUI クラスのインスタンスを作成する際に、キーワード引数 `names=names` を追加する

なお、仮引数 `names` の値は __Marubatsu_GUI クラスで対戦カードを表示する以外の場面では利用しない__ ので、Marubatsu クラスの属性に代入して記録する必要はありません

```python
1  from marubatsu import Marubatsu, Marubatsu_GUI
2
3  def play(self, ai, ai_dict=None, params=None, names=None, verbose=True, seed=None, gui=False, size=3):
元と同じなので省略
4      # gui が True の場合に、GUI の処理を行う Marubatsu_GUI のインスタンスを作成する
5      if gui:
6          mb_gui = Marubatsu_GUI(self, params=params, names=names, ai_dict=ai_dict, seed=seed, size=size)  
元と同じなので省略
7
8  Marubatsu.play = play
```

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

```python
from marubatsu import Marubatsu, Marubatsu_GUI

def play(self, ai, ai_dict=None, params=None, names=None, verbose=True, seed=None, gui=False, size=3):
    # params が None の場合のデフォルト値を設定する
    if params is None:
        params = [{}, {}]
        
    # 一部の仮引数をインスタンスの属性に代入する
    self.ai = ai
    self.verbose = verbose
    self.gui = gui
    
    # seed が None でない場合は、seed を乱数の種として設定する
    if seed is not None:
        random.seed(seed)

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

Marubatsu.play = play
```
</details>

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

```diff_python
from marubatsu import Marubatsu, Marubatsu_GUI

-def play(self, ai, ai_dict=None, params=None, verbose=True, seed=None, gui=False, size=3):
+def play(self, ai, ai_dict=None, params=None, names=None, verbose=True, seed=None, gui=False, size=3):
元と同じなので省略
    # gui が True の場合に、GUI の処理を行う Marubatsu_GUI のインスタンスを作成する
    if gui:
-       mb_gui = Marubatsu_GUI(self, params=params, ai_dict=ai_dict, seed=seed, size=size)  
+       mb_gui = Marubatsu_GUI(self, params=params, names=names, ai_dict=ai_dict, seed=seed, size=size)  
元と同じなので省略

Marubatsu.play = play
```
</details>

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

次に Marubatsu_GUI クラスの `__init__` メソッドを下記のプログラムのように修正します。なお、Marubatsu_GUI クラスのインスタンスは Marubatsu クラスの `play` メソッド内で作成されることが前提として定義されており、5 行目の下にあった `params` が `None` の場合に行う処理は、`play` メソッドの中で既に行っていたことに気が付きましたので削除しました。同様に、仮引数 `seed`、`size` のデフォルト値は `play` メソッドで設定済なので、それらの仮引数を通常の仮引数に修正しました。


* __5 行目__：仮引数 `names` を追加し、`seed` と `size` を通常の仮引数に修正する
* 5 行目の下にあった `params` が `None` の場合に行う処理を削除する
* __8、9 行目__：`names` が `None` の場合は、両方の名前を自動的に設定することを表す `[None, None]` を代入する
* __10 ~ 15 行目__：それぞれの AI の名前を表す `names` の要素が `None` の場合に、名前を `create_dropdown` の 5 ~ 8 行目で行っていたのと同じ方法で設定する処理を行う
* __16 行目__：仮引数 `names` を同名の属性に代入する

```python
 1  import ipywidgets as widgets
 2  from tkinter import Tk, filedialog
 3  import os
 4
 5  def __init__(self, mb, params, names, ai_dict, seed, size):
 6      if ai_dict is None:
 7          ai_dict = {}
 8      if names is None:
 9          names = [None, None]
10      for i in range(2):
11          if names[i] is None:
12              if mb.ai[i] is None:
13                  names[i] = "人間"
14              else:
15                  names[i] = mb.ai[i].__name__
元と同じなので省略
16      self.names = names
元と同じなので省略
17    
18  Marubatsu_GUI.__init__ = __init__
```

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

```python
import ipywidgets as widgets
from tkinter import Tk, filedialog
import os

def __init__(self, mb, params, names, ai_dict, seed=None, size=3):
    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__()
    
Marubatsu_GUI.__init__ = __init__
```
</details>

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

```diff_python
import ipywidgets as widgets
from tkinter import Tk, filedialog
import os

-def __init__(self, mb, params, ai_dict, seed=None, size=3):
+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__
元と同じなので省略
+   self.names = names
元と同じなので省略
    
Marubatsu_GUI.__init__ = __init__
```
</details>

### `create_dropdown` の修正

最後に `create_dropdown` を下記のプログラムのように修正します。

* 2 行目の下にあった、AI の名前を計算する処理は `__init__` メソッドで行うことにしたので削除する
* __9 行目__：対戦カードの名前は `self.names[i]` に代入されているので、キーの名前をそのように修正する

```python
 1  def create_dropdown(self):
元と同じなので省略
 2     for i in range(2):
 3          value = ( self.mb.ai[i], self.params[i] )
 4          # value を select_values に常に登録する
 5          select_values.append(value)
 6          # value が ai_values に登録済かどうかを判定する
 7          if value not in self.ai_dict.values():
 8              # 項目を登録する
 9              self.ai_dict[self.names[i]] = value
元と同じなので省略
10
11  Marubatsu_GUI.create_dropdown = create_dropdown
```

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

```python
def create_dropdown(self):
    # それぞれの手番の担当を表す Dropdown の項目の値を記録する list を初期化する
    select_values = []
    # 〇 と × の Dropdown を格納する list
    self.dropdown_list = []
    # ai に代入されている内容を ai_dict に追加する
    for i in range(2):
        value = ( self.mb.ai[i], self.params[i] )
        # value を select_values に常に登録する
        select_values.append(value)
        # value が ai_values に登録済かどうかを判定する
        if value not in self.ai_dict.values():
            # 項目を登録する
            self.ai_dict[self.names[i]] = value

    for i in range(2):
        # Dropdown の description を計算する
        description = "〇" if i == 0 else "×"
        self.dropdown_list.append(
            widgets.Dropdown(
                options=self.ai_dict,
                description=description,
                layout=widgets.Layout(width="100px"),
                style={"description_width": "20px"},
                value=select_values[i],
            )
        ) 
        
Marubatsu_GUI.create_dropdown = create_dropdown
```
</details>

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

```diff_python
def create_dropdown(self):
元と同じなので省略
    for i in range(2):
        # ラベルと項目の値を計算する
-       if self.mb.ai[i] is None:
-           label = "人間"
-       else:
-           label = self.mb.ai[i].__name__ 
        value = ( self.mb.ai[i], self.params[i] )
        # value を select_values に常に登録する
        select_values.append(value)
        # value が ai_values に登録済かどうかを判定する
        if value not in self.ai_dict.values():
            # 項目を登録する
-           self.ai_dict[label] = value
+           self.ai_dict[self.names[i]] = value
元と同じなので省略
        
Marubatsu_GUI.create_dropdown = create_dropdown
```
</details>

上記の修正後に下記のプログラムでキーワード引数 `names` を記述せずに `play` メソッドを実行すると、実行結果のようにこれまでと同じ対戦カードが表示されることが確認できます。

```python
from ai import ai2s

mb = Marubatsu()
mb.play(ai=[None, ai2s], gui=True)
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/08e6e767-3845-5844-f0d3-b17eb5717cde.png" width="200"> 

次に下記のプログラムで __キーワード引数 `names` を記述__ して `play` メソッドを実行すると、実行結果のように __要素に `"You"` を代入したほうは You__ が、__`None` を代入したほうはこれまでと同様に AI の関数の名前__ が表示されることが確認できます。

```python
mb.play(ai=[None, ai2s], names=["You", None], gui=True)
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/a71f8577-530b-04a9-0978-c550d6c85b52.png" width="200"> 

# ファイルの保存と読み込みに関するバグの修正

Marubatsu_GUI クラスの __ファイルの保存と読み込みを行う処理__ は、Marubatsu_GUI クラスが __AI のパラメータを扱うことができるように修正する前に実装した__ ので、当然ですが __保存したファイルの中に AI のパラメータは含まれません__。また、今回の記事で実装した __AI の名前のデータも保存されない__ ので、それらを保存するように修正する必要があります。

## `on_save_button_clicked` の修正

ファイルを保存する処理は、下記のプログラムのように、`create_event_handler` の中で定義された `on_save_button_clicked` 内で行われています。

```python
 1  def create_event_handler(self):
略                   
 2      def on_save_button_clicked(b=None):
 3          name = ["人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__
 4                   for i in range(2)]
 5          timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
 6          fname = f"{name[0]} VS {name[1]} {timestr}"
 7          path = filedialog.asksaveasfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
 8                                              initialdir="save", initialfile=fname,
 9                                              defaultextension="mbsav")
10          if path != "":
11              with open(path, "wb") as f:
12                  data = {
13                      "records": self.mb.records,
14                      "move_count": self.mb.move_count,
15                      "ai": self.mb.ai,
16                      "seed": self.inttext.value if self.checkbox.value else None
17                  }
18                  pickle.dump(data, f)
略
```

3 行目は、AI の名前を計算する処理ですが、この処理のままでは __今回の記事で新しく実装した__ 、AI の名前を キーワード引数 `names` で設定した場合の __名前が反映されません__。そのため、AI の名前を __選択中の Dropdown の項目名を使って計算する__ ように修正する必要があります。なお、AI の名前として、`Marubatsu_GUI` クラスの `names` 属性の値をそのまま使えば良いのではないかと思う人がいるかもしれませんが、__`names` 属性の値__ は __最初に行われた対戦での AI の名前__ であり、__現在対戦中の AI の名前ではないの__ で使うことはできません[^1]。

また、12 ~ 17 行目からファイルには以下のデータを保存することがわかります。

* 〇×ゲームの棋譜を表す `records` 属性
* 現在の局面の手数をあらわす `move_count` 属性
* それぞれの手番の担当する AI の関数を表す `ai` 属性
* 乱数の種に関するデータ

上記の中には、__AI が必要とするパラメーター__ や、__AI の名前__ の __データが存在しない__ ので、それらのデータを保存するように修正する必要があります。

下記は、そのように `on_save_button_clicked` を修正したプログラムです。なお、行番号の無いプログラムはかなり長いので、この後でファイルの読み込みの処理を修正した後でまとめて表記することにします。

* __3、5 行目__：AI の名前を選択中の Dropdown の項目名を使って計算するように修正する。なお、複数のデータを扱うのに名前が `name` では変なので `names` に修正した
* __12 行目__：AI のパラメーターを `params` のキーの値に代入する
* __13 行目__：AI の名前を `names` のキーの値に代入する

```python
 1  def create_event_handler(self):
略                   
 2      def on_save_button_clicked(b=None):
 3          names = [ self.dropdown_list[i].label for i in range(2) ]
 4          timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
 5          fname = f"{names[0]} VS {names[1]} {timestr}"
略
 6          if path != "":
 7              with open(path, "wb") as f:
 8                  data = {
 9                      "records": self.mb.records,
10                      "move_count": self.mb.move_count,
11                      "ai": self.mb.ai,
12                      "params": self.params,
13                      "names": names,
14                      "seed": self.inttext.value if self.checkbox.value else None
15                  }
16                  pickle.dump(data, f)
略
```

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

```diff_python
def create_event_handler(self):
略 
    def on_save_button_clicked(b=None):
-       name = ["人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__
-               for i in range(2)]
+       names = [ self.dropdown_list[i].label for i in range(2) ]        
        timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
-       fname = f"{name[0]} VS {name[1]} {timestr}"
+       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)
略
```
</details>

## `on_load_button_clicked` の修正

ファイルの読み込みの処理は、下記のプログラムのように、`create_event_handler` の中で定義された `on_load_button_clicked` 内で行われています。

```python
 1  def create_event_handler(self):
略
 2      # 開く、保存ボタンのイベントハンドラを定義する
 3      def on_load_button_clicked(b=None):
 4          path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
 5                                          initialdir="save")
 6          if path != "":
 7              with open(path, "rb") as f:
 8                  data = pickle.load(f)
 9                  self.mb.records = data["records"]
10                  self.mb.ai = data["ai"]
11                  change_step(data["move_count"])
12                  for i in range(2):
13                      value = "人間" if self.mb.ai[i] is None else self.mb.ai[i]
14                      self.dropdown_list[i].value = value               
15                  if data["seed"] is not None:                   
16                      self.checkbox.value = True
17                      self.inttext.value = data["seed"]
18                  else:
19                      self.checkbox.value = False
略
```

上記に対して下記の修正を行う必要があります。

* `self.params` にファイルから読み込んだデータを代入する
* 14 行目で、選択中の Dropdown の項目の値を表す `value` 属性に (AI の関数, AI のパラメーター) の tuple を代入するように修正する必要がある
* [前回の記事](https://qiita.com/ysgeso/items/0894a987b51323a4a9b4#%E4%BA%BA%E9%96%93%E3%82%92%E6%8B%85%E5%BD%93%E3%81%99%E3%82%8B%E9%9A%9B%E3%81%AE%E3%83%87%E3%83%BC%E3%82%BF%E6%A7%8B%E9%80%A0%E3%81%AE%E4%BF%AE%E6%AD%A3)で人間を担当する際のデータ構造の修正したので、13 行目の処理は必要が無くなり、`(self.mb.ai[i], self.params[i])` という tuple を代入すればよい

下記は、それらの修正を行ったプログラムです。

* __11 行目__：AI のパラメーターを `self.params` に代入する
* __14 行目__：選択中の Dropdown の項目の値を表す `value` 属性に代入するデータを (AI の関数, AI のパラメーター) の tuple に修正する

```python
 1  def create_event_handler(self):
略
 2      # 開く、保存ボタンのイベントハンドラを定義する
 3      def on_load_button_clicked(b=None):
 4          path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
 5                                          initialdir="save")
 6          if path != "":
 7              with open(path, "rb") as f:
 8                  data = pickle.load(f)
 9                  self.mb.records = data["records"]
10                  self.mb.ai = data["ai"]
11                  self.params = data["params"]
12                  change_step(data["move_count"])
13                  for i in range(2):
14                      self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])
15                  if data["seed"] is not None:                   
16                      self.checkbox.value = True
17                      self.inttext.value = data["seed"]
18                  else:
19                      self.checkbox.value = False
略
```

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

```diff_python
def create_event_handler(self):
略
    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"]
                change_step(data["move_count"])
                for i in range(2):
-                   value = "人間" if self.mb.ai[i] is None else self.mb.ai[i]
-                   self.dropdown_list[i].value = value               
+                   self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])        
                if data["seed"] is not None:                   
                    self.checkbox.value = True
                    self.inttext.value = data["seed"]
                else:
                    self.checkbox.value = False
略
```
</details>

:::note
上記でファイルに保存した AI の名前を利用していない点が気になった人がいるかもしれませんが、AI の名前のデータは、この後で行う修正で利用します。
:::

下記は修正した `create_event_handler` のプログラムです。長いので折りたたみました。

<details><summary><strong>修正した <code>create_event_handler</code></strong></summary>

```python
import math
import pickle
from datetime import datetime
from tkinter import Tk, filedialog

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"]
                change_step(data["move_count"])
                for i in range(2):
                    self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            
                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_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.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>

実行結果は省略しますが、上記の修正後に、下記のプログラムを実行し、様々な対戦カードで対戦を行ってファイルを保存し、保存したファイルを読み込めることを確認して下さい。

```python
from util import gui_play

gui_play()
```

## 対戦カードの表示が更新されないバグの修正

修正した Marubatsu_GUI クラスにはいくつかのバグがあるので修正することにします。

1 つ目のバグは、__ファイルを読み込んだ際に対戦カードの表示が更新されない__ 点です。例えば「人間 VS 人間」の対戦が行われている際に「ai2s VS ai2s」の対戦結果のファイルを読み込むと、データは正しく読み込めますが、対戦カードの表示が「人間 VS 人間」のまま変化しません。ただし、リプレイボタンをクリックして __手番を前後に移動すると対戦カードの表示が正しくなります__。実際にそのようになることを試してみて下さい。また、バグの原因を少し考えてみて下さい。

リプレイ機能で前後の手番を表示すると __対戦カードが正しく表示されるようになる__ ことから、__データは正しく読み込めている__ ことがわかります。そのことから、ファイルから __データを読み込んだ際のゲーム盤の表示の更新の処理に何らかの問題がある__ ことが推測されます。

そこで、下記の `on_load_button_clicked` の中でゲーム盤の表示の更新を行う処理を探してみると、__直接画面の表示の更新を行う処理が見当たらない__ ことがわかります。

```python
 1  def on_load_button_clicked(b=None):
 2      path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
 3                                        initialdir="save")
 4      if path != "":
 5          with open(path, "rb") as f:
 6              data = pickle.load(f)
 7              self.mb.records = data["records"]
 8              self.mb.ai = data["ai"]
 9              self.params = data["params"]
10              change_step(data["move_count"])
11              for i in range(2):
12                  self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            
13              if data["seed"] is not None:                   
14                  self.checkbox.value = True
15                  self.inttext.value = data["seed"]
16              else:
17                  self.checkbox.value = False
```

この中で、棋譜に従って __実引数に記述した手数までの着手を行う__ 10 行目の __`change_step`__ の定義を見ると、下記のプログラムの 4 行目のように、__その中で描画を更新する処理が行われている__ ことがわかります。

```python
1  def change_step(step):
2     self.mb.change_step(step)
3     # 描画を更新する
4     self.update_gui()    
```

上記から、`on_load_button_clicked` が行う処理を整理すると、下記ようになります。

1. 6 ~ 9 行目でファイルから読み込んだデータを適切な属性に代入する
2. 10 行目で棋譜に従って着手を行い、描画を更新する
3. 11、12 行目で Dropdown の項目を読み込んだ対戦カードのデータに設定する

__対戦カードの表示__ は、__選択中の Dropdown の項目名を使って行われます__。上記の処理では __Dropdown の項目の選択を更新する処理__ を行う手順 3 __より前の手順 2 で描画の更新を行っている__ ため、__対戦カードの表示が更新されません__。これがバグの原因です。

従って、このバグは上記の __手順 2と 手順 3 の順番を入れ替える__ か、__手順 3 の後で描画の更新を行う__ ようにすることで修正できます。下記は手順 2 と 手順 3 を入れ替えるように修正したプログラムで、本記事ではこちらを採用することにします。

```python
1  def create_event_handler(self):
元と同じなので省略
2      # 開く、保存ボタンのイベントハンドラを定義する
3      def on_load_button_clicked(b=None):
元と同じなので省略
4                  for i in range(2):
5                      self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            
6                  change_step(data["move_count"])
元と同じなので省略
7   
8  Marubatsu_GUI.create_event_handler = create_event_handler
```

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

```python
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"]
                for i in range(2):
                    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_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.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
def create_event_handler(self):
元と同じなので省略
    # 開く、保存ボタンのイベントハンドラを定義する
    def on_load_button_clicked(b=None):
元と同じなので省略
-               change_step(data["move_count"])
                for i in range(2):
                    self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            
+               change_step(data["move_count"])
元と同じなので省略
 
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

実行結果は省略しますが、上記の修正後に下記のプログラムを実行し、現在とは異なる対戦カードのファイルを読み込んだ際に、対戦カードの表示が正しく更新されるようになったことを確認して下さい。

```python
gui_play()
```

:::note
このバグは、ファイルの保存と読み込みの機能を最初に実装した際には発生していませんでした。過去のプログラムが正しく動作していたのは、過去の `update_gui` では下記のプログラムのように対戦カードを選択中の Dropdown の項目名ではなく、`ai` 属性を使って表示していたからです。

```python
# 対戦カードの文字列を計算する
names = []
for i in range(2):
    names.append("人間" if ai[i] is None else ai[i].__name__)
ax.text(0, 3.5, f"{names[0]}　VS　{names[1]}", fontsize=20)
```
:::

## Dropdown に登録されていない AI の対戦ファイルを読み込んだ場合のバグの修正

現状の Marubatsu_GUI には、__Dropdown に登録されていない AI__ の対戦ファイルを読み込んだ場合に __エラーが発生する__ という問題があります。

例えば、下記の手順で操作を行うとエラーが発生します。

1\. 下記のプログラムを実行して、`gui_play()` を実行した際に Dropdown に登録されない `ai1` VS `ai2` の対戦を行い、結果をファイルに保存する

```python
from ai import ai1, ai2

gui_play(ai=[ai1, ai2])
```

2\. 下記のプログラムを実行して手順 1 で保存したファイルを読み込む

```python
gui_play()
```

ファイルを読み込んだ際の実行結果

```
略
Cell In[8], line 19
     17 self.params = data["params"]
     18 for i in range(2):
---> 19     self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            
     20 change_step(data["move_count"])
     21 if data["seed"] is not None:      
略
File c:\Users\ys\Anaconda3\envs\marubatsu\Lib\site-packages\ipywidgets\widgets\widget_selection.py:135, in findvalue(array, value, compare)
    133     return next(x for x in array if compare(x, value))
    134 except StopIteration:
--> 135     raise ValueError('%r not in array'%value)

TypeError: not all arguments converted during string formatting
```

最後の行に表示されるエラーメッセージの意味は正直な所よくわかりませんが、__raise ValueError('%r not in array'%value)__ から、`value` が配列（array）に存在しないことが原因である可能性が高いことが推測されます。また、エラーメッセージをさかのぼると、下記の処理を実行した結果エラーが発生したことが確認できます。

```python
self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])  
```

この処理は、`(self.mb.ai[i], self.params[i])` という tuple を項目の値として持つ Dropdown の項目を選択するという処理ですが、__`ai1` も `ai2` も__ `gui_play()` を実行した際の __Dropdown の項目には存在しない__ ので、このエラーは __Dropdwon に存在しない項目を選択しようとした結果表示されたもの__ であることが推測されます。

実際に __`on_load_button_clicked`__ の中では、__Dropdown に存在しない AI__ をファイルから読み込んだ際に、その項目を __登録するという処理を行っていない__ ので、その処理を記述することでこのエラーを解消できる可能性が高いことが推測されます。

### 既に作成されている Dropdown への項目の追加の方法

__`on_load_button_clicked`__ に記述する必要があるのは、__既に作成されている Dropdown に項目を追加する__ という処理なので全く同じではありませんが、__Dropdown を作成する前__ に Dropdown の項目の一覧を表すデータに __項目を追加するという処理__ であれば、下記の __`create_dropdown`__ の 7 ~ 9 行目で行っています。

```python
1  def create_dropdown(self):
略
2      for i in range(2):
3          value = ( self.mb.ai[i], self.params[i] )
4          # value を select_values に常に登録する
5          select_values.append(value)
6          # value が ai_values に登録済かどうかを判定する
7          if value not in self.ai_dict.values():
8              # 項目を登録する
9              self.ai_dict[self.names[i]] = value
略
```

:::note alert
__Dropdown の項目の一覧__ に関するデータは、__`options` 属性に代入されている__ ので、上記のプログラムを参考に、下記のように `on_load_button_clicked` を修正すれば良いと思った人が多いかもしれません。

* __9 行目__：ファイルから読み込んだ AI の名前を `names` に代入する
* __10 ~ 14 行目__：それぞれの Dropdown の項目の一覧を表す `options` 属性に代入された dict のキーの値に、ファイルから読み込んだ (AI の関数, AI のパラメータ) が存在するかどうかを判定し、存在しない場合は 13、14 行目で、両方の Dropdown にその AI の項目を登録する

```python
 1  def create_event_handler(self):
略
 2       def on_load_button_clicked(b=None):
略
 3          if path != "":
 4              with open(path, "rb") as f:
 5                  data = pickle.load(f)
 6                  self.mb.records = data["records"]
 7                  self.mb.ai = data["ai"]
 8                  self.params = data["params"]
 9                  names = data["names"]               
10                  for i in range(2):
11                      value = (self.mb.ai[i], self.params[i])
12                      if not value in self.dropdown_list[i].options.value():
13                          self.dropdown_list[0].options[names[i]] = value
14                          self.dropdown_list[1].options[names[i]] = value
15                  for i in range(2)
16                      self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])
17                  change_step(data["move_count"])
18                  if data["seed"] is not None:                   
19                      self.checkbox.value = True
20                      self.inttext.value = data["seed"]
21                  else:
22                      self.checkbox.value = False
略
```

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

```diff_python
def create_event_handler(self):
略
    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"]
+               names = data["names"] 
+               for i in range(2):
+                   value = (self.mb.ai[i], self.params[i])
+                   if not value in self.dropdown_list[i].options.value():
+                       self.dropdown_list[0].options[names[i]] = value
+                       self.dropdown_list[1].options[names[i]] = value
                for i in range(2):
                    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
略
```
</details>

ただし、__上記の方法では Dropdown の項目を新しく登録することはできませんでした__。上記の例ではわかりづらいので、別の例を使って具体的に説明します。
:::

下記のプログラムは、a と b という 2 つの項目を持つ Dropdown を作成して表示するプログラムで、実行結果のように、そのような Dropdown が作成されることが確認できます。

```python
d = widgets.Dropdown(
    options = { "a": 1, "b": 2 }
)
display(d)
```

実行結果

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/f8211b19-b78f-a063-a652-9b768399affa.png" width="300">

この Dropdown に c という項目を追加しようとして、下記のプログラムのように c のキーの値を代入すると、`options` 属性には確かに c というキーとその値が追加されますが、上記で作成された Dropdown には実行結果のように __c という項目は追加されません__。

```python
d.options["c"] = 3
print(d.options)
```

実行結果

```
{'a': 1, 'b': 2, 'c': 3}
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/f8211b19-b78f-a063-a652-9b768399affa.png" width="300">

色々と試した所、__作成済の Dropdown の項目を後から更新する__ ためには、更新後の項目を表す __別のオブジェクトを `options` 属性に代入しなおす必要がある__ ことが判明しました[^2]。

具体的には、下記のプログラムのように、__`options` 属性に代入された dict を別のオブジェクトにコピー__ し、__コピーした dict に キーを追加して `options` 属性に代入し直す__ と実行結果のように Dropdown に c と d の項目が追加されます。なお、__dict には自身の浅いコピーを行った dict を返す `copy` メソッドがある__ ので、copy モジュールをインポートする必要はありません[^3]。また、新しい値を代入する前に `options` 属性に代入されていたデータは、二度と利用することはないので `options` 属性に対して深いコピーを行う必要はありません。 

```python
options = d.options.copy()
options["d"] = 4
d.options = options
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/a032f4d1-704f-10e2-469d-d3e611349498.png" width="300">

:::note
細かい話なので、下記の意味が良くわからない人は無視してもかまいません。

色々試してみた所、厳密には `options` 属性に「異なるオブジェクト」なおかつ、「異なる内容を持つオブジェクト」を代入する必要があるようです。わかりづらいと思いますので具体例をいくつか挙げます。

例えば、下記のプログラムのように、`options` 属性をコピーせずにそのまま `options` という変数に代入して共有し、c というキーに値を代入した後で `options` 属性に `options` を代入しても、`options` 属性に代入されたオブジェクトが変化しないので、Dropdown の項目は更新されません。

```python
d = widgets.Dropdown(
    options = { "a": 1, "b": 2 }
)
display(d)

options = d.options
options["c"] = 3
d.options = options
```

また、上記の処理の後で、下記のプログラムのように `options` 属性をコピーし、それをそのまま `options` 属性に代入した場合は、Dropdown の項目は a と b のままで、`options` には a、b、c の 3 つの項目を表すデータが代入されているので、Dropdown の項目が更新されるのはないかと思う人が多いかもしれません。筆者もそう思っていたのですが、実際には Dropdown の項目は更新されませんでした。

```python
options = d.options.copy()
d.options = options
```

これは筆者の推測なのですが、`options` 属性の値が実際の Dropdown の項目と一致しているかどうかに関わらず、`options` 属性の値と `options` 属性に代入されたデータの内容が一致している場合は Dropdown の項目は変化しないようです。

先程の例で、`options` をコピーした後で d の項目を追加したのはそのためです。
:::

### `on_load_button_clicked` の修正

上記から、作成済の Dropdown に後から項目を追加するには、`options` 属性の値をコピーした dict に対して項目のデータを追加して `options` 属性に代入すればよいことがわかりましたので、`on_load_button_clicked` を下記のように修正します。

* __12 行目__：ファイルから読み込んだ AI の名前をローカル変数 `names` に代入する
* __13 行目__：Dropdown の項目のデータを表す dict を `copy` メソッドでコピーしてローカル変数 `options` に代入する。なお、__2 つの Dropdown の項目__ は __どちらも同じ__ なので、__片方だけをコピーすればよい__
* __14 ~ 17 行目__：`options` のキーの値に、ファイルから読み込んだ (AI の関数, AI のパラメータ) が存在するかどうかを判定し、存在しない場合は 17 行目で __`options` の AI の名前のキーの値にそのデータを代入する__
* __19 行目__：それぞれの Dropdown の項目のデータを `options` のデータで更新する。なお、ファイルから読み込んだ AI の項目が Dropdown に登録済の場合は、`options` のデータは変化しないので、この処理を行っても Dropdown の項目は変化しない
* __20 行目__：19 行目の処理によって、20 行目の処理で Dropdown に登録されていない項目が選択されてエラーが発生することが無くなる

```python
 1  def create_event_handler(self):
元と同じなので省略
 2      # 開く、保存ボタンのイベントハンドラを定義する
 3      def on_load_button_clicked(b=None):
 4          path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
 5                                            initialdir="save")
 6          if path != "":
 7              with open(path, "rb") as f:
 8                  data = pickle.load(f)
 9                  self.mb.records = data["records"]
10                  self.mb.ai = data["ai"]
11                  self.params = data["params"]
12                  names = data["names"]
13                  options = self.dropdown_list[0].options.copy()
14                  for i in range(2):
15                      value = (self.mb.ai[i], self.params[i]) 
16                      if not value in options.values():
17                          options[names[i]] = value
18                  for i in range(2):
19                      self.dropdown_list[i].options = options
20                      self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            
21                  change_step(data["move_count"])
元と同じなので省略
22    
23  Marubatsu_GUI.create_event_handler = create_event_handler
```

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

```python
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"]
                names = data["names"]
                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_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.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
def create_event_handler(self):
元と同じなので省略
    # 開く、保存ボタンのイベントハンドラを定義する
    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"]
+               names = data["names"]
+               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"])
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

実行結果は省略しますが上記の修正を行った後で下記のプログラムを実行し、`ai1` VS `ai2` の対戦のデータを読み込んでもエラーが発生しなくなったことを確認して下さい。

```python
gui_play()
```

## 互換性の問題の修正

今回の記事で、ファイルに保存するデータの __データ構造を変えたため、過去に保存したファイルを読み込むことができなくなる__ という __互換性の問題__ が発生しています。

下記は、過去に保存したファイルを読み込んだ場合に表示されるエラーメッセージです。

```
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In[16], line 17
     15 self.mb.records = data["records"]
     16 self.mb.ai = data["ai"]
---> 17 self.params = data["params"]
     18 names = data["names"]
     19 options = self.dropdown_list[0].options.copy()

KeyError: 'params'
```

エラーメッセージから読み込んだデータに `names` というキーが存在しないことが確認できますが、これは当然の結果でしょう。今回の記事の github の save フォルダに「古いバージョンのセーブファイル.mbsav」というファイルを保存しておくので、興味がある方は実際に試してみて下さい。

過去に保存したデータを読み込むことができるようにするためには、下記のプログラムのように、ファイルから読み込んだ dict の中に `"params"` と `"names"` が存在しなかった場合の処理を記述する必要があります。

* __4 行目__：`data` に `"params"` というキーが存在しない場合は、`self.params` に `[ {}, {} ]` を代入するように修正する
* __5 ~ 8 行目__：`data` に `"names"` というキーが存在しない場合は、`names` に以前と同じ方法で計算した名前を表す list を計算して代入するように修正する

```python
 1  def create_event_handler(self):
元と同じなので省略
 2      # 開く、保存ボタンのイベントハンドラを定義する
 3      def on_load_button_clicked(b=None):
元と同じなので省略
 4                  self.params = data["params"] if "params" in data else [ {}, {} ]
 5                  if "names" in data:
 6                      names = data["names"]
 7                  else:
 8                      names = [ "人間" if mb.ai[i] is None else mb.ai[i].__name__ for i in range(2)]
元と同じなので省略 
 9    
10  Marubatsu_GUI.create_event_handler = create_event_handler
```

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

```python
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_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.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
def create_event_handler(self):
元と同じなので省略
    # 開く、保存ボタンのイベントハンドラを定義する
    def on_load_button_clicked(b=None):
元と同じなので省略
-               self.params = data["params"]
+               self.params = data["params"] if "params" in data else [ {}, {} ]
-               names = data["names"]
+               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"])
元と同じなので省略 
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

上記の修正後に下記のプログラムを実行し、過去のバージョンのファイルを正しく読み込むことができるようになったことを確認して下さい。

```python
gui_play()
```

### 互換性に対する補足

ファイルの互換性はあると便利ですが、ファイルに保存する __データ構造を何度も変更__ すると、__互換性への対応の処理がどんどん増えていく__ ため、__プログラムが非常にわかりづらくなることが良くあります__。そのため、互換性の対応の処理が __ある程度以上複雑になった時点で、古いバージョンへの互換性を切り捨てる__ ということが良く行われます。

# ？ ボタンの表示の修正

細かい点ですが、下図のように ？ ボタンに .. が表示される点が気になったので修正することにします。

![764.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/7d739374-42b6-b7f3-61b9-229460d132db.png)

色々と試してみた結果、この .. は __ボタンの幅が狭すぎて__ ボタンの中の __文字列がうまく表示しきれない場合に表示される__ ことがわかりました。なお、上図では ？ がうまく表示されているように見えるかもしれませんが、幅が狭すぎて ？ が __中央揃えで表示されていません__。

そのため、ボタンの幅を広げることで .. が表示されないようになります。試行錯誤の結果、？ ボタンの幅を 33 以上にすれば .. が表示されないようになることが判明しましたので、下記のプログラムの 3 ~ 5 行目のように ？ ボタンの幅を 30 から 34 に 4 増やし、代わりに開くと保存ボタンの幅を 2 ずつ減らすことで全体のバランスをとることにしました。

```python
1  def create_widgets(self):
元と同じなので省略
2      # 読み書き、ヘルプのボタンを作成する
3      self.load_button = self.create_button("開く", 78)
4      self.save_button = self.create_button("保存", 78)
5      self.help_button = self.create_button("？", 34)
元と同じなので省略
6
7  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="100px"))   

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

Marubatsu_GUI.create_widgets = create_widgets
```
</details>

上記の修正後に下記のプログラムを実行すると、実行結果のように ？ ボタンに .. が表示されなくなったことが確認できます。

```python
gui_play()
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/c8ad3b28-05a8-42d3-ba80-1fa4662f9394.png" width="500">

# 今回の記事のまとめ

今回の記事ではファイルの保存と読み込みのバグの修正と、改良を行いました。

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

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

# 次回の記事

https://qiita.com/ysgeso/items/b5e388878fef195d8f2c

[^1]:筆者も最初は勘違いして `names` 属性を使って実装してしまいました
[^2]:このことに関して [ipywidgets のドキュメント](https://ipywidgets.readthedocs.io/en/8.1.5/index.html)を探してみたのですが、どこに記載されているかはよくわかりませんでした。ご存じの方がいればコメントで教えて頂ければ助かります
[^3]:深いコピーは copy モジュールの `deepcopy` を利用する必要があります