Pythonのtkinterで簡易タイピングゲームを作ってみた

# はじめに
本記事ではPythonを使ったアプリケーションを開発していきます。
GUIライブラリには標準ライブラリのtkinterを採用しています。
本記事を読むことである程度pythonを理解している方であれば、ある程度tkinterの使い方を覚えることができます。

# Tkinterとは
Pythonの標準ライブラリの1つです。
GUIアプリケーションを構築するためのライブラリです。
シンプルな文法と起動の速さが特徴のGUIライブラリです。

# ウィンドウの作成
まずは基礎となるウィンドウを作っていきましょう。

```py
import tkinter as tk

if __name__ == "__main__":
    root = tk.Tk()
    root.mainloop()
```

![01_ウィンドウ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175546/806577b7-aec1-f950-a7a0-6a3c04e3e0ac.png)

# 画面の作成
次にオブジェクト指向を意識して書くためにFrameクラスを継承したクラスを作成していきます。
create_widgets()内で必要な部品(ウィジェット)を生成し、配置して画面を作っていきます。

```py

import tkinter as tk

class Application(tk.Frame):
    def __init__(self, master):
        super().__init__(master)
        self.pack()

        master.geometry("300x200")
        master.title("タイピングゲーム！")

        self.create_widgets()

    # ウィジェットの生成と配置
    def create_widgets(self):
        self.q_label = tk.Label(self, text="お題：", font=("",20))
        self.q_label.grid(row=0, column=0)
        self.q_label2 = tk.Label(self, text="tkinter", width=5, anchor="w", font=("",20))
        self.q_label2.grid(row=0, column=1)
        self.ans_label = tk.Label(self, text="解答：", font=("",20))
        self.ans_label.grid(row=1, column=0)
        self.ans_label2 = tk.Label(self, text="tkinter", width=5, anchor="w", font=("",20))
        self.ans_label2.grid(row=1, column=1)
        self.result_label = tk.Label(self, text="正否ラベル", font=("",20))
        self.result_label.grid(row=2, column=0, columnspan=2)

if __name__ == "__main__":
    root = tk.Tk()
    Application(master=root)
    root.mainloop()
```

![01.ウィジェット配置.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175546/378489b0-da06-9312-a80e-e2e4208041c1.png)

# キー入力のイベント処理の設定
次にキー入力処理の部分を作成していきます。
まずは文字が入力された際に解答欄に値を追加していく処理を実装します。

まずは、先ほどの解答欄の値の初期値は空文字にしておきます。

```py
self.ans_label2 = tk.Label(self, text="", width=5, anchor="w", font=("",20))
```

次にキー入力時のイベント処理用のメソッドを作ります。
入力されたキーの情報はevent.keysymに格納されています。
例えばAキーを押すと「a」という情報が格納されています。

```py
# キー入力時のイベント処理
def type_event(self, event):
    self.ans_label2["text"] += event.keysym
```

最後に作成したイベント処理をTkクラスのインスタンスにbind()を使って紐づけます。

```py
# Tkインスタンスに対してキーイベント処理を実装
self.master.bind("<KeyPress>", self.click_event)
```

ここまでをまとめると下記の通りになります。

```py
import tkinter as tk

class Application(tk.Frame):
    def __init__(self, master):
        super().__init__(master)
        self.pack()

        master.geometry("300x200")
        master.title("タイピングゲーム！")

        self.create_widgets()

        # Tkインスタンスに対してキーイベント処理を実装
        self.master.bind("<KeyPress>", self.type_event)

    # ウィジェットの生成と配置
    def create_widgets(self):
        self.q_label = tk.Label(self, text="お題：", font=("",20))
        self.q_label.grid(row=0, column=0)
        self.q_label2 = tk.Label(self, text="tkinter", width=5, anchor="w", font=("",20))
        self.q_label2.grid(row=0, column=1)
        self.ans_label = tk.Label(self, text="解答：", font=("",20))
        self.ans_label.grid(row=1, column=0)
        self.ans_label2 = tk.Label(self, text="", width=5, anchor="w", font=("",20))
        self.ans_label2.grid(row=1, column=1)
        self.result_label = tk.Label(self, text="正否ラベル", font=("",20))
        self.result_label.grid(row=2, column=0, columnspan=2)

    # キー入力時のイベント処理
    def type_event(self, event):
        self.ans_label2["text"] += event.keysym

if __name__ == "__main__":
    root = tk.Tk()
    Application(master=root)
    root.mainloop()
```

