0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-09-12

目次と前回の記事

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

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

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

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

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

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

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

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 の関数名が設定される
 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 の名前を要素とする listplay メソッドの 仮引数 names に代入 して設定できるようにすることにします。また、names の要素の値が None の場合 は、対応する AI の名前は これまでと同じ方法で設定する ようにします。

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

play メソッドの修正

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

  • 3 行目:デフォルト値を None とする仮引数 names を追加する
  • 6 行目:Marubatsu_GUI クラスのインスタンスを作成する際に、キーワード引数 names=names を追加する

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

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
行番号のないプログラム
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
修正箇所
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

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

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

  • 5 行目:仮引数 names を追加し、seedsize を通常の仮引数に修正する
  • 5 行目の下にあった paramsNone の場合に行う処理を削除する
  • 8、9 行目namesNone の場合は、両方の名前を自動的に設定することを表す [None, None] を代入する
  • 10 ~ 15 行目:それぞれの AI の名前を表す names の要素が None の場合に、名前を create_dropdown の 5 ~ 8 行目で行っていたのと同じ方法で設定する処理を行う
  • 16 行目:仮引数 names を同名の属性に代入する
 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__
行番号のないプログラム
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__
修正箇所
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__

create_dropdown の修正

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

  • 2 行目の下にあった、AI の名前を計算する処理は __init__ メソッドで行うことにしたので削除する
  • 9 行目:対戦カードの名前は self.names[i] に代入されているので、キーの名前をそのように修正する
 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
行番号のないプログラム
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
修正箇所
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

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

from ai import ai2s

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

実行結果

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

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

実行結果

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

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

on_save_button_clicked の修正

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

 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 のキーの値に代入する
 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)

修正箇所
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)

on_load_button_clicked の修正

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

 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 を代入するように修正する必要がある
  • 前回の記事で人間を担当する際のデータ構造の修正したので、13 行目の処理は必要が無くなり、(self.mb.ai[i], self.params[i]) という tuple を代入すればよい

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

  • 11 行目:AI のパラメーターを self.params に代入する
  • 14 行目:選択中の Dropdown の項目の値を表す value 属性に代入するデータを (AI の関数, AI のパラメーター) の tuple に修正する
 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

修正箇所
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

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

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

修正した create_event_handler
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

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

from util import gui_play

gui_play()

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

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

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

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

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

 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 行目のように、その中で描画を更新する処理が行われている ことがわかります。

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 を入れ替えるように修正したプログラムで、本記事ではこちらを採用することにします。

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
行番号のないプログラム
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
修正箇所
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

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

gui_play()

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

# 対戦カードの文字列を計算する
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 の対戦を行い、結果をファイルに保存する

from ai import ai1, ai2

gui_play(ai=[ai1, ai2])

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

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)に存在しないことが原因である可能性が高いことが推測されます。また、エラーメッセージをさかのぼると、下記の処理を実行した結果エラーが発生したことが確認できます。

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

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

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

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

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

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

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

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

修正箇所
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

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

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

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

実行結果

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

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

実行結果

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

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

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

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

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

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

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

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 の項目は更新されませんでした。

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 に登録されていない項目が選択されてエラーが発生することが無くなる
 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
行番号のないプログラム
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
修正箇所
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

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

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 を計算して代入するように修正する
 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
行番号のないプログラム
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
修正箇所
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

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

gui_play()

互換性に対する補足

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

? ボタンの表示の修正

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

764.png

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

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

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
行番号のないプログラム
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
修正箇所
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

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

gui_play()

今回の記事のまとめ

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

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

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

次回の記事

  1. 筆者も最初は勘違いして names 属性を使って実装してしまいました

  2. このことに関して ipywidgets のドキュメントを探してみたのですが、どこに記載されているかはよくわかりませんでした。ご存じの方がいればコメントで教えて頂ければ助かります

  3. 深いコピーは copy モジュールの deepcopy を利用する必要があります

0
0
0

Register as a new user and use Qiita more conveniently

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?