0
0

Pythonで〇×ゲームのAIを一から作成する その87 ファイルダイアログの設定とOutputウィジェット

Last updated at Posted at 2024-06-06

目次と前回の記事

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

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

ルールベースの AI の一覧

ルールベースの AI の一覧については、下記の記事を参照して下さい。

ファイルダイアログに関する問題点

前回の記事では、対戦結果をファイルに読み書きする処理を実装しましたが、その際に表示される ファイルダイアログ には以下のような 欠点 があります。

今回の記事では、それぞれの対処法を説明します。

  • ファイルを開く ダイアログで すべて拡張子のファイルが表示 される。実際に開く ファイルの 拡張子 のファイル だけを表示 にしたほうが便利である
  • ダイアログに表示 される フォルダmarubatsu.ipynb のフォルダとは限らない ので、ファイルを保存するフォルダが必ず表示されるようにしたほうが便利である
  • ファイルの保存marubatsu.ipynb とは 別のフォルダに行ったほうが便利 である
  • ファイルを保存 するダイアログで、ファイル名をすべて入力する必要があるひな形となるファイル名拡張子あらかじめ入力 されていたほうが便利である
  • ファイルダイアログを表示した際に、ファイアダイアログが フォーカスされない ので、キーボードでファイルダイアログを操作するためには、ファイルダイアログをクリックしてフォーカスする必要がある(後で説明しますが、この問題は未解決 です。修正方法をご存時の方がいればコメントしていただければ嬉しいです)

ファイルダイアログに関する設定は、ファイルダイアログを作成する際に キーワード引数を記述 することで行います。記述できるキーワード引数の一覧については、下記のリンク先を参照して下さい。

JupyterLab でファイルダイアログを開くための前処理の記述場所

前回の記事で記述したプログラムには、修正したほうが良い点がある事がわかったので、ファイルダイアログの設定の説明を行う前に、その修正を行うことにします。

前回の記事で、JupyterLab でファイルダイアログを開くための 前処理 として、下記のプログラムを記述しました。

from tkinter import Tk, filedialog

root = Tk()
root.withdraw()
root.call('wm', 'attributes', '.', '-topmost', True)

前回の記事では、下記のプログラムのように、ファイルダイアログを開くたびにこの処理を実行しましたが、この処理は 1 回だけ実行すれば良い ことがわかりました。

def on_load_button_clicked(b):
    root = Tk()
    root.withdraw()
    root.call('wm', 'attributes', '.', '-topmost', True)        
    path = filedialog.askopenfilename()

上記の root = Tk() という処理では、Tk というクラスのインスタンスが作成されます。そのため、上記のプログラムでは、ファイルダイアログを開くたびに Tk クラスのインスタンスが作成されることになります。

ファイルダイアログを JupyterLab から開くためには、Tk クラスのインスタンスは 1 つだけ作成すれば十分です。必要のないインスタンスの作成無駄なメモリの消費などにつながる ので避けたほうが良いでしょう。

そこで、上記の処理を下記のプログラムのように Marubatsu_GUI クラスブロックの中に記述 することにします。以前の記事で説明したように、クラスの定義を実行 すると、クラスのブロックの中 に記述された プログラムが実行されます。そのため、下記のプログラムのように marubatsu.py の中で Marubatsu_GUI クラスを定義し、marubatsu モジュールをインポート すると、Marubatsu_GUI の定義が実行され、一度だけ JupyterLab でファイルダイアログを開くための前処理が 実行される ようになります。

class Marubatsu_GUI:
    # JupyterLab からファイルダイアログを開く際に必要な前処理
    root = Tk()
    root.withdraw()
    root.call('wm', 'attributes', '.', '-topmost', True)  
    
    def __init__(self, mb, ai_dict=None, seed=None, size=3):
元と同じなので省略
修正箇所
class Marubatsu_GUI:
    # JupyterLab からファイルダイアログを開く際に必要な前処理
+   root = Tk()
+   root.withdraw()
+   root.call('wm'+, 'attributes', '.', '-topmost', True)  
    
    def __init__(self, mb, ai_dict=None, seed=None, size=3):
元と同じなので省略

ファイルダイアログの前処理は、今回の記事の最初で marubatsu.iypnb に記述して実行済なので、今回の記事の残りのプログラムを実行する際に、上記のクラスの定義を marubatsu.ipynb に記述して実行する必要はありません。

上記のプログラムは、今回の記事の marubatsu_new.py に反映します。

上記のように記述することで、下記のプログラムのように、create_event_handler の中で、ファイルダイアログを開く処理だけを記述すれば良くなります。

  • 8、11 行目の下に記述されていた、ファイルダイアログを開くための前処理を削除する
 1  from marubatsu import Marubatsu, Marubatsu_GUI
 2  import math
 3  import pickle
 4
 5  def create_event_handler(self):
元と同じなので省略
 6      # 開く、保存ボタンのイベントハンドラを定義する
 7      def on_load_button_clicked(b):
 8          # この下にあったファイルダイアログを開くための前処理を削除する
 9          path = filedialog.askopenfilename()
元と同じなので省略
10      def on_save_button_clicked(b):
11          # この下にあったファイルダイアログを開くための前処理を削除する
12          path = filedialog.asksaveasfilename()
元と同じなので省略
13    
14  Marubatsu_GUI.create_event_handler = create_event_handler
行番号のないプログラム
from marubatsu import Marubatsu, Marubatsu_GUI
import math
import pickle

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):
        path = filedialog.askopenfilename()
        if path != "":
            with open(path, "rb") as f:
                data = pickle.load(f)
                self.mb.records = data["records"]
                self.mb.ai = data["ai"]
                change_step(data["move_count"])

    def on_save_button_clicked(b):
        path = filedialog.asksaveasfilename()
        if path != "":
            with open(path, "wb") as f:
                data = {
                    "records": self.mb.records,
                    "move_count": self.mb.move_count,
                    "ai": self.mb.ai,
                }
                pickle.dump(data, f)
            
    self.load_button.on_click(on_load_button_clicked)
    self.save_button.on_click(on_save_button_clicked)
    
    # 変更ボタンのイベントハンドラを定義する
    def on_change_button_clicked(b):
        for i in range(2):
            self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
        self.mb.play_loop(self)

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

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

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    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,            
        }
        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 marubatsu import Marubatsu, Marubatsu_GUI
import math
import pickle

def create_event_handler(self):
元と同じなので省略
    # 開く、保存ボタンのイベントハンドラを定義する
    def on_load_button_clicked(b):
-       root = Tk()
-       root.withdraw()
-       root.call('wm', 'attributes', '.', '-topmost', True) 
        path = filedialog.askopenfilename()
元と同じなので省略
    def on_save_button_clicked(b):
-       root = Tk()
-       root.withdraw()
-       root.call('wm', 'attributes', '.', '-topmost', True) 
        path = filedialog.asksaveasfilename()
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler

実行結果は省略しますが、下記のプログラムで gui_play を実行し、開く、保存ボタンをクリックするとファイルダイアログが表示されることを確認して下さい。

from util import gui_play

gui_play()

filetypes によるファイルの種類の設定

