Pythonで〇×ゲームのAIを一から作成する　その84　IntSliderとキー入力によるGUIの実装

# 目次と前回の記事

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

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

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

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

https://github.com/ysgeso/marubatsu/blob/master/084/marubatsu.py

https://github.com/ysgeso/marubatsu/blob/master/084/ai.py

https://github.com/ysgeso/marubatsu/blob/master/084/util.py

## ルールベースの AI の一覧

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

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

# リプレイ中の背景色の変更

前回の記事で、〇×ゲームの GUI の基本的な機能の実装が完了しましたが、まだ改良の余地があるので、今回の記事では引き続きいくつかの改良を行うことにします。

現状では、リプレイ機能を使って __過去の局面を表示__ している場合と、最後の着手が行われた __現在の局面を表示__ している場合の __区別がつきづらい__ という問題があります。実際には、__> と >> ボタンが灰色になっているかどうか__ で区別することができますが、__わかりやすいとは言えない__ でしょう。そこで、リプレイ中の局面を表示している場合に、以下のように表示することで、__リプレイ中であるかどうかを簡単に区別をできる__ ようにすることにします。

* 背景色を水色で表示する
* 手番の後に "Replay" というメッセージを表示する

下記は、そのように ゲーム盤の描画を行う `draw_board` メソッドを修正したプログラムです。なお、背景色やメッセージを別のものに設定したい人は自由に変更して下さい。

* __5 行目__：リプレイ中かどうかを判定し、`is_replay` に代入する
* __6 ~ 9 行目__：ゲーム盤の背景色を表す `facecolor` に適切な色を代入する
* __22、23 行目__：リプレイ中の場合に、手番の後に "Replay" を表示するようにする

```python
 1  from marubatsu import Marubatsu, Marubatsu_GUI
 2  
 3  def draw_board(self):   
元と同じなので省略
 4      # リプレイ中、ゲームの決着がついていた場合は背景色を変更する
 5      is_replay =  self.mb.move_count < len(self.mb.records) - 1 
 6      if self.mb.status == Marubatsu.PLAYING:
 7          facecolor = "lightcyan" if is_replay else "white"
 8      else:
 9          facecolor = "lightyellow"
10
11      ax.figure.set_facecolor(facecolor)
元と同じなので省略
12      # ゲームの決着がついていない場合は、手番を表示する
13      if self.mb.status == Marubatsu.PLAYING:
14          text = "Turn " + self.mb.turn
15      # 引き分けの場合
16      elif self.mb.status == Marubatsu.DRAW:
17          text = "Draw game"
18      # 決着がついていれば勝者を表示する
19      else:
20          text = "Winner " + self.mb.status
21      # リプレイ中の場合は "Replay" を表示する
22      if is_replay:
23          text += " Replay"
24      ax.text(0, -0.2, text, fontsize=20)
元と同じなので省略
25
26  Marubatsu_GUI.draw_board = draw_board
```

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

```python
from marubatsu import Marubatsu, Marubatsu_GUI

def draw_board(self):   
    ax = self.ax
    ai = self.mb.ai
    
    # Axes の内容をクリアして、これまでの描画内容を削除する
    ax.clear()
    
    # y 軸を反転させる
    ax.invert_yaxis()
    
    # 枠と目盛りを表示しないようにする
    ax.axis("off")   
    
    # リプレイ中、ゲームの決着がついていた場合は背景色を変更する
    is_replay =  self.mb.move_count < len(self.mb.records) - 1 
    if self.mb.status == Marubatsu.PLAYING:
        facecolor = "lightcyan" if is_replay else "white"
    else:
        facecolor = "lightyellow"

    ax.figure.set_facecolor(facecolor)
        
    # 上部のメッセージを描画する
    # 対戦カードの文字列を計算する
    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)   
    
    # ゲームの決着がついていない場合は、手番を表示する
    if self.mb.status == Marubatsu.PLAYING:
        text = "Turn " + self.mb.turn
    # 引き分けの場合
    elif self.mb.status == Marubatsu.DRAW:
        text = "Draw game"
    # 決着がついていれば勝者を表示する
    else:
        text = "Winner " + self.mb.status
    # リプレイ中の場合は "Replay" を表示する
    if is_replay:
        text += " Replay"
    ax.text(0, -0.2, text, fontsize=20)
    
    # ゲーム盤の枠を描画する
    for i in range(1, self.mb.BOARD_SIZE):
        ax.plot([0, self.mb.BOARD_SIZE], [i, i], c="k") # 横方向の枠線
        ax.plot([i, i], [0, self.mb.BOARD_SIZE], c="k") # 縦方向の枠線   

    # ゲーム盤のマークを描画する
    for y in range(self.mb.BOARD_SIZE):
        for x in range(self.mb.BOARD_SIZE):
            color = "red" if (x, y) == self.mb.last_move else "black"
            self.draw_mark(ax, x, y, self.mb.board[x][y], color)            

    self.update_widgets_status()  
    
Marubatsu_GUI.draw_board = draw_board
```
</details>

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

```diff_python
from marubatsu import Marubatsu, Marubatsu_GUI

def draw_board(self):   
元と同じなので省略
    # リプレイ中、ゲームの決着がついていた場合は背景色を変更する
-   facecolor = "white" if self.mb.status == Marubatsu.PLAYING else "lightyellow"
+   is_replay =  self.mb.move_count < len(self.mb.records) - 1 
+   if self.mb.status == Marubatsu.PLAYING:
+       facecolor = "white"
+       facecolor = "lightcyan" if is_replay else "white"
+   else:
+       facecolor = "lightyellow"

    ax.figure.set_facecolor(facecolor)
元と同じなので省略
    # ゲームの決着がついていない場合は、手番を表示する
    if self.mb.status == Marubatsu.PLAYING:
        text = "Turn " + self.mb.turn
    # 引き分けの場合
    elif self.mb.status == Marubatsu.DRAW:
        text = "Draw game"
    # 決着がついていれば勝者を表示する
    else:
        text = "Winner " + self.mb.status
    # リプレイ中の場合は "Replay" を表示する
+   if is_replay:
+       text += " Replay"
    ax.text(0, -0.2, text, fontsize=20)
元と同じなので省略

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

上記の修正後に、下記のプログラムで `gui_play` を実行し、< ボタンなどをクリックしてリプレイ中の局面を表示すると、実行結果のように背景色が水色のゲーム盤と、手番の後に "Replay" が表示されるようになります。なお、下記では、すぐにリプレイを行えるように、AI どうしの対戦を行いました。

```python
from ai import ai1

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/842e2bd0-75ce-860f-506c-630878a97478.png" width="200">

