Pygameでタイピングゲーム

# 完成
難易度を選んでゲームスタート。
簡略化のために、スコア、時間制限などは排除。

![typing.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1056497/28ff2a2f-b5c8-3667-fe24-d907360f76f3.gif)

# 環境
python == 3.11.0
pygame == 2.6.0

# インストール
```
pip install pygame==2.6.0
```

# ディレクトリ
__TypingGame__
|___sounds__
・タイピング-メカニカル単2.mp3
・決定音.mp3
|___words__
・easy_words.json
・normal_words.json
・hard_words.json
keyboard.png
main.py

# コード
github:https://github.com/YuyMat/TypingGame
``` main.py
import json
import pygame
import random

class TypingGame(object):
    def __init__(self):
        pygame.init()
        pygame.mixer.init()
        self.width = 900
        self.height = 675
        self.title = "Typing Game"
        self.background_color = (220, 220, 220)
        self.screen_mode = "menu"
        self.rect_dict = {}
        self.difficulty = None
        self.words_json = {
            "easy" : "words/easy_words.json",
            "normal" : "words/normal_words.json",
            "hard" : "words/hard_words.json"
            }
        self.words_dict = None

        # A dictionary that maps keyboard keys to alphabets
        _ = [x for x in range(97, 123)]
        alphabet_list = [chr(i) for i in range(ord('a'), ord('z') + 1)]
        self.keyboard_dict = {n: alphabet for n, alphabet in zip(_, alphabet_list)}
        
        self.typing_sound = pygame.mixer.Sound("sounds/タイピング-メカニカル単2.mp3")
        self.correct_sound = pygame.mixer.Sound("sounds/決定音.mp3")

    def make_screen(self):
        self.screen = pygame.display.set_mode((self.width, self.height))
        pygame.display.set_caption(self.title)

    def init_screen(self):
        self.screen.fill(self.background_color)

    def menu_button(self, mode, rect_x, rect_y, mode_x, mode_y, width, height, color):
        # settings
        font = pygame.font.SysFont(None, 30, italic=True)
        display_mode = font.render(mode, True, "white")
        rect = pygame.Rect(rect_x, rect_y, width, height)
        
        # store the rect in self.rect_dict for using a button
        self.rect_dict[mode] = rect

        # display
        pygame.draw.rect(self.screen, color, rect)
        self.screen.blit(display_mode, (mode_x, mode_y))

    def menu_screen(self):
        # settings
        font = pygame.font.SysFont(None, 80)
        title = font.render("Typing Game by PyGame!", True, "black")
        image = pygame.image.load("keyboard.png")
        image = pygame.transform.scale(image, (500, 300))
        
        # display
        self.menu_button("easy", 700, 400, 725, 410, 100, 40, (59, 175, 117))
        self.menu_button("normal", 700, 450, 713, 460, 100, 40, (0, 106, 182))
        self.menu_button("hard", 700, 500, 725, 510, 100, 40, (215, 29, 59))
        self.screen.blit(title, (20, 50))
        self.screen.blit(image, (40, 300))
        pygame.display.update()
    
    def json_to_dict(self):
        json_path = self.words_json[self.difficulty]
        with open(json_path, "r") as f:
            self.words_dict = json.load(f)

    def play_screen(self):
        self.init_screen()
        if self.i == 0:
            # decide typing word    
            self.word, self.romaji = random.choice(list(self.words_dict.items()))
            
        # settings
        word_font = pygame.font.SysFont("ヒラキノ角コシックw7", 80)
        romaji_font = pygame.font.SysFont(None, 40)
        
        word = word_font.render(self.word, True, "black")
        romaji = romaji_font.render(self.romaji, True, "black")
        word_width_mid = (self.width/2) - (word.get_width()/2)
        romaji_width_mid = (self.width/2) - (romaji.get_width()/2)

        # display
        self.screen.blit(word, (word_width_mid, 50))
        self.screen.blit(romaji, (romaji_width_mid, 150))
        pygame.display.update()

    def progress_bar(self, word):
        progress = (len(self.letter) / len(word)) * self.width
        rect = pygame.Rect(0, 210, progress, 10)

        pygame.draw.rect(self.screen, (255, 0, 0), rect)

    def game_run(self, event):
        font = pygame.font.SysFont(None, 80)
        romaji = self.romaji.replace(" ", "")
        # right typing
        if self.keyboard_dict[event] == romaji[self.i]:
            
            # make sound
            self.typing_sound.play()
            
            self.letter += romaji[self.i]
            
            # display typed letter
            letter = font.render(self.letter, True, "black")
            self.i += 1
            self.play_screen()
            self.screen.blit(letter, ((self.width/2) - (letter.get_width()/2), 400))
            self.progress_bar(romaji)
            pygame.display.update()

            # when complited
            if self.letter == romaji:
                self.correct_sound.set_volume(0.2)
                self.correct_sound.play()
                self.i = 0
                self.letter = ""
                self.play_screen()

    def display_screen(self):
        if self.screen_mode == "menu":
            self.menu_screen()
        if self.screen_mode == "play":
            self.play_screen()

    def run(self):
        self.i = 0
        self.letter = ""
        self.make_screen()
        self.init_screen()
        self.menu_screen()
        running = True
        while running:
            for event in pygame.event.get():
                # exit
                if event.type == pygame.QUIT:
                    running = False
                
                # click
                elif event.type == pygame.MOUSEBUTTONDOWN and self.screen_mode == "menu":
                    if self.rect_dict["easy"].collidepoint(event.pos):
                        self.difficulty = "easy"
                        self.json_to_dict()
                        self.screen_mode = "play"
                    elif self.rect_dict["normal"].collidepoint(event.pos):
                        self.difficulty = "normal"
                        self.json_to_dict()
                        self.screen_mode = "play"
                    elif self.rect_dict["hard"].collidepoint(event.pos):
                        self.difficulty = "hard"
                        self.json_to_dict()
                        self.screen_mode = "play"
                    
                    self.display_screen()
                
                # push keyboard
                elif event.type == pygame.KEYDOWN:
                    # run game
                    if event.key > 96 and event.key < 123 and self.screen_mode == "play":
                        self.game_run(event.key)
                    # esc to finish the game
                    elif event.key == 27:
                        running = False

typing_game = TypingGame()
typing_game.run()
```