ファイルダイアログ に表示する ファイルの拡張子を設定する方法 を説明します。そのためには、〇×ゲームの 対戦データ を保存するファイルの 拡張子を決める 必要があります。

ファイルの拡張子の決定

ファイルの拡張子は、どこかの機関が管理していると思っている人がいるかもしれませんが、そのような機関はありません。ファイルの拡張子 は、その ファイルの作成者自由に決めて構いません前回の記事pickle で保存したファイルは .pkl という拡張子で良く保存されると説明しまいたが、下記の理由から、〇×ゲームの対戦データを保存するファイルは、別の拡張子で保存したほうが良い でしょう。

  • 他の Python のプログラムが pickle で保存した 関係のないファイルと区別がつかない
  • 間違って他人が作った 危険な pickle のファイル開いてしまう可能性が生じる

本記事では、〇×ゲームの対戦のデータなので .mbsav(marubatsu save の略)という拡張子で保存することにします。おそらくこの拡張子が他のアプリの保存ファイルで使われていることはほぼないと思いますので、間違って別のアプリが作成した .mbsav ファイルを開く可能性は低い でしょう。なお、別の拡張子が良いと思った方は自由に変更して下さい。

ファイルの種類の設定方法

ファイルダイアログに表示する ファイルの拡張子の設定 は、ファイルの種類拡張子 の情報を ペアで設定 します。具体的には、filetypes という キーワード引数 に、("ファイルの種類の説明", "*.拡張子") という tuple を、list の形式で記述 します。

なお、キーワード引数の名前が filetypes になっているので、以後はこの設定のことを拡張子の設定ではなく、ファイルの種類の設定 と表記することにします。

"*.拡張子" の部分は、ファイルダイアログに 表示するファイルの検索の設定 という意味を持ちます。拡張子の前に記述 する * は、ワイルドカード という、検索 のために利用できる 特殊な記号 で、任意の文字列 という意味を持ちます。例えば *.mbsav と記述することで、.mbsav の前任意の文字列が入ったファイルを検索する という意味になるので、拡張子が .mbsav のファイルだけ がファイルダイアログに 表示される ようになります。

なお、検索の対象となるのはファイルだけ で、フォルダはすべて表示 されます。

本記事では利用しませんが、*.* と記述すると、任意の拡張子のファイルを検索 するという意味になります。ファイルダイアログのファイルの種類で良く見かける「すべてのファイル(*.*)」は、すべての拡張子のファイルを検索して表示するという意味です。ただし、細かい話になりますが、*.* は、ファイル名の中に必ず . が入っている必要があるので、拡張子がないファイルは検索されません

ワイルド は「特殊な」という意味を持つ wild という英単語で、カードは文字通り、トランプやタロットなどのカードのことです。例えば トランプの Joker特殊なカード なので ワイルドカード(wild card)と呼ばれます。また、ウノ の WildWild 4 なども特殊な意味を持つカードです。

大富豪やポーカーなどの多くのトランプのゲームの Joker や、ウノの Wild は 任意のカード や、最強のカード として利用することができ、そのような性質を持つものを、英語で「全能の」という意味を持つ オールマイティ(allmighty)と呼びます。ワイルドカード は、オールマイティの意味 で用いられることが 多く、上記の * という記号は、任意の文字列のという、オールマイティな意味を持つ記号です。

余談ですが、大リーグのプレーオフで、リーグで優勝していない出場チームを表すワイルドカードは、スポーツにおける特別な追加枠という別の意味の用語です。

filetypes は list の形式なので、複数のファイルの種類 を設定することができます。複数のファイルの種類を設定した場合は、ファイルダイアログの中に表示されるドロップダウンメニューから選択できるようになります。なお、ファイルの種類が 1 つしかない場合 でも list の形式で記述する必要がある 点に注意して下さい。

下記は、1 行目が実引数を記述せずにファイルダイアログを開く、2 行目がファイルを開くダイアログに .mbsav の拡張子のファイルのみを表示するプログラムで、実行結果の左図が 1 行目、右図が 2 行目のプログラムで表示されるダイアログです。なお、ダイアログに表示されているフォルダは、どちらも marubatsu.ipynb 保存されるフォルダです。

filedialog.askopenfilename()
filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")])

実行結果(下図は、画像なので操作することはできません)

 

左右の図は、以下の点が異なります。

左図 右図
表示されるファイル すべてのファイル 拡張子が .mbsav のファイル
(存在しないので何も表示されない)
ファイル名の
テキストボックスの右
何も表示されない ファイルの種類を選択する
ドロップダウンメニュー

ファイルの保存ダイアログの場合もほぼ同様ですが、下記のプログラムの実行結果のように、ファイル名の下 にファイルの種類を選択するドロップダウンメニューが表示される点が異なります。

filedialog.asksaveasfilename(filetypes=[("〇×ゲーム", "*.mbsav")])

実行結果(下図は、画像なので操作することはできません)

initialdir による表示するフォルダの設定

ファイルダイアログの 作成時に表示されるフォルダ は、キーワード引数 initialdir記述しない 場合は、直前にファイルダイアログでファイルを選択したフォルダ になります。

ファイルダイアログの作時に 特定のフォルダを表示 したい場合は、キーワード引数 initialdir 1フォルダのパス(住所)を指定します。ファイルダイアログを呼び出したプログラム(marubatsu.ipynb)のファイルと 同じフォルダを設定 する場合は、下記のプログラムのように、現在のフォルダのパス を意味する "./" を記述します。

filedialog.askopenfilename(initialdir="./")

実行結果は省略しますが、上記のプログラムを実行すると、ファイルダイアログの 作成時に表示されるフォルダ が、必ず marubatsu.ipynb が保存されているフォルダ になります。

別のフォルダの表示

対戦結果を保存する ファイルの数が多くなる と、marubatsu.ipynb とは 別のフォルダに対戦結果のファイルを保存 したほうが ファイルの整理がついて わかりやすくなります。そこで、本記事では save というフォルダの中に対戦結果のファイルを保存することにします。

marubatsu.ipynb同じフォルダ内 にあるフォルダを表示する場合は、下記のプログラムのように、initialdir にその フォルダの名前を記述 します。

filedialog.askopenfilename(initialdir="save")

ただし、実行結果は省略しますが、上記のプログラムのように、存在しないフォルダ をキーワード引数 initialdir に記述してファイルダイアログを表示した場合は、エラーにはならず、直前にファイルダイアログでファイルを選択したフォルダ が表示されます。

従って、ファイルダイアログを表示した際に save フォルダを表示 したい場合は、ファイルダイアログを表示する前に save フォルダを作成する必要があります

フォルダの作成

あらかじめ save フォルダを 自分で作成するのは面倒 なので、ファイルダイアログを開く際に、Python のプログラムmarubatsu.ipynb と同じフォルダに save というフォルダを作成する ことにします。

フォルダの作成 は、os という組み込みモジュールの mkdir2 という関数で行えます。mkdir の実引数 には、作成する フォルダのパスを記述 します。これから作成する save フォルダのように、mkdir を実行する Python のプログラムと 同じフォルダ に新しいフォルダを作成する場合は、下記のプログラムのように フォルダの名前だけ を記述します。