![02_イベント処理実装.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175546/92be4a25-6c2a-6fb6-df95-eaa612b6b89a.gif)


# 答え合わせ処理の実装
今回は「Enterキー」を押したら正解判定をするという仕様で作成していきます。
入力されたキーの判定はevent.keysymの値を使って判定していきます。
「Enterキー」を押すとevent.keysymには"Return"が格納されるのでそれをifで判定していきます。
ちなみに「keysym」は「キーシンボル」の略です。

```py
    # キー入力時のイベント処理
    def type_event(self, event):
        # 入力値がEnterの場合は答え合わせ
        if event.keysym == "Return":
            if self.q_label2["text"] == self.ans_label2["text"]:
                self.result_label.configure(text="正解！", fg="red")
            else:
                self.result_label.configure(text="残念！", fg="blue")

            # 解答欄をクリア
            self.ans_label2.configure(text="")

        else:
            # 入力値がEnter以外の場合は文字入力としてラベルに追記する
            self.ans_label2["text"] += event.keysym
```

![03_正否判定処理.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175546/68d2ee27-76bf-d8e3-e439-e9cf45ea3b5a.gif)

# 連続出題機能の実装
正否判定後に次の問題へ進む機能を実装します。

まずは問題のリストを作成します。

```py

QUESTION = ["tkinter", "geometry", "widgets", "messagebox", "configure", 
            "label", "column", "rowspan", "grid", "init"]
```

次に、コンストラクタ内で問題数を管理するindex用の変数を用意します。

```py

# 問題数インデックス
self.index = 0
```

最後に、イベント処理内で次の問題にラベルの値を書き換える処理を追記します。

```py
# 次の問題を出題
self.index += 1
self.q_label2.configure(text=QUESTION[self.index])
```

ここまでをまとめると下記の通りです。

```py
import tkinter as tk

QUESTION = ["tkinter", "geometry", "widgets", "messagebox", "configure", 
            "label", "column", "rowspan", "grid", "init"]

class Application(tk.Frame):
    def __init__(self, master):
        super().__init__(master)
        self.pack()

        master.geometry("300x200")
        master.title("タイピングゲーム！")

        # 問題数インデックス
        self.index = 0

        self.create_widgets()

        # Tkインスタンスに対してキーイベント処理を実装
        self.master.bind("<KeyPress>", self.type_event)

    # ウィジェットの生成と配置
    def create_widgets(self):
        self.q_label = tk.Label(self, text="お題：", font=("",20))
        self.q_label.grid(row=0, column=0)
        self.q_label2 = tk.Label(self, text=QUESTION[self.index], width=10, anchor="w", font=("",20))
        self.q_label2.grid(row=0, column=1)
        self.ans_label = tk.Label(self, text="解答：", font=("",20))
        self.ans_label.grid(row=1, column=0)
        self.ans_label2 = tk.Label(self, text="", width=10, anchor="w", font=("",20))
        self.ans_label2.grid(row=1, column=1)
        self.result_label = tk.Label(self, text="正否ラベル", font=("",20))
        self.result_label.grid(row=2, column=0, columnspan=2)

    # キー入力時のイベント処理
    def type_event(self, event):
        # 入力値がEnterの場合は答え合わせ
        if event.keysym == "Return":
            if self.q_label2["text"] == self.ans_label2["text"]:
                self.result_label.configure(text="正解！", fg="red")
            else:
                self.result_label.configure(text="残念！", fg="blue")

            # 解答欄をクリア
            self.ans_label2.configure(text="")

            # 次の問題を出題
            self.index += 1
            self.q_label2.configure(text=QUESTION[self.index])

        else:
            # 入力値がEnter以外の場合は文字入力としてラベルに追記する
            self.ans_label2["text"] += event.keysym

if __name__ == "__main__":
    root = tk.Tk()
    Application(master=root)
    root.mainloop()

```

![04_連続解答.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175546/93c90279-c23d-7fa6-3169-e2913af9e677.gif)