# IntSlider による任意の手数への移動

現状のリプレイ機能のボタンでは、一度のクリック操作で __任意の手数の局面に移動することはできません__。そこで、任意の手数の局面に移動するための GUI を実装することにします。

## IntSlider の属性と表示

ipywidgets には、__IntSlider__ という、__整数の値を選択__ できるスライダーのウィジェットが用意されています。IntSlider には、主に下記の属性があります。

| 属性 | 意味 | デフォルト値 |
|:--|:--|--:|
| __`value`__  | 現在の値  | `0` |
| __`min`__  | 最小値  | `0` |
| __`max`__  | 最大値  | `100` |
| __`step`__ | 選択できる数値の間隔 | `1` | 
| __`description`__  | 左に表示される説明  | `""` |
| __`disabled`__ | `True` の場合に操作できなくなる | `False` |

下記は、IntSlider を作成して表示するプログラムの例です。実行結果のように、IntSlider は、ドラッグして整数を選択できる丸いつまみと、その右に現在の値が表示されます。

```python
import ipywidgets as widgets

display(widgets.IntSlider())
```

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/ddef5965-27c9-20c3-e865-205110db37c7.png" width="200">

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

https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20List.html#intslider

https://ipywidgets.readthedocs.io/en/stable/reference/ipywidgets.html#ipywidgets.widgets.widget_int.IntSlider