import os

os.mkdir("save")

上記のプログラムを実行しても何も表示されませんが、下図のように marubatsu.ipynb と同じフォルダsave というフォルダが作成される ので、確認して下さい。

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

既にフォルダが存在する場合の対処法

mkdir を実行した際に、 既に 作成しようとした フォルダが存在する場合エラーが発生 します。例えば、上記のプログラムを実行後に、下記のプログラムで もう一度 save フォルダを作成 しようとすると実行結果のような「既に存在するファイルを作成することはできません」というメッセージが表示される エラーが発生 します。

os.mkdir("save")

実行結果

---------------------------------------------------------------------------
FileExistsError                           Traceback (most recent call last)
Cell In[9], line 1
----> 1 os.mkdir("save")

FileExistsError: [WinError 183] 既に存在するファイルを作成することはできません。: 'save'

従って、mkdir を利用する場合 は、作成しようとした フォルダが存在しない ことを 確認する必要がありますフォルダが存在するか どうかは、os.path.exists という関数で確認することができます。os.path.exists は、実引数に記述 された パスのファイルやフォルダが存在する 場合は True を返す 関数で、下記のプログラムを実行すると、save フォルダ は先程作成して 存在する ので、実行結果のように True が表示 されます。

print(os.path.exists("save"))

実行結果

True

従って、下記のプログラムのように記述することで、フォルダが存在しない場合のみmkdir が実行される ようになるので、実行結果のようにエラーが発生しなくなります。なお、下記のプログラムは if 文の条件文の計算結果が False になり、2 行目の処理は行われないため、実行結果には何も表示されません。

if not os.path.exists("save"):
    os.mkdir("save")

実行結果

save フォルダを作成 する処理は 一度だけ行えば良い ので、下記のプログラムの 7、8 行目のように、ファイルダイアログの前処理と同様に、Marubatsu_GUI クラスのブロックの中に記述する ことにします。

 1  class Marubatsu_GUI:
 2      # JupyterLab からファイルダイアログを開く際に必要な前処理
 3      root = Tk()
 4      root.withdraw()
 5      root.call('wm', 'attributes', '.', '-topmost', True)  
 6
 7      # save フォルダが存在しない場合は作成する
 8      if not os.path.exists("save"):
 9          os.mkdir("save")    
10    
11      def __init__(self, mb, ai_dict=None, seed=None, size=3):
元と同じなので省略
修正箇所
class Marubatsu_GUI:
    # JupyterLab からファイルダイアログを開く際に必要な前処理
    root = Tk()
    root.withdraw()
    root.call('wm'+, 'attributes', '.', '-topmost', True)  

    # save フォルダが存在しない場合は作成する
+   if not os.path.exists("save"):
+       os.mkdir("save")    
    
    def __init__(self, mb, ai_dict=None, seed=None, size=3):
元と同じなので省略

save フォルダは先程作成したので、上記のクラスの定義を marubatsu.ipynb に記述して実行する必要はありません。

上記のプログラムは、今回の記事の marubatsu_new.py に反映します。

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

os.makedirs3 という関数にキーワード引数 exist_ok=True を記述して実行することで、既にフォルダが作成されている場合でもエラーが発生しないようにすることもできます。なお、os.mkdiros.makedirs は、他にも行う処理に若干の違いがあります。興味がある方は、下記のリンク先を参照して下さい。

ファイルの保存ダイアログのファイル名のひな形の設定

ファイルの保存ダイアログファイル名 のテキストボックスに、あらかじめファイル名のひな形を入力する ためには、ひな形となるファイル名をどのように設定するかを決める必要があります。どのような名前がふさわしいかについて少し考えてみて下さい。

ファイルの作成の際に問題となるのは、過去に保存したファイル異なる名前を考える必要がある 点です。作成する ファイル名の重複を避ける方法 の一つに、下記のような理由から、ファイル名に ファイルを保存した日付と時刻を入れる というものがあります。

  • 日付と時刻を入れることで、過去に保存したファイル名重複しなくなる
  • いつ保存した ファイルであるかが わかりやすくなる

datetime クラスによる現在時刻の取得

現在の日付と時刻 は、datetime というモジュールで定義された、日付と時刻を管理する機能 を持つ datetime というクラスの now というメソッドで得ることができます。

具体的には、下記のプログラムのように datetime.datetime.now を記述します。

import datetime

print(datetime.datetime.now())

実行結果

2024-06-06 12:06:22.770140

now は、datetime モジュールで定義された、モジュールと同じ名前datetime というクラスのメソッドなので、datetime.datetime.now() と記述する必要があります。間違えやすいですが、datetime.now() と記述するとエラーが発生する点に注意して下さい。

datetime はクラスとして定義されたものですが、名前の頭文字が大文字になっていません。これは筆者の勝手な想像ですが、datetime から直接インスタンスを作成するという使い方をしないので、そのようになっているのではないかと思います(ちがっていたらすみません)。

この記述がわかりづらいと思った方は、下記のプログラムのように datetime モジュールから datetime クラスをインポート することで、datetime.now() と記述できる ようになります。本記事では、以降はこの表記を採用します。

from datetime import datetime

print(datetime.now())

実行結果

2024-06-06 12:06:23.668461

now は、datetime クラスの クラスメソッドとして定義 されているので、datetime クラスの インスタンスを作成せず に、datetime クラスから 直接呼び出します

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

datetime クラスの now メソッドの詳細については、下記のリンク先を参照して下さい。

時刻を表す文字列のフォーマットの設定方法

下記は、先程の実行結果です。これは 2024年6月6日12時6分23.668461秒 を意味し、now が、現在時刻ミリ秒単位で計算 することがわかります。しかし、ファイル名に ミリ秒の部分の情報 を入れるとファイル名が長くなるので、削除したほうが良い でしょう。

2024-06-06 12:06:23.668461

また、Windows では、ファイル名半角の : を入れることはできない という決まりになっているので、上記の文字列そのままファイル名に入れることはできません。そこで、現在の日付と時刻を 別の文字列で表現する ことにします。

日付と時刻は、下記のように、さまざまな文字列の形式 で記述することができます。このような、何かを表現する 文字列の形式 の事を文字列の フォーマット(format)と呼びます。

  • 2024-06-06 12:06:23.668461
  • 2024/06/06 12:06:23
  • 2024-June-06 12:06:23
  • 2024年6月6日 12時6分23秒
  • 2024年06月06日 12時06分23秒

上記の 1、2、5 番目のフォーマットでは、1 桁の数字 である 6 の 先頭に 0 をつけて 2 桁の数字で表現 しています。このように 数字の先頭に 0 をつけて、桁の数をそろえる ことを 0 埋め と呼び、下記のような利点があるので良く使われます。

  • 日付と時刻の 文字列の長さが常に同じになる ので、並べた時に見やすくなる
  • 名前の順で並べ替えた時に、日付の 新しい(または古い)順で並べ替えることができる

上記の 2 番目の 2024/06/06 のような日付のフォーマットは良く使われますが、Windows では ファイル名に半角の / を入れることはできない ので、本記事では上記の 5 番目のフォーマットで日付と時刻の文字列を表現することにします。