# バックスペース機能の実装
次にバックスペース機能を実装していきます。
event.keysymが「BackSpace」の場合の分岐を追加して実装します。

```py
    # キー入力時のイベント処理
    def type_event(self, event):
        # 入力値がEnterの場合は答え合わせ
        if event.keysym == "Return":
            if self.q_label2["text"] == self.ans_label2["text"]:
                self.result_label.configure(text="正解！", fg="red")
            else:
                self.result_label.configure(text="残念！", fg="blue")

            # 解答欄をクリア
            self.ans_label2.configure(text="")

            # 次の問題を出題
            self.index += 1
            self.q_label2.configure(text=QUESTION[self.index])
        elif event.keysym == "BackSpace":
            text = self.ans_label2["text"]
            self.ans_label2["text"] = text[:-1]
        else:
            # 入力値がEnter以外の場合は文字入力としてラベルに追記する
            self.ans_label2["text"] += event.keysym
```

![05_バックスペース機能.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175546/c74a8503-6415-767e-f0c1-bbba345726e5.gif)


# 結果表示機能の実装
最後まで解答した後にリザルトを表示する機能を実装します。
リザルトはポップアップウィンドウを使って表示します。
また、今回はポップアップを閉じると同時にアプリも強制終了するような仕様にします。

上記処理を実装するために、まずは2つのライブラリをインポートします。

```py
from tkinter import messagebox
import sys
```

また、正解数をカウントしておく変数を用意します。
こちらはコンストラクタ内で初期化するようにしましょう。

```py
# 正解数カウント用
self.correct_cnt = 0
```

最後にイベント処理内の次の問題に進むで問題の最後まで到達しているかを判定し、リザルトのポップアップ(messagebox)を表示する処理を呼び出します。
sys.exit(0)はプログラムを強制終了するメソッドです。

```py
# 次の問題を出題
self.index += 1
if self.index == len(QUESTION):
    self.q_label2.configure(text="終了！")
    messagebox.showinfo("リザルト", f"あなたのスコアは{self.correct_cnt}/{self.index}問正解です。")
    sys.exit(0)
self.q_label2.configure(text=QUESTION[self.index])
```

ここまでをまとめると下記の通りです。

```py
import tkinter as tk
from tkinter import messagebox
import sys

QUESTION = ["tkinter", "geometry", "widgets", "messagebox", "configure", 
            "label", "column", "rowspan", "grid", "init"]

class Application(tk.Frame):
    def __init__(self, master):
        super().__init__(master)
        self.pack()

        master.geometry("300x200")
        master.title("タイピングゲーム！")

        # 問題数インデックス
        self.index = 0

        # 正解数カウント用
        self.correct_cnt = 0

        self.create_widgets()

        # Tkインスタンスに対してキーイベント処理を実装
        self.master.bind("<KeyPress>", self.type_event)

    # ウィジェットの生成と配置
    def create_widgets(self):
        self.q_label = tk.Label(self, text="お題：", font=("",20))
        self.q_label.grid(row=0, column=0)
        self.q_label2 = tk.Label(self, text=QUESTION[self.index], width=10, anchor="w", font=("",20))
        self.q_label2.grid(row=0, column=1)
        self.ans_label = tk.Label(self, text="解答：", font=("",20))
        self.ans_label.grid(row=1, column=0)
        self.ans_label2 = tk.Label(self, text="", width=10, anchor="w", font=("",20))
        self.ans_label2.grid(row=1, column=1)
        self.result_label = tk.Label(self, text="", font=("",20))
        self.result_label.grid(row=2, column=0, columnspan=2)

    # キー入力時のイベント処理
    def type_event(self, event):
        # 入力値がEnterの場合は答え合わせ
        if event.keysym == "Return":
            if self.q_label2["text"] == self.ans_label2["text"]:
                self.result_label.configure(text="正解！", fg="red")
                self.correct_cnt += 1
            else:
                self.result_label.configure(text="残念！", fg="blue")

            # 解答欄をクリア
            self.ans_label2.configure(text="")

            # 次の問題を出題
            self.index += 1
            if self.index == len(QUESTION):
                self.q_label2.configure(text="終了！")
                messagebox.showinfo("リザルト", f"あなたのスコアは{self.correct_cnt}/{self.index}問正解です。")
                sys.exit(0)
            self.q_label2.configure(text=QUESTION[self.index])

        elif event.keysym == "BackSpace":
            text = self.ans_label2["text"]
            self.ans_label2["text"] = text[:-1]

        else:
            # 入力値がEnter以外の場合は文字入力としてラベルに追記する
            self.ans_label2["text"] += event.keysym

if __name__ == "__main__":
    root = tk.Tk()
    Application(master=root)
    root.mainloop()
```