:::note
他にも、整数以外の数値を扱える [FloatSlider](
https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20List.html#floatslider) などのスライダーがあります。
:::

## ウィジェットのイベントハンドラと `observe` メソッド

__ipywidgets のウィジェット__ は、その __属性の値が変更された際に実行__ する __イベントハンドラを定義__ し、ウィジェットの __`observe`__ [^1]というメソッドで __結びつける__ ことができます。

ウィジェットのイベントの処理に関する詳細は、下記のリンク先を参照して下さい。

https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Events.html#traitlet-events

### イベントハンドラの定義の方法

__`observe`__ メソッドで結びつけるイベントハンドラには、下記のプログラムのように、ウィジェットの操作によって __変更されたデータを表すオブジェクト__ を代入するための __仮引数を記述__ します。下記のイベントハンドラは、仮引数の値を `print` で表示する処理を行います。なお、下記のプログラムでは、[ipywidgets のドキュメントのプログラム](https://ipywidgets.readthedocs.io/en/stable/examples/Widget%20Events.html#signatures)に倣って、この仮引数の名前を `changed` にしました。

```python
def on_slider_changed(changed):
    print(changed)
```

### `observe` メソッドの記述方法

ウィジェットの __`observe`__ メソッドには、下記の実引数を記述します。

* __最初の実引数__：ウィジェットに結びつけるイベントハンドラ
* __キーワード引数 `names`__：ウィジェットのどの属性の値が変更された場合にイベントハンドラが実行されるかを指定する文字列型のデータ[^2]

例えば、下記のプログラムでは、IntSlider に、__IntSlider の現在の値__ を表す __`value` 属性の値が変更__ された際に実行するイベントハンドラを __結び付けています__。

```python
slider = widgets.IntSlider()
slider.observe(on_slider_changed, names="value")
display(slider)
```

:::note
他の例として、names="max" を記述することで、IntSlider の `max` 属性に値を代入して最大値が変更された場合に呼び出されるイベントハンドラを定義できます。
:::

### イベントハンドラの仮引数の値の意味

上記のプログラムを実行後に、表示された __スライダーの値をドラッグして変更__ すると、スライダーの __`value` 属性の値が変更される__ ため __`on_slider_changed` が呼び出され__、下記のような `changed` の値が表示されます。

```
{'name': 'value', 'old': 20, 'new': 1, 'owner': IntSlider(value=1), 'type': 'change'}
```

`changed` には、下記の表のようなキーを持つ dict が代入されます。上記の場合は、`value` 属性の値が 20 から 1 に変更されたことを意味します。

| キー | キーの値の意味 |
|:--|:--|
| __`name`__  | 変更された属性の名前  |
| __`old`__  | 変更前の属性の値  |
| __`new`__  | 変更後の属性の値  |
| __`owner`__  | 属性が変更されたウィジェット |
| __`type`__  | イベントの種類。`change` は属性の値が変更されたことを意味する |



## IntSlider の作成と表示

IntSlider の使い方がわかったので、次は __どこにどのような大きさで IntSlider を配置するか__ を決める必要があります。本記事では、4 つのリプレイボタンの幅を半分にし、空いた右のスペースに IntSlider を配置することにします。下記はそのように `create_widgets` メソッドを修正するプログラムです。なお、下記のプログラムでは、IntSlider の表示幅のみを設定して作成しています。IntSlider の `values` 属性などの値の設定は別の所で行います。

* __3 ~ 6 行目__：リプレイボタンの幅を 50 ピクセルに変更する
* __7 行目__：IntSlider を 200 ピクセルの幅で作成し、`slider` 属性に代入する

```python
 1  def create_widgets(self):
元と同じなので省略
 2      # リプレイのボタンとスライダーを作成する
 3      self.first_button = self.create_button("<<", 50)
 4      self.prev_button = self.create_button("<", 50)
 5      self.next_button = self.create_button(">", 50)
 6      self.last_button = self.create_button(">>", 50)     
 7      self.slider = widgets.IntSlider(layout=widgets.Layout(width="200px"))
 8      # ゲーム盤の画像を表す figure を作成する
 9      self.create_figure()       
10    
11  Marubatsu_GUI.create_widgets = create_widgets
```

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

```python
def create_widgets(self):
    # AI を選択する Dropdown を作成する
    self.create_dropdown()
    # 変更、リセットボタンを作成する
    self.change_button = self.create_button("変更", 100)
    self.reset_button = self.create_button("リセット", 100)
    # リプレイのボタンとスライダーを作成する
    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()       
    
Marubatsu_GUI.create_widgets = create_widgets
```
</details>

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

```diff_python
def create_widgets(self):
元と同じなので省略
-   self.first_button = self.create_button("<<", 100)
+   self.first_button = self.create_button("<<", 50)
-   self.prev_button = self.create_button("<", 100)
+   self.prev_button = self.create_button("<", 50)
-   self.next_button = self.create_button(">", 100)
+   self.next_button = self.create_button(">", 50)
-   self.last_button = self.create_button(">>", 100) 
+   self.last_button = self.create_button(">>", 50) 
+   self.slider = widgets.IntSlider(layout=widgets.Layout(width="200px"))
    # ゲーム盤の画像を表す figure を作成する
    self.create_figure()       
    
Marubatsu_GUI.create_widgets = create_widgets
```
</details>

下記は `display_widgets` を修正するプログラムです。

* __5 行目__：`hbox2` に登録するウィジェットの最後に `self.slider` を追加する

```python
1  def display_widgets(self):
2      # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
3      hbox1 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button])
4      # リプレイ機能のボタンを横に配置した HBox を作成する
5      hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
6      # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
7      display(widgets.VBox([hbox1, hbox2])) 
8    
9  Marubatsu_GUI.display_widgets = display_widgets
```

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

```python
def display_widgets(self):
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
    hbox1 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
    hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
    # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2])) 
    
Marubatsu_GUI.display_widgets = display_widgets
```
</details>

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

```diff_python
def display_widgets(self):
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
    hbox1 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
-   hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button]) 
+   hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
    # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2])) 
    
Marubatsu_GUI.display_widgets = display_widgets
```
</details>

上記の修正後に、下記のプログラムで `gui_play` を実行すると、実行結果のようにリプレイボタンの右に IntSlider が表示されるようになります。

```python
gui_play()
```

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/3d20c012-f090-d53f-49b1-c82992ea38dd.png" width="200">

## IntSlider の設定の変更

IntSlider の表示ができたので、次は __IntSlider の設定を行うプログラム__ を記述する必要があります。その処理は、他のリプレイボタンの設定の処理と同時に行えば良いので、`update_widgets_status` メソッドの中で、下記のプログラムのように記述します。なお、最小値を表す `min` 属性と、選択できる数値の間隔を表す `step` 属性の値は、デフォルト値のままで良いので設定する必要はありません。

* __3 行目__：IntSlider の現在値を、現在の手数に設定する
* __4 行目__：IntSlider の最大値を、最後の手数を表す __`records` の要素の数 - 1__ に設定する[^3]

```python
1  def update_widgets_status(self):
元と同じなので省略
2      self.set_button_status(self.last_button, self.mb.move_count >= len(self.mb.records) - 1)    
3      self.slider.value = self.mb.move_count
4      self.slider.max = len(self.mb.records) - 1
5    
6  Marubatsu_GUI.update_widgets_status = update_widgets_status
```

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

```python
def update_widgets_status(self):
    # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
    self.set_button_status(self.first_button, self.mb.move_count <= 0)
    self.set_button_status(self.prev_button, self.mb.move_count <= 0)
    self.set_button_status(self.next_button, self.mb.move_count >= len(self.mb.records) - 1)
    self.set_button_status(self.last_button, self.mb.move_count >= len(self.mb.records) - 1)    
    self.slider.value = self.mb.move_count
    self.slider.max = len(self.mb.records) - 1
    
Marubatsu_GUI.update_widgets_status = update_widgets_status
```
</details>

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

```diff_python
def update_widgets_status(self):
元と同じなので省略
    self.set_button_status(self.last_button, self.mb.move_count >= len(self.mb.records) - 1)    
+   self.slider.value = self.mb.move_count
+   self.slider.max = len(self.mb.records) - 1
    
Marubatsu_GUI.update_widgets_status = update_widgets_status
```
</details>

実行結果は省略しますが、上記の修正後に、下記のプログラムで `gui_play` を実行し、着手を行ったり、IntSlider 以外のリプレイボタンをクリックして手数を変更すると、IntSlider の値が正しく変更されることを確認して下さい。

```python
gui_play()
```

## IntSlider のイベントハンドラの定義と結び付け

IntSlider の __イベントハンドラが行う処理は__ 、__ゲームの手数__ を IntSlider の現在値を表す __`value` 属性の値に変更する__ ことです。従って、__`create_event_handler`__ の中で、その処理を行う __`on_slider_changed`__ を下記のプログラムのように定義し、結び付けます。

* __4、5 行目__：IntSlider の値が変更された際に、変更された値を表す `changed["new"]` 手目の局面に移動する処理を行うイベントハンドラを定義する
* __11 行目__：上記のイベントハンドラを IntSlider に結びつける

```python
 1  import math
 2
 3  def create_event_handler(self):
元と同じなので省略
 4      def on_slider_changed(changed):
 5         change_step(changed["new"])
 6
 7      self.first_button.on_click(on_first_button_clicked)
 8      self.prev_button.on_click(on_prev_button_clicked)
 9      self.next_button.on_click(on_next_button_clicked)
10      self.last_button.on_click(on_last_button_clicked)
11      self.slider.observe(on_slider_changed, names="value")
元と同じなので省略              
12    
13  Marubatsu_GUI.create_event_handler = create_event_handler
```

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

```python
import math

def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    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):
        self.mb.restart()
        on_change_button_clicked(b)
        
    # イベントハンドラをボタンに結びつける
    self.change_button.on_click(on_change_button_clicked)
    self.reset_button.on_click(on_reset_button_clicked)   
    
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.draw_board()        

    def on_first_button_clicked(b):
        change_step(0)

    def on_prev_button_clicked(b):
        change_step(self.mb.move_count - 1)

    def on_next_button_clicked(b):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b):
        change_step(len(self.mb.records) - 1)

    def on_slider_changed(changed):
        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.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
            self.mb.play_loop(self)
            
    # fig の画像にマウスを押した際のイベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

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

```diff_python
import math

def create_event_handler(self):
元と同じなので省略
+   def on_slider_changed(changed):
+       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")
元と同じなので省略              
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

実行結果は省略しますが、上記の修正後に、下記のプログラムで `gui_play` を実行し、いくつかの着手を行った後で、IntSlider をドラッグして変更すると、それに合わせて表示される手数が正しく変更されることを確認して下さい。

```python
gui_play()
```

これで、任意の手数の局面に移動する GUI の実装は完了です。

# 待った機能の実装

__自分が行った着手を取り消す「待った」__ は、リプレイ機能の < ボタンを 2 回クリックすることで行うことができますが、__以下のような問題があります__。

* < を 2 回クリックするのは面倒
* < を 2 回クリックした場合は、リプレイの機能によって __過去の局面が表示__ されるので、__ゲーム盤の背景色が水色__ になり、__上部に Replay が表示__ される
* __待ったで行いたい__ のは、__一つ前に行った自分の着手の取り消し__ なので、__リプレイ中の画面になって欲しくない__

そこで、__一つ前の自分の手番に戻し__、その局面を __最後の着手が行われた局面にする__ という処理を行う __待ったボタンを実装__ することにします。

## 待ったボタンの配置と表示

まず、待ったボタンを __どこに配置するかを検討する__ 必要があります。本記事では、__変更とリセットボタンの幅を小さく__ し、__その右に待ったボタンを配置__ することにします。他の配置が良いと思った人は自由に変更して下さい。

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

* __5、6 行目__：変更ボタンとリセットボタンの幅を小さくする。なお、新しく設定したボタンの幅は、試行錯誤して筆者がちょうどよいと感じたものである
* __7 行目__：待ったボタンを 60 ピクセルの幅で作成し、`undo_button` 属性に代入する。なお、__`undo`__ とは、__元に戻す__ という意味の英単語である

```python
1  def create_widgets(self):
2      # AI を選択する Dropdown を作成する
3      self.create_dropdown()
4      # 変更、リセット、待ったボタンを作成する
5      self.change_button = self.create_button("変更", 50)
6      self.reset_button = self.create_button("リセット", 80)
7      self.undo_button = self.create_button("待った", 60)    
元と同じなので省略    
8    
9  Marubatsu_GUI.create_widgets = create_widgets
```

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

```python
def create_widgets(self):
    # 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()       
    
Marubatsu_GUI.create_widgets = create_widgets
```
</details>

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

```diff_python
def create_widgets(self):
    # AI を選択する Dropdown を作成する
    self.create_dropdown()
    # 変更、リセット、待ったボタンを作成する
-   self.change_button = self.create_button("変更", 100)
+   self.change_button = self.create_button("変更", 50)
-   self.reset_button = self.create_button("リセット", 100)
+   self.reset_button = self.create_button("リセット", 80)
+   self.undo_button = self.create_button("待った", 60)    
元と同じなので省略    
    
Marubatsu_GUI.create_widgets = create_widgets
```
</details>

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

* __3 行目__：`hbox1` に登録するウィジェットの最後に `self.undo_button` を追加する

```python
1  def display_widgets(self):
2      # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
3      hbox1 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
4      # リプレイ機能のボタンを横に配置した HBox を作成する
5      hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
6      # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
7      display(widgets.VBox([hbox1, hbox2])) 
8    
9  Marubatsu_GUI.display_widgets = display_widgets
```

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

```python
def display_widgets(self):
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
    hbox1 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
    hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
    # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2])) 
    
Marubatsu_GUI.display_widgets = display_widgets
```
</details>

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

```diff_python
def display_widgets(self):
    # 〇 と × の dropdown とボタンを横に配置した HBox を作成する
-   hbox1 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button])
+   hbox1 = widgets.HBox([self.dropdown_list[0], self.dropdown_list[1], self.change_button, self.reset_button, self.undo_button])
    # リプレイ機能のボタンを横に配置した HBox を作成する
    hbox2 = widgets.HBox([self.first_button, self.prev_button, self.next_button, self.last_button, self.slider]) 
    # hbox1 と hbox2 を縦に配置した VBox を作成し、表示する
    display(widgets.VBox([hbox1, hbox2])) 
    
Marubatsu_GUI.display_widgets = display_widgets
```
</details>

上記の修正後に、下記のプログラムで `gui_play` を実行すると、実行結果のように「待った」ボタンが表示されるようになります。

```python
gui_play()
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/dabf5a1f-d2e6-e497-84f3-2e41b2744918.png" width="200">

## 待ったボタンのイベントハンドラの定義と結び付け

待ったボタンが行う処理は以下の処理です。__最後の 5 行目の条件__ は、__過去の局面で待ったができるのは変__ だと考えて加えた条件ですが、過去の局面でも待ったを行えてよいと思った方はその条件は入れなくても構いません。

* 2 手前の局面に移動する
* 局面を最後の着手が行われた局面にする
* ただし、以下の場合は待ったをかけることはできない
    * 自分が着手を一度も行っていない 0 手目または 1 手目の局面
    * リプレイ中（最後の着手が行われた以外の局面）の場合

従って、イベントハンドラの定義と結び付けは以下のプログラムのように記述します。

* __3 ~ 8 行目__：待ったボタンを押した際に実行するイベントハンドラを定義する
* __4 行目__：2 手目以降で、リプレイ中でないことを判定する
* __5 ~ 8 行目__：`move_count` 属性を 2 減らし、`records` 属性を修正し、`change_step` メソッドを呼び出して 2 手前の局面に戻る処理を行う。なお、8 行目の __`draw_board` を呼び出さないと、画面の描画が更新されない点に注意__ すること（筆者は記述し忘れました）
* __13 行目__：上記のイベントハンドラを、待ったボタンに結びつける

```python
 1  def create_event_handler(self):
元と同じなので省略
 2      # 待ったボタンのイベントハンドラを定義する
 3      def on_undo_button_clicked(b):
 4          if self.mb.move_count >= 2 and self.mb.move_count == len(self.mb.records) - 1:
 5              self.mb.move_count -= 2
 6              self.mb.records = self.mb.records[0:self.mb.move_count+1]
 7              self.mb.change_step(self.mb.move_count)
 8              self.draw_board()
 9       
10      # イベントハンドラをボタンに結びつける
11      self.change_button.on_click(on_change_button_clicked)
12      self.reset_button.on_click(on_reset_button_clicked)   
13      self.undo_button.on_click(on_undo_button_clicked)   
元と同じなので省略
14    
15  Marubatsu_GUI.create_event_handler = create_event_handler
```

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

```python
def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    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):
        self.mb.restart()
        on_change_button_clicked(b)

    # 待ったボタンのイベントハンドラを定義する
    def on_undo_button_clicked(b):
        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)   
    
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.draw_board()        

    def on_first_button_clicked(b):
        change_step(0)

    def on_prev_button_clicked(b):
        change_step(self.mb.move_count - 1)

    def on_next_button_clicked(b):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b):
        change_step(len(self.mb.records) - 1)

    def on_slider_changed(changed):
        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.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
            self.mb.play_loop(self)
            
    # fig の画像にマウスを押した際のイベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

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