now は、現在時刻 の情報を持つ datetime クラスのインスタンスを作成 する関数で、datetime クラスには、時刻任意のフォーマットの文字列に整形 する strftime というメソッドがあります。

strftime の実引数には、フォーマットを表す文字列 を記述し、フォーマットの中に記述された下記の表の % で始まる 2 文字の文字列 を、対応する文字列に変換する という処理を行います。なお、数値に変換 する際には、先程説明した 0 埋めが行われます

意味 文字の語源
%Y 4 桁の西暦に変換する year の略
%m 2 桁の月に変換する month の略
%d 2 桁の日に変換する day の略
%H 2 桁の時間に変換する hour の略
%M 2 桁の分に変換する minute の略
%S 2 桁の秒に変換する second の略

なお、上記は本記事で利用するもののみを記述しています。strftime の詳細については、下記のリンク先を参照して下さい。

わかりづらいと思いますので具体例を示します。

下記の 2 ~ 5 行目は、現在時刻を strftime で指定したフォーマットに変換する処理を行うプログラムです。1 行目と比較して strftime が行う処理を確認して下さい。

なお、3、4 行目のように日付や時刻だけの文字列を作成することも可能です。また、本記事では、下記の 5 行目のフォーマットを採用しますが、もっとわかりやすいフォーマットを思いついた人は、自由に変更して下さい。

print(datetime.now())
print(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
print(datetime.now().strftime("%Y-%m-%d"))
print(datetime.now().strftime("%H:%M:%S"))
print(datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒"))

実行結果

2024-06-06 12:19:19.645087
2024-06-06 12:19:19
2024-06-06
12:19:19
2024年06月06日 12時19分19秒

名前への対戦カードの記述

ひな形となるファイル名には、下記のように時刻だけでなく、対戦カードを入れたほうが便利 だと思いましたので、ファイル名は下記のようにすることにします。対戦カードを入れないほうが良いと思った人、対戦カードを日付の後に入れたほうが良いと思った人、他のもっと良い名前を思いついた人などは、自由に変更して下さい。

人間 VS ai1s 2024年06月06日 12時19分19秒.mbsav

ファイル名が入力済のファイルの保存ダイアログの作成

ファイル名が入力済のファイル保存ダイアログは、下記のプログラムのように、キーワード引数 initialfile を記述することで、実行結果のように作成できます。また、下記のプログラムを実行して表示されるダイアログの保存ボタンをクリックすると、実行結果のように、initialfile に記述したファイルのパスが表示されます。

print(filedialog.asksaveasfilename(filetypes=[("〇×ゲーム", "*.mbsav")], initialdir="save",
                            initialfile="人間 VS ai1s 2024年06月04日 23時55分41秒.mbsav"))

実行結果(下図は、画像なので操作することはできません)

C:/Users/ys/ai/marubatsu/087/save/人間 VS ai1s 2024年06月04日 23時55分41秒.mbsav

save フォルダには、拡張子が .mbsav のファイルが存在しないので、上図のようにファイルの保存ダイアログにはファイルは表示されません。

また、上図で「検索条件に一致する項目はありません」と表示されることから *.mbsav によって 拡張子が .mbsav のファイルが 検索された ことが確認できます。

デフォルトの拡張子の設定

ファイル保存ダイアログを作成する際に、キーワード引数 defaultextension を記述することで、ファイル名に 拡張子を記述しなかった場合 に、自動的にその拡張子を付加 したファイルのパスが作成されるようになります。

例えば、下記のプログラムでは initialfile拡張子のない "test" を設定 し、defaultextensionmbsav というデフォルトの拡張子を設定しています。このプログラムを実行すると、実行結果のように、ファイル名 には test.mbsav という、defaultextension で設定した 拡張子が付加 されたものが表示されます。

print(filedialog.asksaveasfilename(filetypes=[("〇×ゲーム", "*.mbsav")], initialdir="save",
                                   initialfile="test", defaultextension="mbsav"))

実行結果

また、上図のファイル名を編集して、下図のように ファイル名に拡張子の無い test を入力 して保存ボタンをクリックすると、下記の実行結果のように、defaultextension で設定した 拡張子が付加されたファイルのパスが表示 されます。

C:/Users/ys/ai/marubatsu/087/save/test.mbsav

プログラムの修正

下記は、ここまでで説明した内容を使って、ファイルダイアログの欠点を修正するように create_event_handler を修正したプログラムです。

  • 4 行目:「ファイルの種類」と、「ファイルダイアログの作成時に表示するフォルダ」を設定した、ファイルを開くダイアログを作成して表示する
  • 7、8 行目:ファイル名のひな形で利用する、それぞれの手番を表す文字列を計算し、list 内包表記を利用して list の形式で name に代入する
  • 9 行目:現在の日付と時刻を表す文字列を計算して timestr に代入する
  • 10 行目nametimestr を使ってファイル名のひな形を計算し、fname に代入する
  • 11 行目:「ファイルの種類」、「ファイルダイアログの作成時に表示するフォルダ」、「ファイル名のひな形」、「デフォルトの拡張子」を設定した、ファイルの保存ダイアログを作成して表示する
 1  def create_event_handler(self):
元と同じなので省略
 2      # 開く、保存ボタンのイベントハンドラを定義する
 3      def on_load_button_clicked(b):
 4          path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
 5                                            initialdir="save")
元と同じなので省略
 6      def on_save_button_clicked(b):
 7          name = ["人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__ 
 8                  for i in range(2)]
 9          timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
10          fname = f"{name[0]} VS {name[1]} {timestr}"
11          path = filedialog.asksaveasfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
12                                              initialdir="save", initialfile=fname,
13                                              defaultextension="mbsav")
元と同じなので省略            
14    
15  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):
        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"]
                change_step(data["move_count"])

    def on_save_button_clicked(b):
        name = ["人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__
                for i in range(2)]
        timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
        fname = f"{name[0]} VS {name[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,
                }
                pickle.dump(data, f)
            
    self.load_button.on_click(on_load_button_clicked)
    self.save_button.on_click(on_save_button_clicked)
    
    # 変更ボタンのイベントハンドラを定義する
    def on_change_button_clicked(b):
        for i in range(2):
            self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
        self.mb.play_loop(self)

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

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

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    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,            
        }
        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):
        path = filedialog.askopenfilename()
+       path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
+                                         initialdir="save")
元と同じなので省略         
    def on_save_button_clicked(b):
+       name = ["人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__
+               for i in range(2)]
+       timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
+       fname = f"{name[0]} VS {name[1]} {timestr}"
-       path = filedialog.asksaveasfilename()
+       path = filedialog.asksaveasfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
+                                           initialdir="save", initialfile=fname,
+                                           defaultextension="mbsav")
元と同じなので省略            
    
Marubatsu_GUI.create_event_handler = create_event_handler