# 解説
run()で挙動を制御。

#### run() 1-5行目 メニュー画面
```python  
    self.i = 0
    self.letter = ""
    self.make_screen()
    self.init_screen()
    self.menu_screen()
```
make_screen()でpygameのスクリーンを作成。
```python
def make_screen(self):
    self.screen = pygame.display.set_mode((self.width, self.height))
    pygame.display.set_caption(self.title)
```

init_screen()で背景色を設定。
のちにこれ単体で画面を塗りつぶすため、make_screen()と分離。
```python
def init_screen(self):
    self.screen.fill(self.background_color)
```
menu_screen()で難易度選択の初期画面を作成。
```python
def menu_screen(self):
    # settings
    font = pygame.font.SysFont(None, 80)
    title = font.render("Typing Game by PyGame!", True, "black")
    image = pygame.image.load("keyboard.png")
    image = pygame.transform.scale(image, (500, 300))
    
    # display
    self.menu_button("easy", 700, 400, 725, 410, 100, 40, (59, 175, 117))
    self.menu_button("normal", 700, 450, 713, 460, 100, 40, (0, 106, 182))
    self.menu_button("hard", 700, 500, 725, 510, 100, 40, (215, 29, 59))
    self.screen.blit(title, (20, 50))
    self.screen.blit(image, (40, 300))
    pygame.display.update()
```
menu_button()では難易度選択ボタンを作成、表示する。
ボタンとして検知するために、作成した四角形のsurfaceをself.rect_dictに保存している。
rect_dict = {"難易度"："四角形のsurface"}

```python
def menu_button(self, mode, rect_x, rect_y, mode_x, mode_y, width, height, color):
    # settings
    font = pygame.font.SysFont(None, 30, italic=True)
    display_mode = font.render(mode, True, "white")
    rect = pygame.Rect(rect_x, rect_y, width, height)
    
    # store the rect in self.rect_dict for using a button
    self.rect_dict[mode] = rect
    # display
    pygame.draw.rect(self.screen, color, rect)
    self.screen.blit(display_mode, (mode_x, mode_y))
```

#### run() 6-11行目
runningフラグを追加。Falseになったらゲーム終了。
pygameの雛形的存在？

```python
running = True
    while running:
        for event in pygame.event.get():
            # exit
            if event.type == pygame.QUIT:
                running = False
```

#### run() 13-28行目 クリック検知, ボタン