```diff_python
def create_event_handler(self):
元と同じなので省略
    # 待ったボタンのイベントハンドラを定義する
+   def on_undo_button_clicked(b):
+       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)   
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

実行結果は省略しますが、上記の修正後に、下記のプログラムで `gui_play` を実行し、「待った」ボタンが正しく機能することを確認して下さい。

```python
gui_play()
```

## 待ったボタンの表示の変更

上記では、__待ったができない場合でも待ったボタンが緑色で表示される__ ので、__待ったができない場合__ に、待ったボタンを __灰色で表示して操作できない__ ようにします。下記は、そのように `update_widgets_status` メソッドを修正したプログラムです。

* __2 行目__：待ったができない場合に待ったボタンを操作できないようにする。なお、待ったができないのは、「2 手目未満」または、「最後の着手の局面でない」場合である

```python
1  def update_widgets_status(self):
2      self.set_button_status(self.undo_button, self.mb.move_count < 2 or self.mb.move_count != len(self.mb.records) - 1)
3      # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
4      self.set_button_status(self.first_button, self.mb.move_count <= 0)
元と同じなので省略
5    
6  Marubatsu_GUI.update_widgets_status = update_widgets_status
```

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

```python
def update_widgets_status(self):
    self.set_button_status(self.undo_button, self.mb.move_count < 2 or self.mb.move_count != len(self.mb.records) - 1)
    # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
    self.set_button_status(self.first_button, self.mb.move_count <= 0)
    self.set_button_status(self.prev_button, self.mb.move_count <= 0)
    self.set_button_status(self.next_button, self.mb.move_count >= len(self.mb.records) - 1)
    self.set_button_status(self.last_button, self.mb.move_count >= len(self.mb.records) - 1)    
    self.slider.value = self.mb.move_count
    self.slider.max = len(self.mb.records) - 1
    