上記の修正後に、下記のプログラムで gui_play を実行し、保存ボタンをクリックすると、save フォルダが表示され、実行結果の左図のように ひな形のファイル名が入った ファイルの保存ダイアログが表示されます。ファイルを保存後に、開くボタンをクリックすると、実行結果の右図のように、save フォルダ内の 拡張子が .mbsav のファイルのみが表示 されるようになります。また、保存したファイルを読み込むことができることを確認して下さい。

gui_play()

実行結果(下図は、画像なので操作することはできません)

 

ファイルダイアログのフォーカス(この問題は未解決です)

ウィンドウやパネルなどが、キーボードからの入力の対象 となっている状態のことを、フォーカス されていると呼びます。現状では、開くや保存ボタンをクリックした際に、表示される ファイルダイアログフォーカスされずJupyterLab のウィンドウフォーカスされたまま になっているという問題があります。

そのため、例えば保存ボタンをクリックして表示されるファイルの保存ダイアログの ひな形のファイル名のままファイルを保存 しようとしてエンターキーを押しても、JupyterLab 上でエンターキーが押されたことになる のでファイルは保存されません。また、ファイルの保存ダイアログの ファイル名に文字を入力する ためには、ファイルダイアログをマウスでクリック してフォーカスする必要があります。

JupyterLab でファイルダイアログを作成した際に、ファイルダイアログをフォーカスする方法 を探してみたのですが、みつかりませんでした ので、現状では残念ながら、ファイルダイアログを表示した後で ファイルダイアログに対してキー入力操作を行う際 には、ファイルダイアログを クリックしてフォーカス する必要があります。

JupyterLab でファイルダイアログを作成した際に、ファイルダイアログをフォーカスする方法をご存じの方 がいれば、コメントで教えて頂ければ大変助かります

以上でファイルダイアログの欠点の修正は完了です。残りの記事では、他の〇×ゲームの GUI の改良方法について紹介します。

ファイルを開いた際の Dropdown の設定と乱数の種の保存

現状では、ファイルを開いた際に、手番の担当を表す Dropdown は変更されません。そのため、例えば下記のような操作を行うと、意図しない処理が行われる 可能性があります。

  1. 人間どうしで対戦を行い、ファイルに保存する
  2. Dropdown を操作して、対戦カードを変更した後で、手順 1 で保存したファイルを開く
  3. 人間どうしの対戦のデータが読み込まれるが、Dropdown は手順 2 で変更された対戦カードのまま変化しない
  4. そのため、リセットボタンをクリックすると、リセットボタンをクリックする前に表示されていた人間どうしの対戦ではなく、手順 2 で変更した対戦カードが開始されてしまう。これは、人によっては意図した対戦ではないかもしれない

また、現状では対戦結果のファイルに 乱数の種の情報が保存されていない ので、乱数の種を設定した AI どうしの対戦 を保存たファイルを 読み込んだ際 に、どのような乱数の種で対戦が行われたかがわからなくなる という問題があります。

そこで、上記の問題を解決するために、下記のようにプログラムを修正することにします。

  • ファイルを読み込んだ際に、Dropdown に対戦カードが選択されるようにする
  • ファイルに乱数の種の情報を保存する
  • ファイルを読み込んだ際に、乱数の種のウィジェットを、読み込んだ情報で更新する

本記事では行いませんが、ファイル名のひな形に、下記のように乱数の種の情報を入れるという工夫も考えられます。

人間 VS ai1s 乱数の種 123 2024年06月06日 12時19分19秒.mbsav

下記は、そのように create_event_handler を修正したプログラムです。なお、下記の説明は、わかりやすさを重視して、先に保存の処理の修正から説明します。

  • 30 行目:乱数の種の情報を保存する際に、乱数の種の Chekcbox と IntText の value 属性を保存してもかまわないが、Checkox が OFF の状態の乱数の種の数値を保存しても意味はない ので、dict の seed というキーの値に、下記のデータを代入することにした
    • 乱数の種の Checkbox が ON の場合は IntText の value 属性の値を代入する
    • 乱数の種の Checkboxが OFF の場合は None を代入する
  • 14 ~ 16 行目:ファイルから読み込んだ dict の ai 属性の値を、それぞれの手番の Dropdown の value 属性に代入することで、Dropdown の選択を対戦カードのものに設定する。ただし、人間が担当する場合ai 属性の要素には None が代入 されているのに対し、Dropdown人間の項目の value 属性 には、以前の記事で説明した理由により "人間" という文字列が代入 されているので、その変換の処理 を 15 行目で行っている
  • 17 ~ 21 行目:ファイルから読み込んだ dict の seed 属性の値によって下記の処理を行う
    • None 以外の場合は Checkbox を ON にし、IntText の value 属性にその値を代入する
    • None の場合は Checkbox を OFF にする
 1  import math
 2
 3  def create_event_handler(self):
元と同じなので省略
 4      # 開く、保存ボタンのイベントハンドラを定義する
 5      def on_load_button_clicked(b):
 6          path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
 7                                            initialdir="save")
 8          if path != "":
 9              with open(path, "rb") as f:
10                  data = pickle.load(f)
11                  self.mb.records = data["records"]
12                  self.mb.ai = data["ai"]
13                  change_step(data["move_count"])
14                  for i in range(2):
15                      value = "人間" if self.mb.ai[i] is None else self.mb.ai[i]
16                      self.dropdown_list[i].value = value      
17                  if data["seed"] is not None:                   
18                      self.checkbox.value = True
19                      self.inttext.value = data["seed"]
20                  else:
21                      self.checkbox.value = False
22                     
23      def on_save_button_clicked(b):
元と同じなので省略
24          if path != "":
25              with open(path, "wb") as f:
26                  data = {
27                      "records": self.mb.records,
28                      "move_count": self.mb.move_count,
29                      "ai": self.mb.ai,
30                      "seed": self.inttext.value if self.checkbox.value else None
31                  }
32                  pickle.dump(data, f)
元と同じなので省略
33    
34  Marubatsu_GUI.create_event_handler = create_event_handler
行番号のないプログラム
import random

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):
        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"]
                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                       
                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):
        name = ["人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__
                for i in range(2)]
        timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
        fname = f"{name[0]} VS {name[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,
                    "seed": self.inttext.value if self.checkbox.value else None
                }
                pickle.dump(data, f)
            
    self.load_button.on_click(on_load_button_clicked)
    self.save_button.on_click(on_save_button_clicked)
    
    # 変更ボタンのイベントハンドラを定義する
    def on_change_button_clicked(b):
        for i in range(2):
            self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
        self.mb.play_loop(self)

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

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

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    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,            
        }
        if event.key in keymap:
            keymap[event.key]()
        else:
            try:
                num = int(event.key) - 1
                event.inaxes = True
                event.xdata = num % 3
                event.ydata = 2 - (num // 3)
                on_mouse_down(event)
            except:
                pass
            
    # fig の画像イベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    self.fig.canvas.mpl_connect("key_press_event", on_key_press)   
    
Marubatsu_GUI.create_event_handler = create_event_handler
修正箇所
import math

def create_event_handler(self):
元と同じなので省略
    # 開く、保存ボタンのイベントハンドラを定義する
    def on_load_button_clicked(b):
        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"]
                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     
+               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):
元と同じなので省略  
        if path != "":
            with open(path, "wb") as f:
                data = {
                    "records": self.mb.records,
                    "move_count": self.mb.move_count,
                    "ai": self.mb.ai,
+                   "seed": self.inttext.value if self.checkbox.value else None
                }
                pickle.dump(data, f)
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler

実行結果は省略しますが、上記の修正後に下記のプログラムで gui_play() を実行し、下記の操作を行ってプログラムが正しく動作することを確認して下さい。

gui_play()
  • 以下の対戦を行い、それぞれの対戦結果をファイルに保存する(下記以外の対戦を行ってファイルに保存してもかまいません)
    • 人間 VS 人間 で乱数の種を利用しない
    • 人間 VS AI で乱数の種を利用しない
    • AI VS AI で乱数の種を利用する
  • それぞれの対戦結果のファイルを開き、正しい対戦結果が再現され、乱数の種と Dropdown が正しく設定されることを確認する

ファイルの保存データの形式を変更 したので、上記の 修正を行う前に保存したファイルを読み込むとエラーが発生します。そのため、上記の修正前に保存したファイルは github にはアップロードしません。

Output ウィジェットによるメッセージの表示と消去

現状のプログラムでは、既にマークが配置されたマスをクリック すると、下図のように、print による メッセージがゲーム盤の画像の下に表示 されます。

現状では、この メッセージはずっと残りつづけます が、ゲームが リセットされた場合 は、表示を消去したほうが良い のでメッセージを消去する方法を説明します。

Output ウィジェット

通常print による 文字列のメッセージの表示 は、プログラムを実行したセルの下の 末尾に表示 が行われますが、ipywidgets の Output というウィジェットを利用することで、文字列のメッセージの 表示位置を制御 したり、表示内容を消去できる ようになります。

Outputprint などによる、プログラムの 出力(output)の結果を表示 するウィジェットで、下記のプログラムのように記述することで、作成して表示することができます。

import ipywidgets as widgets

output = widgets.Output()
display(output)

Output は、作成した時点では表示内容は空 なので、上記のプログラムを実行しても何も表示されませんが、実行した JupyterLab のセルの下に Output が作成されています。

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

Output への文字の表示と表示内容の消去

Outputprint で文字を表示 するには、下記のプログラムの 2 行目のように with output: のブロックの中に記述 します。下記のプログラムを実行すると、2 行目の with output: のブロックの中で実行された print によるメッセージは、先程 Output を作成したセルの下に作成された Output の中に表示されますが、4 行目の print によるメッセージは、下記のプログラムを実行したセルの下に表示されます。

with output:
    print("output message")  # このメッセージは Output を作成したセルの下に表示される

print("message")             # このメッセージはこのセルの下に表示される

Output の表示内容を消去 するには、下記のプログラムのように、Output の clear_output メソッドを実行します。下記のプログラムを実行すると、先程の Output ウィジェットの表示が消去されます。

output.clear_output()

Output の作成、配置の実装

下記は、create_widgets を修正して Output を作成するプログラムです。

  • 6 行目:Output を作成して、output 属性に代入する
1  def create_widgets(self):
元と同じなので省略
2      # ゲーム盤の画像を表す figure を作成する
3      self.create_figure()
4
5      # print による文字列を表示する Output を作成する
6      self.output = widgets.Output()
7
8  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("開く", 100)
    self.save_button = self.create_button("保存", 100)
    
    # 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):
元と同じなので省略
    # ゲーム盤の画像を表す figure を作成する
    self.create_figure()

    # print による文字列を表示する Output を作成する
+   self.output = widgets.Output()

Marubatsu_GUI.create_widgets = create_widgets

下記は、create_widgets を修正して Output を一番下に配置 するプログラムです。本記事では行いませんが、VBox の配列の先頭に self.output を記述することで、Output を一番上に表示するようにすることもできます。

  • 3 行目:VBox の配列の最後に self.output を記述し、Output を一番下に配置する
1  def display_widgets(self):
元と同じなので省略
2      # hbox1 ~ hbox3、Output を縦に配置した VBox を作成し、表示する
3      display(widgets.VBox([hbox1, hbox2, hbox3, self.output]))
4    
5  Marubatsu_GUI.display_widgets = display_widgets 
行番号のないプログラム
def display_widgets(self):
    # 乱数の種のウィジェット、読み書きのボタンを横に配置した HBox を作成する
    hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button, self.save_button])
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
    hbox2 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
    hbox3 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
    # hbox1 ~ hbox3、Output を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2, hbox3, self.output]))
    
Marubatsu_GUI.display_widgets = display_widgets
修正箇所
def display_widgets(self):
元と同じなので省略
    # hbox1 ~ hbox3、Output を縦に配置した VBox を作成し、表示する
-   display(widgets.VBox([hbox1, hbox2, hbox3]))
+   display(widgets.VBox([hbox1, hbox2, hbox3, self.output]))
    
Marubatsu_GUI.display_widgets = display_widgets 

Output へのメッセージの表示と消去の処理の実装

先程のメッセージの表示は、プレイヤーが マークが配置済のマスに配置を行おうとした際 に表示され、GUI でのその処理は on_mouse_down の中 で行われます。また、配置の処理move メソッドで行われる ので、move メソッドの呼び出しwith self.output: のブロックの中に記述 することで、メッセージを Output 内に表示することができます。

メッセージの消去 は、ゲームのリセット が行われた際に行う必要があります。GUI でのゲームのリセットは on_reset_button_clicked で行われるので、その中で clear_output メソッドを呼び出す ことで、リセット時に Output の表示を削除することができます。

下記は、そのように create_event_handler を修正したプログラムです。

  • 8 行目:7 行目でゲームをリセットした後で、Output の表示内容を消去する
  • 16、17 行目:着手を行う move メソッドの呼び出しを with self.output のブロック内で行うように修正する
 1  def create_event_handler(self):
元と同じなので省略
 2      # リセットボタンのイベントハンドラを定義する
 3      def on_reset_button_clicked(b=None):
 4          # 乱数の種のチェックボックスが ON の場合に、乱数の種の処理を行う
 5          if self.checkbox.value:
 6              random.seed(self.inttext.value)
 7          self.mb.restart()
 8          self.output.clear_output()
 9          on_change_button_clicked(b)
元と同じなので省略
10      # ゲーム盤の上でマウスを押した場合のイベントハンドラ
11      def on_mouse_down(event):
12          # Axes の上でマウスを押していた場合のみ処理を行う
13          if event.inaxes and self.mb.status == Marubatsu.PLAYING:
14              x = math.floor(event.xdata)
15              y = math.floor(event.ydata)
16              with self.output:
17                  self.mb.move(x, y)                
18              # 次の手番の処理を行うメソッドを呼び出す
19              self.mb.play_loop(self)
元と同じなので省略
20    
21  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):
        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"]
                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               
                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):
        name = ["人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__
                for i in range(2)]
        timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
        fname = f"{name[0]} VS {name[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,
                    "seed": self.inttext.value if self.checkbox.value else None
                }
                pickle.dump(data, f)
            
    self.load_button.on_click(on_load_button_clicked)
    self.save_button.on_click(on_save_button_clicked)
    
    # 変更ボタンのイベントハンドラを定義する
    def on_change_button_clicked(b):
        for i in range(2):
            self.mb.ai[i] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
        self.mb.play_loop(self)

    # リセットボタンのイベントハンドラを定義する
    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.draw_board()
        
    # イベントハンドラをボタンに結びつける
    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.draw_board()        

    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)

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    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,            
        }
        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_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_mouse_down(event):
        # Axes の上でマウスを押していた場合のみ処理を行う
        if event.inaxes and self.mb.status == Marubatsu.PLAYING:
            x = math.floor(event.xdata)
            y = math.floor(event.ydata)