これで基本的な機能は大体実装できたと思います。
実行結果は下記の通りです。
![06_基本機能完成.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175546/4c1bf27f-8bc5-9061-af54-6fc12d910462.gif)


# おまけ(マルチスレッド処理を使って時間の計測処理を追加)

おまけとしてマルチスレッド処理を使ってリアルタイムで時間も計測できるようにすると下記の通りになります。


![07_タイム計測機能実装.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/175546/d6549005-5711-1a27-b1f8-aaa2adff680c.gif)

ソースコードは下記通りです。

```py
import tkinter as tk
from tkinter import messagebox
import sys
import time
import threading

QUESTION = ["tkinter", "geometry", "widgets", "messagebox", "configure", 
            "label", "column", "rowspan", "grid", "init"]

class Application(tk.Frame):
    def __init__(self, master):
        super().__init__(master)
        self.pack()

        master.geometry("300x200")
        master.title("タイピングゲーム！")

        # 問題数インデックス
        self.index = 0

        # 正解数カウント用
        self.correct_cnt = 0

        self.create_widgets()

        # 経過時間スレッドの開始
        t = threading.Thread(target=self.timer)
        t.start()

        # Tkインスタンスに対してキーイベント処理を実装
        self.master.bind("<KeyPress>", self.type_event)

    # ウィジェットの生成と配置
    def create_widgets(self):
        self.q_label = tk.Label(self, text="お題：", font=("",20))
        self.q_label.grid(row=0, column=0)
        self.q_label2 = tk.Label(self, text=QUESTION[self.index], width=10, anchor="w", font=("",20))
        self.q_label2.grid(row=0, column=1)
        self.ans_label = tk.Label(self, text="解答：", font=("",20))
        self.ans_label.grid(row=1, column=0)
        self.ans_label2 = tk.Label(self, text="", width=10, anchor="w", font=("",20))
        self.ans_label2.grid(row=1, column=1)
        self.result_label = tk.Label(self, text="", font=("",20))
        self.result_label.grid(row=2, column=0, columnspan=2)

        # # 時間計測用のラベル
        self.time_label = tk.Label(self, text="", font=("",20))
        self.time_label.grid(row=3, column=0, columnspan=2)

        self.flg2 = True

    # キー入力時のイベント処理
    def type_event(self, event):
        # 入力値がEnterの場合は答え合わせ
        if event.keysym == "Return":
            if self.q_label2["text"] == self.ans_label2["text"]:
                self.result_label.configure(text="正解！", fg="red")
                self.correct_cnt += 1
            else:
                self.result_label.configure(text="残念！", fg="blue")

            # 解答欄をクリア
            self.ans_label2.configure(text="")

            # 次の問題を出題
            self.index += 1
            if self.index == len(QUESTION):
                self.flg = False
                self.q_label2.configure(text="終了！")
                messagebox.showinfo("リザルト", f"あなたのスコアは{self.correct_cnt}/{self.index}問正解です。\nクリアタイムは{self.second}秒です。")
                sys.exit(0)
            self.q_label2.configure(text=QUESTION[self.index])

        elif event.keysym == "BackSpace":
            text = self.ans_label2["text"]
            self.ans_label2["text"] = text[:-1]

        else:
            # 入力値がEnter以外の場合は文字入力としてラベルに追記する
            self.ans_label2["text"] += event.keysym

    def timer(self):
        self.second = 0
        self.flg = True
        while self.flg:
            self.second += 1
            self.time_label.configure(text=f"経過時間：{self.second}秒")
            time.sleep(1)

if __name__ == "__main__":
    root = tk.Tk()
    Application(master=root)
    root.mainloop()
```