Marubatsu_GUI.update_widgets_status = update_widgets_status
```
</details>

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

```diff_python
def update_widgets_status(self):
+   self.set_button_status(self.undo_button, self.mb.move_count < 2 or self.mb.move_count != len(self.mb.records) - 1)
    # 0 手目と最後の着手を行った局面で、特定のリプレイに関するボタンを操作できないようにする
    self.set_button_status(self.first_button, self.mb.move_count <= 0)
元と同じなので省略
    
Marubatsu_GUI.update_widgets_status = update_widgets_status
```
</details>

上記の修正後に、下記のプログラムで `gui_play` を実行すると、実行結果のように、ゲーム開始時の局面では「待った」ボタンが灰色で表示され、操作できないようになります。また、着手を行ったり、リプレイボタンをクリックし、待ったを行える状況でのみ、待ったボタンが緑色で表示されることを確認して下さい。

```python
gui_play()
```

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/e5f3f6e1-544d-4d45-8c7f-a2e1786eca63.png" width="200">

# キー入力による操作

ここまでで実装した〇×ゲームの GUI は、マウスによって操作を行うというものでしたが、__キー入力による操作も行えると便利__ です。そこで、本記事では〇×ゲームの操作を、下記の表のキーで行えるようにすることにします。下記は、__テンキーとカーソルキーで操作を行える__ ようにしたものですが、他のキーの方が良いと思った方は自由に変更して下さい。

| キー | 行われる操作 |
|:-:|:--|
| 1 ~ 9  | テンキーをゲーム盤のマスと見立て、対応するマスに着手を行う  |
| ↑  | ゲーム開始時まで戻す（<< ボタンに対応） |
| ←  | 一手戻す（< ボタンに対応） |
| →  | 一手進める（> ボタンに対応） |
| ↓  | 最後に行われた着手まで進める（>> ボタンに対応） |
| 0  | 待ったを行う  |
| Enter  | ゲームをリセットする  |

## キーイベントに対するイベントハンドラの定義と結び付け

Matplotlib の Figure は、[以前の記事](https://qiita.com/ysgeso/items/1920c68f5a1e8a998796#mpl_connect-%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89)で説明したように、`mpl_connect` を使ってイベントハンドラを結び付けることができます。

どのキーが押されたかは、イベントハンドラの仮引数に代入されるオブジェクトの `key` 属性に代入されます。下記は、キーが押された場合に、どのキーが押されたかを `print` で表示するイベントハンドラの定義です。

```python
def on_key_press(event):
    print(event.key)
```

キーが押された場合のイベントハンドラを結び付けるには、下記のプログラムのように、`mpl_connect` の最初の実引数に `"key_press_event"` を記述します。

```python
fig.canvas.mpl_connect("key_press_event", on_mouse_down)    
```

下記は、`create_event_handler` に上記のイベントハンドラの定義と、Figure への結び付けを記述するプログラムです。

* __2 行目__：キーを押した時に呼び出されるイベントハンドラを定義する
* __7 行目__：Figure と上記のイベントハンドラを結び付ける

```python
1  def create_event_handler(self):
元と同じなので省略
2      def on_key_press(event):
3          print(event.key)            
4            
5      # fig の画像イベントハンドラを結び付ける
6      self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
7      self.fig.canvas.mpl_connect("key_press_event", on_key_press)     
8    
9  Marubatsu_GUI.create_event_handler = create_event_handler
```

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

```python
def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    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):
        self.mb.restart()
        on_change_button_clicked(b)

    # 待ったボタンのイベントハンドラを定義する
    def on_undo_button_clicked(b):
        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)   
    
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.draw_board()        

    def on_first_button_clicked(b):
        change_step(0)

    def on_prev_button_clicked(b):
        change_step(self.mb.move_count - 1)

    def on_next_button_clicked(b):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b):
        change_step(len(self.mb.records) - 1)

    def on_slider_changed(changed):
        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.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
            self.mb.play_loop(self)

    def on_key_press(event):
        print(event.key)            
            
    # fig の画像イベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    self.fig.canvas.mpl_connect("key_press_event", on_key_press)     
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

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