-           self.mb.move(x, y)                
+           with self.output:
+               self.mb.move(x, y)                
            # 次の手番の処理を行うメソッドを呼び出す
            self.mb.play_loop(self)
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler

上記の修正後に、下記のプログラムで gui_play を実行し、既にマークが配置されたマスをクリックすると、実行結果のようにメッセージが表示されますが、メッセージがゲーム盤の画像の上に表示される という問題が生じています。ただし、リセットボタンをクリック すると、メッセージが消去される という処理は 正しく実装されている ことが確認できます。

gui_play()

実行結果(下図は、画像なので操作することはできません)

matplotlib の figure の配置

上記の問題は、ゲーム盤を描画する matplotlib の Figuredisplay を使って表示していない ことが原因です。Figure は以前の記事で説明したように、JupyterLab の セルの実行後に自動的に描画される ので、display によって表示された ウィジェットより後に表示 されることになり、ゲーム盤の画像の上に Output のメッセージが表示されます。

matplotlib の Figure は、ipywidgets の ウィジェット同様 に、display で描画できます。その際には、Figure の canvas 属性を指定 します。 また、HBoxVBox の中Figure を配置 することもできるので、下記のプログラムのように、Figure を VBox の中で Output の前に配置 することで、ゲーム盤の下に Output のメッセージが表示される ようになります。

  • 3 行目:VBox の中で、Output の前に Figure を登録するように修正する
1  def display_widgets(self):
元と同じなので省略
2      # hbox1 ~ hbox3、Figure、Output を縦に配置した VBox を作成し、表示する
3      display(widgets.VBox([hbox1, hbox2, hbox3, self.fig.canvas, self.output]))
4    
5  Marubatsu_GUI.display_widgets = display_widgets 
行番号のないプログラム
def display_widgets(self):
    # 乱数の種のウィジェット、読み書きのボタンを横に配置した HBox を作成する
    hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button, self.save_button])
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
    hbox2 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
    hbox3 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
    # hbox1 ~ hbox3、Figure、Output を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2, hbox3, self.fig.canvas, self.output]))
    
Marubatsu_GUI.display_widgets = display_widgets 
修正箇所
def display_widgets(self):
元と同じなので省略
    # hbox1 ~ hbox3、Figure、Output を縦に配置した VBox を作成し、表示する
-   display(widgets.VBox([hbox1, hbox2, hbox3, self.output]))
+   display(widgets.VBox([hbox1, hbox2, hbox3, self.fig.canvas, self.output]))
    
Marubatsu_GUI.display_widgets = display_widgets 

上記の修正を行った後で、下記のプログラムで gui_play() を実行し、マークが配置されているマスをクリックすると、実行結果のように、ゲーム盤の下にメッセージが表示されるようになりますが、その下に 2 つ目のゲーム盤が描画されてしまう という問題が発生します。

gui_play()

実行結果(下図は、画像なので操作することはできません)

Figure が 2 回描画される問題の修正方法

この問題は、display と、JupyterLab のセルの実行の終了後で、2 回 Figure が描画される ことが原因です。従って、JupyterLab の セルの実行の終了後自動的に Figure を描画しないようにする ことでこの問題を解決することができます。

%matplotlib widget を実行した場合 の matplotlib の Figure は、Figure の作成時以前の記事で説明した matplotlib の インタラクティブモードを OFF にすることで、セルの実行後に自動的に描画されない ようにすることができます。

matplotlib のインタラクティブモードは、plt.on()plt.off() を実行することで、ON/OFF を切り替えることができるので、下記のプログラムのように create_figure を修正することで、この問題を解決することができます。なお、Figure の作成後 にインタラクティブモードを ON に戻す必要がある 点に注意して下さい。

  • 4 ~ 6 行目:5 行目で Figure を作成する前に 4 行目でインタラクティブモードを OFF にし、Figure の作成後の 6 行目で ON に戻す
 1  import matplotlib.pyplot as plt
 2
 3  def create_figure(self):
 4      plt.ioff()
 5      self.fig, self.ax = plt.subplots(figsize=[self.size, self.size])
 6      plt.ion()
 7      self.fig.canvas.toolbar_visible = False
 8      self.fig.canvas.header_visible = False
 9      self.fig.canvas.footer_visible = False
10      self.fig.canvas.resizable = False
11    
12  Marubatsu_GUI.create_figure = create_figure
行番号のないプログラム
import matplotlib.pyplot as plt

def create_figure(self):
    plt.ioff()
    self.fig, self.ax = plt.subplots(figsize=[self.size, self.size])
    plt.ion()
    self.fig.canvas.toolbar_visible = False
    self.fig.canvas.header_visible = False
    self.fig.canvas.footer_visible = False
    self.fig.canvas.resizable = False
    
Marubatsu_GUI.create_figure = create_figure
修正箇所
import matplotlib.pyplot as plt

def create_figure(self):
+   plt.ioff()
    self.fig, self.ax = plt.subplots(figsize=[self.size, self.size])
-   plt.ion()
    self.fig.canvas.toolbar_visible = False
    self.fig.canvas.header_visible = False
    self.fig.canvas.footer_visible = False
    self.fig.canvas.resizable = False
    
Marubatsu_GUI.create_figure = create_figure

matplotlib のインタラクティブモードについては、初心者が完全に理解するのは難しいと思いますので、当面は display を使って Figure の配置を行いたい場合 は、Figure を作成する際にインタラクティブモードを OFF にする必要がある とだけ覚えておけばよいでしょう。

なお、インタラクティブモードを 一時的に OFF にする 場合は、下記のプログラムの 2、3 行目のように with plt.ioff(): のブロックの中 に Figure を作成する処理を記述するという方法があります。この方法を利用した場合は、処理の後で plt.ion() を記述してインタラクティブモードを ON に戻す処理を記述しなくても済む という利点があるので、本記事でもこの記述方法を採用することにします。

1  def create_figure(self):
2      with plt.ioff():
3          self.fig, self.ax = plt.subplots(figsize=[self.size, self.size])
4      self.fig.canvas.toolbar_visible = False
5      self.fig.canvas.header_visible = False
6      self.fig.canvas.footer_visible = False
7      self.fig.canvas.resizable = False
8    
9  Marubatsu_GUI.create_figure = create_figure
行番号のないプログラム
def create_figure(self):
    with plt.ioff():
        self.fig, self.ax = plt.subplots(figsize=[self.size, self.size])
    self.fig.canvas.toolbar_visible = False
    self.fig.canvas.header_visible = False
    self.fig.canvas.footer_visible = False
    self.fig.canvas.resizable = False
    