```python
# click
elif event.type == pygame.MOUSEBUTTONDOWN and self.screen_mode == "menu":
    if self.rect_dict["easy"].collidepoint(event.pos):
        self.difficulty = "easy"
        self.json_to_dict()
        self.screen_mode = "play"
    elif self.rect_dict["normal"].collidepoint(event.pos):
        self.difficulty = "normal"
        self.json_to_dict()
        self.screen_mode = "play"
    elif self.rect_dict["hard"].collidepoint(event.pos):
        self.difficulty = "hard"
        self.json_to_dict()
        self.screen_mode = "play"
    
    self.display_screen()
```
1. self.rect_dictに格納されている難易度に対応したsurfaceが押されたらself.difficultyを変更
2. self.json_to_dict()でself.difficultyに対応したjsonファイル(下記参照)からゲームで使う単語を取得
3. screen_modeをplayに変更
4. self.display_screen()でゲームプレイスクリーンへ変更
```easy_words.json
{
    "猫背": "nekoze",
    "海鳥": "umidori",
    "鏡": "kagami",
    "雪国": "yukiguni",
    "未来": "mirai",
    "夕日": "yuuhi",
    "旅行": "ryokou",
    "朝日": "asahi",
    "車道": "shadou",
    "etc": "etc"
 }
``` 
```python
def display_screen(self):
    if self.screen_mode == "menu":
        self.menu_screen()
    if self.screen_mode == "play":
        self.play_screen()
```

##### play_screen()
メニュー画面の表示と、タイピングワードの決定も担当。
```python
def play_screen(self):
    self.init_screen()
    if self.i == 0:
        # decide typing word    
        self.word, self.romaji = random.choice(list(self.words_dict.items()))
        
    # settings
    # 日本語に対応させるためにヒラキノ角コシックw7を使用
    word_font = pygame.font.SysFont("ヒラキノ角コシックw7", 80)
    romaji_font = pygame.font.SysFont(None, 40)
    
    word = word_font.render(self.word, True, "black")
    romaji = romaji_font.render(self.romaji, True, "black")
    word_width_mid = (self.width/2) - (word.get_width()/2)
    romaji_width_mid = (self.width/2) - (romaji.get_width()/2)
    # display
    self.screen.blit(word, (word_width_mid, 50))
    self.screen.blit(romaji, (romaji_width_mid, 150))
    pygame.display.update()
```
#### run() 30-37行目 ゲーム核 キー検知
```python
# push keyboard
elif event.type == pygame.KEYDOWN:
    # run game
    if event.key > 96 and event.key < 123 and self.screen_mode == "play":
        self.game_run(event.key)
    # esc to finish the game
    elif event.key == 27:
        running = False
```
a-zはevent.keyの戻り値が97-122の連番のため、対応する辞書をself.keyboard_dictで作成
(以下、__init__で作成済み)
```python
_ = [x for x in range(97, 123)]
alphabet_list = [chr(i) for i in range(ord('a'), ord('z') + 1)]
self.keyboard_dict = {n: alphabet for n, alphabet in zip(_, alphabet_list)}
```
mode == playの時にa-zを押したことを検知したらself.game_run()を実行。

##### game_run()
```python
def game_run(self, event):
    font = pygame.font.SysFont(None, 80)
    romaji = self.romaji.replace(" ", "")
    # right typing
    if self.keyboard_dict[event] == romaji[self.i]:
        # make sound
        self.typing_sound.play()
        
        self.letter += romaji[self.i]
        
        # display typed letter
        letter = font.render(self.letter, True, "black")
        self.i += 1
        self.play_screen()
        self.screen.blit(letter, ((self.width/2) - (letter.get_width()/2), 400))
        self.progress_bar(romaji)
        pygame.display.update()
        # when complited
        if self.letter == romaji:
            self.correct_sound.set_volume(0.2)
            self.correct_sound.play()
            self.i = 0
            self.letter = ""
            self.play_screen()
```
フローチャート
![スクリーンショット 2024-09-15 22.36.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1056497/bdb6e8d9-6014-5bf5-e792-30a869f37505.png)

キー検知を追加したせいでescでのゲーム終了ができなくなってしまったため、一応下記を追加
```python
elif event.key == 27:
    running = False
```

#### プログレスバー
視覚的にどこまでタイピングしたか分かりやすくするためプログレスバーを作成してみた。
```python
def progress_bar(self, word):
    progress = (len(self.letter) / len(word)) * self.width
    rect = pygame.Rect(0, 210, progress, 10)
```
これをgame_run()にぶち込んで完成。