```diff_python
def create_event_handler(self):
元と同じなので省略
+   def on_key_press(event):
+       print(event.key)            
            
    # fig の画像イベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
+   self.fig.canvas.mpl_connect("key_press_event", on_key_press)     
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

上記の修正後に、下記のプログラムで `gui_play` を実行し、ゲーム盤の画像の上でマウスをクリックして __Figure を選択後にキーを押す__ と、実行結果のように __押したキーに対応する文字列が表示されます__。下記は、↑、0、Enter の順でキーを押した場合の実行結果です。なお、ゲーム盤の画像は前と同じなので省略します。

```python
gui_play()
```

実行結果

```
up
0
enter
```

## Figure に対するキー入力の処理の注意点

matplotlib の Figure に対するキー入力処理に関しては、以下の点に注意する必要があります。

### Figure を選択状態にする必要がある

Figure に結びつけたイベントハンドラは、__Figure が選択状態の場合でしか呼び出されません__。従って、下記の場合は、キーを押しても対応する文字列は表示されません。

* `gui_play()` の実行直後。JupyterLab のセルが選択状態になるため
* リセットボタンなどのボタンのクリック後。クリックしたボタンが選択状態になるため

なお、Figure をクリックして選択状態にしても Figure の表示は変化しませんが、その状態でキーを押すと、下図のように Figure の枠がオレンジ色で表示されるようになるようです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/a8957701-8a98-c6ba-79bb-c410aa871d8c.png" width="150">

:::note
条件がよくわからないのですが、Figure をクリックすることでも上図のように Figure の枠がオレンジ色で表示される場合もあるようです。条件についてご存じの方がいればコメントで教えて頂けると嬉しいです。
:::

なお、__`gui_play()` を実行した直後__ は、ゲーム盤の画像の __Figure が選択状態になっていない__ ため、__キーで操作を行うため__ には、一度 __Figure をクリックして選択する必要があります__。JupyterLab のセルで `gui_play()` を実行後に、自動的に Figure を選択状態にする方法を探してみたのですが今の所見つけられていません。ご存じの方がいればコメントで知らせて頂ければうれしいです。

### 初期設定で登録されているショートカットキーによる操作

Figure には、__初期設定でいくつかのキー__ が __ショートカットキーとして特定の操作に割り当てられています__。例えば、Figure を選択中に `s` キーを押すと、Figure を画像として保存するパネルが表示されます。従って、__ショートカットキーに割り当てられているキーを〇×ゲームの操作に割り当ててしまう__ と、__意図しない処理が行われる__ ことになります。どのようなキーがどのような操作に対応しているかについては、下記のリンク先を参照して下さい。

https://matplotlib.org/stable/users/explain/figure/interactive.html#navigation-keyboard-shortcuts

先程〇×ゲームの操作に割り当てたキーのうち、__← と → は Back と Forward という操作に割り当てられています__。Back と Forward の操作は、Figure に対して行った Zoom（拡大・縮小）や Pan（移動）などの操作を前後に移動するという操作なので、__それらの操作が行われていない場合は何も行われません__。また、__それ以外のキーはショートカットキーに割り当てられていない__ ので、__先程の表のキーしか押さなければ問題は発生しません__。そのため、ショートカットキーに対する対策を行わなくても問題はありませんが、間違ってショートカットキーに割り当てられたキーを押してしまうと、下記のような予期せぬ動作が行われる場合があるので、ショートカットキーの操作を禁止することにします。

例えば、Figure をクリックして選択後に p キーを押すと、下図のように、Figure の内容をマウスでドラッグして移動することができるようになります。また、下図の状態で ← キーを押すと、Figure の表示が移動が行われる前の状態に戻ります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3524802/a84c8f19-5d9c-487b-46cc-8b5496dbd1f3.png" width="200">

### matplotlib の設定の変更

先程のショートカットキーの操作は、__matplotlib の設定を変更することで禁止できます__。matplotlib の設定を変更する方法はいくつかありますが、本記事ではその中で __プログラムで設定を変更する方法__ を紹介します。他の方法については下記のリンク先を参照して下さい。

https://matplotlib.org/stable/users/explain/customizing.html#customizing

__matplotlib__ の設定は、インポートした __matplotlib の `rcParams` 属性に dict の形式で保存__ されており、設定できる属性は上記のリンク先のページの下部に一覧で示されています。

下記は、上記のリンク先に示されている属性（先頭の # は名前ではありません）の中から __ショートカットキーの設定に関する属性__ の部分を抜き出したものです。これらの属性には、対応するショートカットキーを表す文字列を list の形式で保存します。従って、__空の list を代入することで、対応するショートカットキーの操作が禁止__ されます。

```
#keymap.fullscreen: f, ctrl+f   # toggling
#keymap.home: h, r, home        # home or reset mnemonic
#keymap.back: left, c, backspace, MouseButton.BACK  # forward / backward keys
#keymap.forward: right, v, MouseButton.FORWARD      # for quick navigation
#keymap.pan: p                  # pan mnemonic
#keymap.zoom: o                 # zoom mnemonic
#keymap.save: s, ctrl+s         # saving current figure
#keymap.help: f1                # display help about active tools
#keymap.quit: ctrl+w, cmd+w, q  # close the current figure
#keymap.quit_all:               # close all figures
#keymap.grid: g                 # switching on/off major grids in current axes
#keymap.grid_minor: G           # switching on/off minor grids in current axes
#keymap.yscale: l               # toggle scaling of y-axes ('log'/'linear')
#keymap.xscale: k, L            # toggle scaling of x-axes ('log'/'linear')
#keymap.copy: ctrl+c, cmd+c     # copy figure to clipboard
```

例えば、下記のプログラムを実行すると、Figure を保存するパネルを表示する __ショートカットキーが無くなる__ ため、__禁止されたことになります__。

```python
import matplotlib as mlp

mlp.rcParams["keymap.save"] = []
```

上記のプログラムを実行後に、ゲーム盤の画像の上をクリックして Figure を選択し、s キーを押しても何も起きなくなることを確認して下さい。

上記のすべての属性に空の list を代入する処理を、一つずつ記述するのは大変です。上記の属性はすべて `keymap.` で始まるので、下記のプログラムのように、__その後ろに続く属性の名前を要素とする list を作成__ し、__繰り返し処理__ を使って __すべてのショートカットキー操作を禁止__ することができます。

```python
attrs = [ "fullscreen", "home", "back", "forward", "pan", "zoom", "save", "help",
         "quit", "quit_all", "grid", "grid_minor", "yscale", "xscale", "copy"]