Marubatsu_GUI.create_figure = create_figure
修正箇所
def create_figure(self):
-   plt.ioff()
-   self.fig, self.ax = plt.subplots(figsize=[self.size, self.size])
+   with plt.ioff():
+       self.fig, self.ax = plt.subplots(figsize=[self.size, self.size])
-   plt.ion()       
    self.fig.canvas.toolbar_visible = False
    self.fig.canvas.header_visible = False
    self.fig.canvas.footer_visible = False
    self.fig.canvas.resizable = False
    
Marubatsu_GUI.create_figure = create_figure

上記の修正を行った後で、下記のプログラムで gui_play() を実行するとゲーム盤が 1 つだけ描画されるようになります。また、マークが配置されているマスをクリックすると、実行結果のように、ゲーム盤の下にメッセージが表示されるようになることを確認して下さい。

gui_play()

実行結果(下図は、画像なので操作することはできません)

キー操作の追加とヘルプの表示

最後に以下の機能を追加することにします。操作説明 は、作成したプログラムを自分で遊ぶだけであれば必要はありませんが、他人に遊んでもらう場合はあったほうが良い でしょう。

  • 開く、保存の操作をキー入力で行えるようにする
  • 操作説明のヘルプの表示機能の追加

まず、開く、保存、ヘルプのそれぞれの操作に対して、どのキーをそれぞれの操作に対応させるかを決める必要があります。本記事では、以下のキーをそれぞれの機能に対応させることにします。基本的にはテンキーに対応させましたが、保存、開く、ヘルプは save、open、help の頭文字を取って soh キーにも対応させることにしました。

機能 キー
開く -l
保存 +s
ヘルプ *h

ヘルプ機能に関しては、以下の処理を実装することにします。

  • 開くボタンの右に、ヘルプ機能を呼び出す ? が表示されたボタンを配置する
  • Output の表示内容を削除し、操作説明を表示する

下記は、create_widgets にヘルプボタンの作成する処理を追加したプログラムです。

  • 3 ~ 5 行目:開く、保存ボタンの幅を縮小し、ヘルプボタンを作成する
1  def create_widgets(self):
元と同じなので省略
2      # 読み書き、ヘルプのボタンを作成する  
3      self.load_button = self.create_button("開く", 80)
4      self.save_button = self.create_button("保存", 80)
5      self.help_button = self.create_button("", 30)
元と同じなので省略    
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("開く", 80)
    self.save_button = self.create_button("保存", 80)
    self.help_button = self.create_button("", 30)
    
    # 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("開く", 100)
+   self.load_button = self.create_button("開く", 80)
-   self.save_button = self.create_button("保存", 100)
+   self.save_button = self.create_button("保存", 80)
+   self.help_button = self.create_button("", 30)
元と同じなので省略    

Marubatsu_GUI.create_widgets = create_widgets

下記は、display_widgets にヘルプボタンを配置する処理を追加したプログラムです。

  • 3、4 行目:ヘルプボタンを保存ボタンの右に配置する
1  def display_widgets(self):
2      # 乱数の種のウィジェット、読み書き、ヘルプのボタンを横に配置した HBox を作成する
3      hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button, 
4                            self.save_button, self.help_button])
元と同じなので省略
5    
6  Marubatsu_GUI.display_widgets = display_widgets 
行番号のないプログラム
def display_widgets(self):
    # 乱数の種のウィジェット、読み書き、ヘルプのボタンを横に配置した HBox を作成する
    hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button,
                          self.save_button, self.help_button])
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
    hbox2 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
    hbox3 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
    # hbox1 ~ hbox3、Figure、Output を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2, hbox3, self.fig.canvas, self.output]))
    
Marubatsu_GUI.display_widgets = display_widgets 
修正箇所
def display_widgets(self):
    # 乱数の種のウィジェット、読み書き、ヘルプのボタンを横に配置した HBox を作成する
-   hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button, self.save_button])
+   hbox1 = widgets.HBox([self.checkbox, self.inttext, self.load_button,
+                         self.save_button, self.help_button])
元と同じなので省略
    
Marubatsu_GUI.display_widgets = display_widgets 

下記は create_event_handler を修正したプログラムです。

修正箇所が長いので、行番号は表示せずに、プログラムの説明のみを行います。なお、表示する操作説明を変更した人は自由に変更して下さい。

on_help_button_clicked は、ヘルプボタンをクリックした際の下記の処理を記述します。

  • Output の表示を削除する
  • with self.output: のブロックの中に、Output に表示する 操作説明を表示 する。複数行にまたがって表示 するので、以前の記事で説明した トリプルクオート(""")を使って表示を行っている。なお、文字列の中に記述されている \t は、タブ という 空白文字 を表す。タブ は、次の 8 の倍数の文字までの長さ空白を表す文字 で、この操作説明のように、複数行にまたがって、表示する文字の 縦の位置を揃える 際に良く使われる

on_key_press では、他のボタンと同様の方法で、キーと対応する処理を行う関数を表すデータを追加しています。

def create_event_handler(self):
元と同じなので省略
    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_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,            
        }
元と同じなので省略
    
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"]
                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               
                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):
        name = ["人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__
                for i in range(2)]
        timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
        fname = f"{name[0]} VS {name[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,
                    "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] = None if self.dropdown_list[i].value == "人間" else self.dropdown_list[i].value
        self.mb.play_loop(self)

    # リセットボタンのイベントハンドラを定義する
    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.draw_board()
        
    # イベントハンドラをボタンに結びつける
    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.draw_board()        

    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)

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    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
修正箇所(長いので on_help_button_clickedの定義には + はつけません)
def create_event_handler(self):
元と同じなので省略
    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_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,            
        }
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler

上記の修正を行った後で、下記のプログラムで gui_play() を実行すると、実行結果のように ? ボタンが表示されるようになります。また、? ボタンをクリックすると、実行結果のように、Output に操作説明が表示されるようになります。また、開く、保存、? に対応するキーを押すと、それぞれの機能が呼び出されることを確認して下さい。

gui_play()

実行結果(下図は、画像なので操作することはできません)

以上で、GUI の機能の実装は終了です。以前の記事でも説明しましたが、アイディアがあればいくらでも GUI の機能を充実させることができるので、良いアイディアを思いついた人は実装してみて下さい。

今回の記事のまとめ

今回の記事では、ファイルダイアログに関するプログラムの修正、Output に関する処理の実装、ヘルプ機能の実装を行いました。

次回からは、再び AI の話に戻り、探索型の AI の実装を開始します。

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

以下のリンクから、本記事で入力して実行した JupyterLab のファイルを見ることができます。

以下のリンクは、今回の記事で更新した marubatsu.py です。

次回の記事

  1. 最初(initial)に表示するディレクトリ(directory)(フォルダの別名のこと)が名前の由来です

  2. フォルダ(directory)の作成(make)を意味する make directory の略 です

  3. mkdir と 名前が良く似ていますが、make が略されていない 点と、名前が複数形 になっている点が異なります

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