for attr in attrs:
    mlp.rcParams[f"keymap.{attr}"] = []
```

そこで、`Marubatsu_GUI` クラスに上記の処理を行う `disable_shortcutkeys` というメソッドを下記のプログラムのように定義します。なお、このメソッドは `Marubatsu_GUI` クラスのインスタンスの情報を利用しないので、静的メソッドとして定義する事にします。

```python
@staticmethod
def disable_shortcutkeys(self):
    attrs = [ "fullscreen", "home", "back", "forward", "pan", "zoom", "save", "help",
             "quit", "quit_all", "grid", "grid_minor", "yscale", "xscale", "copy"]
    for attr in attrs:
        mlp.rcParams[f"keymap.{attr}"] = []

Marubatsu_GUI.disable_shortcutkeys = disable_shortcutkeys
```

次に、下記のプログラムの 2 行目ように、`__init__` メソッドに上記の関数を呼び出す処理を追加します。

```python
1  def __init__(self, mb, ai_dict=None, size=3):
元と同じなので省略
2      self.disable_shortcutkeys()
3      self.create_widgets()
4      self.create_event_handler()
5      self.display_widgets() 
6        
7  Marubatsu_GUI.__init__ = __init__
```

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

```python
def __init__(self, mb, ai_dict=None, size=3):
    # ai_dict が None の場合は、空の list で置き換える
    if ai_dict is None:
        ai_dict = {}

    self.mb = mb
    self.ai_dict = ai_dict
    self.size = size
    
    # %matplotlib widget のマジックコマンドを実行する
    get_ipython().run_line_magic('matplotlib', 'widget')
        
    self.disable_shortcutkeys()
    self.create_widgets()
    self.create_event_handler()
    self.display_widgets() 
        
Marubatsu_GUI.__init__ = __init__
```
</details>

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

```diff_python
def __init__(self, mb, ai_dict=None, size=3):
元と同じなので省略
    self.disable_shortcutkeys()
    self.create_widgets()
    self.create_event_handler()
    self.display_widgets() 
        
Marubatsu_GUI.__init__ = __init__
```
</details>

実行結果は省略しますが、上記の修正後に、下記のプログラムで `gui_play` を実行し、ゲーム盤の画像の上でマウスをクリックして __Figure を選択後にキーを押す__ と、どのキーを押してもそのキーに対応する文字が表示され、その際に余計な処理が実行されなくなります。

```python
gui_play()
```

なお、__`rcParams` の属性に値を代入__ する方法で変更した __matplotlib の設定__ は、__JupyterLab を再起動すると無効になります__。永久に matplotlib の設定を変更したい場合は、先程紹介したリンク先の記事に記されている、設定ファイルを変更方法で行うことができます。

## 押されたキーに対応する処理の記述

押されたキーに対応する処理を記述するためには、先程の表の __それぞれのキーが押された時__ に、__どのような文字列が `event.key` に代入されるかを知る__ 必要がありますが、それは __実際にそれぞれのキーを押して表示される文字列を見る__ ことで確認できます。

### それぞれのキーに対応する `event.key` の値

下記はそれぞれのキーに対応する文字列です。

| キー | `event.key` の値 |
|:-:|:--|
| 1 ~ 9  | `"1"` ~ `"9"`  |
| ↑  |`"up"` |
| ←  | `"left"` |
| →  | `"right"` |
| ↓  | `"down"` |
| 0  | `"0"`  |
| Enter  | `"enter"`  |

### ← キーに対応する処理の記述方法

1 手前に戻る __← キーの処理__ は、__`on_prev_button_clicked` のイベントハンドラと同じ__ です。そのため、その処理は __`on_key_press`__ 内で下記のプログラムのように記述できます。

```python
def on_key_press(event):
    if event.key == "left":
        on_prev_button_clicked(None)
```

なお、__ボタンをクリックした際に実行するイベントハンドラ__ には、クリックされたボタンのウィジェットが代入される __仮引数が必要__ なので、__`on_prev_button_clicked` を呼び出す際__ にはその仮引数に対応する __実引数を記述する必要があります__。__`on_prev_button_clicked`__ は、下記のプログラムのように __仮引数 `b` の情報を利用しない__ ので、__実引数には何を記述しても構いません__。そこで、上記のプログラムでは実引数に `None` を記述しました。

```python
def on_prev_button_clicked(b):
    change_step(self.mb.move_count - 1)
```

また、__`on_prev_button_clicked` の仮引数__ を下記のプログラムのように、何らかのデフォルト値を持つ __デフォルト引数として定義__ する事で、__`on_prev_button_clicked` を呼び出す際に実引数を省略できる__ ようになるので、本記事では下記のように修正することにします。

```python
def on_prev_button_clicked(b=None):
    change_step(self.mb.move_count - 1)
```

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

```diff_python
-def on_prev_button_clicked(b):
+def on_prev_button_clicked(b=None):
    change_step(self.mb.move_count - 1)
```
</details>

:::note warn
このように、__イベントハンドラとして定義した関数__ を、__通常の関数と同様の方法で呼び出して利用__ することができますが、その際には、イベントハンドラの __仮引数の扱いに注意__ して呼び出す必要があります。
:::

### リセット、待った、リプレイ機能に対するキー入力の処理の記述

上記の方法で、下記のプログラムのように、リセット、待った、リプレイ機能に対するキー入力の処理を記述することができます。

* __3、5、6、9、12、15 行目__：ボタンをクリックした時に実行するイベントハンドラの仮引数を `None` をデフォルト値とするデフォルト引数にする
* __18 ~ 29 行目__：それぞれのキーに対応する処理を行うイベントハンドラを呼び出す

```python
 1  def create_event_handler(self):
元と同じなので省略
 2      # リセットボタンのイベントハンドラを定義する
 3      def on_reset_button_clicked(b=None):
元と同じなので省略
 4      # 待ったボタンのイベントハンドラを定義する
 5      def on_undo_button_clicked(b=None):
元と同じなので省略
 6      def on_first_button_clicked(b=None):
 7          change_step(0)
 8
 9      def on_prev_button_clicked(b=None):
10          change_step(self.mb.move_count - 1)
11
12      def on_next_button_clicked(b=None):
13          change_step(self.mb.move_count + 1)
14        
15      def on_last_button_clicked(b=None):
16          change_step(len(self.mb.records) - 1)
元と同じなので省略
17      def on_key_press(event):
18          if event.key == "up":
19              on_first_button_clicked()
20          elif event.key == "left":
21              on_prev_button_clicked()
22          elif event.key == "right":
23              on_next_button_clicked()
24          elif event.key == "down":
25              on_last_button_clicked()
26          elif event.key == "0":
27              on_undo_button_clicked()
28          elif event.key == "enter":
29              on_reset_button_clicked()
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler
```

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

```python
def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    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):
        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)   
    
    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):
        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.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
            self.mb.play_loop(self)

    def on_key_press(event):
        if event.key == "up":
            on_first_button_clicked()
        elif event.key == "left":
            on_prev_button_clicked()
        elif event.key == "right":
            on_next_button_clicked()
        elif event.key == "down":
            on_last_button_clicked()
        elif event.key == "0":
            on_undo_button_clicked()
        elif event.key == "enter":
            on_reset_button_clicked()
            
    # fig の画像イベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    self.fig.canvas.mpl_connect("key_press_event", on_key_press)     
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

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

```diff_python
def create_event_handler(self):
元と同じなので省略
    # リセットボタンのイベントハンドラを定義する
-   def on_reset_button_clicked(b):
+   def on_reset_button_clicked(b=None):
元と同じなので省略
    # 待ったボタンのイベントハンドラを定義する
-   def on_undo_button_clicked(b):
+   def on_undo_button_clicked(b=None):
元と同じなので省略
-   def on_first_button_clicked(b):
+   def on_first_button_clicked(b=None):
        change_step(0)

-   def on_prev_button_clicked(b):
+   def on_prev_button_clicked(b=None):
        change_step(self.mb.move_count - 1)

-   def on_next_button_clicked(b):
+   def on_next_button_clicked(b=None):
        change_step(self.mb.move_count + 1)
        
-   def on_last_button_clicked(b):
+   def on_last_button_clicked(b=None):
        change_step(len(self.mb.records) - 1)
元と同じなので省略
    def on_key_press(event):
-       print(event.key)
+       if event.key == "up":
+           on_first_button_clicked()
+       elif event.key == "left":
+           on_prev_button_clicked()
+       elif event.key == "right":
+           on_next_button_clicked()
+       elif event.key == "down":
+           on_last_button_clicked()
+       elif event.key == "0":
+           on_undo_button_clicked()
+       elif event.key == "enter":
+           on_reset_button_clicked()
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

実行結果は省略しますが、上記の修正後に、下記のプログラムで `gui_play` を実行し、ゲーム盤の画像の上でマウスをクリックして __Figure を選択する__ と、キー操作によってリセット、待った、リプレイ機能を呼び出すことができるようになります。

```python
gui_play()
```

### プログラムの改良

上記のプログラムで、それぞれのキーに対応する処理に対して、if 文の条件式を書くのが面倒だと思った人はいないでしょうか。上記のプログラムは、下記のような __dict を使って__ 下記のプログラムのように __簡潔に記述__ することができます。

__dict のキー__：〇×ゲームの操作を行うキー[^4]を表す文字列
__dict のキーの値__：押されたキー[^4]に対応する処理を行う関数

* __3 ~ 10__：上記の dict を `keymap`[^5] に代入する
* __11 行目__：__押されたキーを表す文字列__ が、__`keymap` のキーに存在するか__ どうかを判定する
* __12 行目__：存在する場合は、__`keymap[event.key]`__ そのキーに __対応する処理を行う関数が代入__ されているので、__後ろに `()` を記述__ することで __その関数を呼び出す__

```python
 1  def create_event_handler(self):
元と同じなので省略
 2      def on_key_press(event):
 3          keymap = {
 4              "up": on_first_button_clicked,
 5              "left": on_prev_button_clicked,
 6              "right": on_next_button_clicked,
 7              "down": on_last_button_clicked,
 8              "0": on_undo_button_clicked,
 9              "enter": on_reset_button_clicked,            
10           }
11           if event.key in keymap:
12               keymap[event.key]()
元と同じなので省略
13    
14  Marubatsu_GUI.create_event_handler = create_event_handler
```

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

```python
def create_event_handler(self):
    # 変更ボタンのイベントハンドラを定義する
    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):
        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)   
    
    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):
        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.draw_board()
            # 次の手番の処理を行うメソッドを呼び出す
            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]()
            
    # fig の画像イベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    self.fig.canvas.mpl_connect("key_press_event", on_key_press)     
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

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

```diff_python
def create_event_handler(self):
元と同じなので省略
    def on_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]()
元と同じなので省略
    
Marubatsu_GUI.create_event_handler = create_event_handler
```
</details>

実行結果は省略しますが、上記の修正後に、下記のプログラムで `gui_play` を実行し、正しい処理が行われることを確認して下さい。

```python
gui_play()
```

まだ着手を行うキーの処理の実装を行っていませんが、長くなりましたので今回の記事はここまでにします。余裕がある方は、着手を行うキーの実装方法について考えてみて下さい。

# 今回の記事のまとめ

今回の記事では、以下の実装を行いました。

* リプレイ中の局面であることを明確にする
* IntSlider を使って、任意の手数の局面に移動する機能の実装
* 待った機能の実装
* キー操作による GUI の機能の実装の一部

次回の記事では、キー操作による GUI の機能の実装を完了した後でリプレイ機能以外の改良について紹介し、GUI の実装を完了する予定です。

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

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

https://github.com/ysgeso/marubatsu/blob/master/084/marubatsu.ipynb

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

https://github.com/ysgeso/marubatsu/blob/master/084/marubatsu_new.py

# 次回の記事

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

[^1]:__observe__ は、__監視する__、観察するという意味の英単語です
[^2]:キーワード引数 __`names` を省略__ すると、ウィジェットの __すべての属性が対象になります__。また、list で指定することで、複数の属性の名前を指定することもできます
[^3]:1 を引くのは、__`records` 属性の要素__ に、__0 手目の着手のデータが代入されている__ ためです
[^4]:このキーは __キーボードのキー__ の事です
[^5]:map は一般的には地図という意味を表しますが、__図表__ という意味も持ちます。`keymap` は、キーとそのキーに対応する関数を表す __対応表__ であることからそのような名前を付けました