pygameでノベルゲームを作成してみる

## はじめに

NTTドコモの坂口です！<br>普段は音声認識や音声合成に関する業務に携わっておりますが，今回はタイトルの通りノベルゲームをさくっと作ってみたいと思います！というのも，筆者が2次元がとにかく大好きでして．．アニメの世界に入りたいな．．ナーヴギアの開発はまだかな．．なんて思うことは日常茶飯事です（笑）ですが，現在のノベルゲームって選択式のものが多いじゃないですか．正直「私そんなこと言わないし」って思うことが多いんですよね（笑）ってことで，自分で文字入力が出来るノベルゲームを作っていきたいと思います！
なお今回はシステムの作成がメインのため，キャラとユーザーの対話部分の精度に関してはご容赦ください．

## 参考
ノベルゲームを作成する既存のサービスとして，[ティラノスクリプト](https://tyrano.jp/)などがあります．今回はただ物語を追うのに加えて，さらにユーザーも自分のセリフを入力できるノベルゲームを作っていこうと思います！

## 注意
- イラストは，筆者の友達に描いてもらった完全オリジナルイラストです．
- ユーザーのキャラネームは「柳ユカ」です．筆者がつけました．

## 目指す対話システム
完成はこのようになります
![完成イメージ2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2294957/805be79e-400e-f719-0d68-20aa0f624b87.gif)
事前に作成したシナリオが流れて，途中でユーザーが答えていく．．という流れです！
では早速作っていきましょう！

## インストール環境
- macOS Monterey 12.0.1
- python 3.9.7
- pygame 2.1.0
- torch 1.10.0
- transformers 4.13.0
- mojimoji 0.0.12
- numpy 1.21.4

## pygameとは
ゲーム制作のために設計されたpythonモジュールです．ゲーム制作といえばUnityを思い浮かべる人も多いかと思いますが，今回は筆者が馴染みやすいpygameを使うことにしました．

## 固定の背景を表示させよう
まずは，常に固定で表示したい画面を作成します．
フォントは[IPAサイト](https://moji.or.jp/ipafont/ipaex00301/)からダウンロードしてきました．関数```every_time```で，背景画像と常に表示させておきたい文字(ここでは「とある高校1年1組」)を記載しています．この関数をmain関数のループ内で常に表示させ続けます．

```python
def every_time(screen):
    font = pygame.font.Font("ipaexg.ttf", 30)

    bkg = pygame.image.load("教室.jpg") #背景画像
    bkg= pygame.transform.scale(bkg, (800, 600)) 
    screen.blit(bkg, (0, 0))
    
    message = font.render("とある高校1年1組", True, (0, 0, 0)) #画面左上に記載するテキスト
    screen.blit(message, (20, 30))

def main():
    
    pygame.init()
    running = True

    screen = pygame.display.set_mode((800, 600))
    pygame.display.set_caption("ノベルゲーム") #画面タイトル

    while running:
        every_time(screen)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
        pygame.display.update()
    

if __name__ == "__main__":
    main()

```

## キャラのセリフを流そう

次にキャラのセリフを流します．話しをするキャラと，そのキャラのセリフ，シナリオパターンと発話順をカンマ区切りでテキスト保存(```synario.txt```)します．
シナリオパターンは，ユーザーの発話内容によって，異なるシナリオを選択できるように区別しています．また発話順は，そのシナリオの中で何番目に表示させたいセリフの順に番号を振っています．1文のセリフが長い場合は，例の2行目・3行目のようにあらかじめテキストを分けて保存してください．

```
# synario.txt
話すキャラ,話すテキスト,シナリオパターン,発話順
先生,んじゃあ今から遠足の,1,1
先生,行き先について決めたいんだが..,1,1
生徒1,東京がいいでーす！,1,2
生徒2,えぇー沖縄がいいよ！,1,3
生徒3,やっぱ修学旅行といえば北海道だろ！,1,4
```

プログラムコードは次の通りです．
- 関数```position```: キャラのセリフの表示場所を示します．どの位置にセリフを表示させるかは手動で確認しました．
- 関数```synario```: 先ほどテキスト保存したファイルを読み込みます．ここではメイン関数から指定されたシナリオ番号のセリフと，そのシナリオでの最後の発話番号を返します．
- 関数```message_input```: キャラのセリフを表示します．複数のセリフを同時に表示させたい場合(発話順が同じ値で連続する場合)は，画面の初期化をパスする仕様にしています．さらにこの場合，メッセージ表示する位置も，テキストの分割数により変更するように書いています．また，キャラのセリフ終了時の文字のみ，大きく表示させています．
- 関数```out_pic```: 同時に表示させたいテキストか，時間差で表示させたいテキストかを判断した上で，テキストを表示するため，```message_input```にリクエストを送ります．同時に表示させたいテキストであった場合，テキストごとに少しずつ表示位置をずらしています．
- 関数```main```: セリフの表示は3秒間行い，3秒後には一度画面を初期化し，次のセリフ表示を行います．

```python
# coding: utf-8
import pygame
import time

def every_time(screen):
    font = pygame.font.Font("ipaexg.ttf", 30)

    bkg = pygame.image.load("教室.jpg")
    bkg= pygame.transform.scale(bkg, (800, 600)) 
    screen.blit(bkg, (0, 0))
    
    message = font.render("とある高校1年1組", True, (0, 0, 0))
    screen.blit(message, (20, 20))

def position():
    #ラベルごとに，セリフを表示する位置を決める
    chara_position = {
        "先生": [450, 230],
        "生徒1": [450, 130],
        "生徒2": [300, 50],
        "生徒3": [50, 400],
        "end": [300, 300]
    }
    return chara_position

def synario(id):
    #シナリオファイルの読み込み
    synario_file = ""
    with open("synario.txt") as f:
        synario_file = f.read()
    synario_file = synario_file.split("\n")
    synario_txt = []
    for text in synario_file:
        line = text.split(",")
        if len(line) == 4:
            if str(line[2]) == id: 
                synario_txt.append(line)
    last_sentence = synario_txt[-1][3]
    return synario_txt, int(last_sentence)

def message_input(screen, chara, text, chara_position, adjust, reset):
    if reset == 1:
        every_time(screen)
    if chara == "end":
        font_chara = pygame.font.Font("ipaexg.ttf", 80)
    else:
        font_chara = pygame.font.Font("ipaexg.ttf", 20)
    message = font_chara.render(text, True, (0, 0, 0))
    position = [chara_position[chara][0], chara_position[chara][1]-adjust]
    screen.blit(message, position)

def out_pic(screen, synario_txt, cha_position, n):
    fle = 0 #同一発話順の出現回数
    for i in range(len(synario_txt)):
        if int(synario_txt[i][3]) == n: #対象のidと同じであれば
            fle += 1
            chara = synario_txt[i][0]
            text = synario_txt[i][1]
    if fle == 1: #連続するidがない場合
        message_input(screen, chara, text, cha_position, 0, 1)
    elif fle > 1: #連続するidがある場合
        adjust = 20*fle
        utterance = 0 #発話番号
        for i in range(len(synario_txt)):
            if int(synario_txt[i][3]) == n:
                adjust -= 20 * utterance
                if utterance == 0:
                    reset = 1
                else:
                    reset = 0
                message_input(screen, synario_txt[i][0], synario_txt[i][1], cha_position, adjust, reset)
                utterance += 1


def main():
    
    pygame.init()
    running = True

    screen = pygame.display.set_mode((800, 600))
    pygame.display.set_caption("ノベルゲーム")

    n = 1 #表示テキストの発話番号
    next_id = "1" #シナリオ番号
    synario_txt, last_sentence = synario(next_id)
    cha_position = position()
    time_sta = time.perf_counter()
    pic_status = 0 #画面リセット：0, テキスト表示：1
    while running:
        time_end = time.perf_counter()
        tim = int(time_end- time_sta)
        if tim % 3 == 0:
            every_time(screen)
            if pic_status == 1:
                n += 1
                pic_status = 0
        elif n > last_sentence: #シナリオ終了
            message_input(screen, "end", "end!", cha_position, 0, 1)
        else:
            out_pic(screen, synario_txt, cha_position, n)
            pic_status = 1
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
        pygame.display.update()


if __name__ == "__main__":
    main()
```
現時点でこのように動作します．
![録画1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2294957/3dbb42a2-4770-0e9b-9251-09d4b4b9b3fe.gif)
## ユーザーもセリフを流してみよう

次に，ユーザーもセリフの入力ができるようにプログラムをかきます．シナリオは下記の3行を```synario.txt```に追記します．「話すキャラ」にユーザーを設定することで，ユーザーの発話パターンとします．ユーザーの発話はアプリ上で入力するため，事前に準備するシナリオファイルでは，空文字にしておきます．

```
先生,柳さんはどう思う？,1,5
ユーザー,,1,6
生徒1,やっぱ東京だよな！,2,1
```
ユーザーのテキスト入力方法は，うまくまとめてくれている方がいらっしゃったので，[こちらの記事](https://dygv.github.io/blog/post/2021/01/pygame%E3%81%AE%E3%83%86%E3%82%AD%E3%82%B9%E3%83%88%E5%85%A5%E5%8A%9B/)を参考にしました．```text.py```はそのまま利用しています．

関数```draw_text```では，画面を初期化する関数を足して，入力文字色のみ変更しています．

```python
def draw_text(text: str, screen, font) -> None:
    #入力文字を描画するための関数
    every_time(screen)
    text_surface = font.render(text, True, (0, 0, 0))
    center_w = (800 / 2) - (text_surface.get_width() / 2)
    center_h = (600 / 2) - (text_surface.get_height() / 2)
    screen.blit(text_surface, (center_w, center_h))
    pygame.display.update()
```

関数```out_pic```には，次のセリフがユーザー入力か，キャラのセリフかを返す処理を足しました．

```python
def out_pic(screen, synario_txt, cha_position, n): #キャラのセリフ画面表示
    fle = 0
    next_status = "" #次の入力がユーザーにあるか，キャラにあるか
    for i in range(len(synario_txt)):
        if int(synario_txt[i][3]) == n: #対象のidと同じであれば
            fle += 1
            chara = synario_txt[i][0]
            text = synario_txt[i][1]
            if i < len(synario_txt)-1:
                next_status = synario_txt[i+1][0]
    if fle == 1: #連続するidがない場合
        message_input(screen, chara, text, cha_position, 0, 1)
    elif fle > 1: #連続するidがある場合
        adjust = 20*fle
        utterance = 0 #発話番号
        for i in range(len(synario_txt)):
            if int(synario_txt[i][3]) == n:
                adjust -= 20 * utterance
                if utterance == 0:
                    reset = 1
                else:
                    reset = 0
                message_input(screen, synario_txt[i][0], synario_txt[i][1], cha_position, adjust, reset)
                utterance += 1
    return next_status
```

後ほど，ユーザーのセリフによって，選択されるシナリオが変更されるという意味で関数```next_synario```を作成します．

```python
def next_synario(now_synario, user_text):
    input_text, user_text = "", ""
    return 2, input_text, user_text
```
<br>
```main```関数は，ユーザーの入力時の処理のため修正しました．これでキャラのセリフが流れ，ユーザーはテキスト入力ができるプログラムが動作します．


```python
def main():
    
    pygame.init()
    running = True
    text = Text() #テキスト処理のロジックTextクラスをインスタンス化

    screen = pygame.display.set_mode((800, 600))
    pygame.display.set_caption("ノベルゲーム")

    pygame.key.start_text_input() # input, editingイベントをキャッチするようにする

    event_trigger = {
        K_BACKSPACE: text.delete_left_of_cursor,
        K_DELETE: text.delete_right_of_cursor,
        K_LEFT: text.move_cursor_left,
        K_RIGHT: text.move_cursor_right,
        K_RETURN: text.enter,
        }

    n = 1 #表示テキストの発話番号
    synario_id = "1" #シナリオ番号
    synario_txt, last_sentence = synario(synario_id)
    cha_position = position()
    time_sta = time.perf_counter()
    pic_status = 0 #画面リセット：0, テキスト表示：1
    usr_status = 0 #ユーザー入力なし:0, ユーザー編集中:1, ユーザーのテキスト入力終了直後:2
    input_text, user_text = "", ""
    next_status = "" #次のセリフがキャラかユーザーか
    font = pygame.font.Font("ipaexg.ttf", 20)
    while running:
        time_end = time.perf_counter()
        tim = int(time_end- time_sta)
        if tim % 3 == 0 and usr_status == 0: #画面初期化
            every_time(screen)
            if pic_status == 1:
                n += 1
                pic_status = 0
        elif n > last_sentence: #ゲーム終了の文字
            message_input(screen, "end", "end!", cha_position, 0, 1)
        elif next_status != "ユーザー": #セリフがキャラのターン
            next_status = out_pic(screen, synario_txt, cha_position, n)
            pic_status = 1
        elif next_status == "ユーザー" and pic_status == 0: #ユーザーの入力ターン
            usr_status = 1
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    running = False
                else:
                    #if event.type == KEYDOWN and not text.is_editing:
                    if event.type == KEYDOWN:
                        if event.key in event_trigger.keys():
                            input_text = event_trigger[event.key]()
                        # 入力の確定
                        if event.unicode in ("\r", "") and event.key == K_RETURN:
                            print(input_text)  #確定した文字列を表示
                            user_text = input_text
                            draw_text(format(text), screen, font)
                            input_text = format(text)
                            synario_id, input_text, user_text = next_synario(synario_id, user_text)
                            synario_txt, last_sentence = synario(synario_id)
                            usr_status = 0
                            pic_status = 0
                            next_status = ""
                            n = 1
                            time_sta = time.perf_counter()
                            break
                    elif event.type == TEXTEDITING:  # 全角入力
                        input_text = text.edit(event.text, event.start)
                    elif event.type == TEXTINPUT:  # 半角入力、もしくは全角入力時にenterを押したとき
                        input_text = text.input(event.text)
                    # 描画しなおす必要があるとき
                    if event.type in [KEYDOWN, TEXTINPUT]:
                        draw_text(input_text, screen, font)
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
        pygame.display.update()
```

## ユーザーの入力から次のシナリオを決定しよう
次に，ユーザーの入力内容から次のシナリオを選択するプログラムをかきます．ここのプログラムによって，例えば「海に行きたいな」とユーザーが入力すると，シナリオは「沖縄」にいく方向で進ませることができます．
今回は，bertを用いて文ベクトルを作成し，コサイン類似度を計算します．コサイン類似度が最も高いテキストのシナリオidを次のシナリオとしました．
まず，先ほどの```synario.txt```に次の2行を追加してください．

```
# synario.txt
生徒3,わかるー！海鮮食べたいし，北海道だね！,3,1
生徒2,やったー！青い海！沖縄！,4,1
```

これで，ユーザーのセリフから「東京」「沖縄」「北海道」のいずれかのシナリオに分類されるシナリオとなります．
学習済みモデルは，東北大学乾研究室が公開している[cl-tohoku/bert-base-japanese-whole-word-masking](https://huggingface.co/cl-tohoku/bert-base-japanese-whole-word-masking)をダウンロードしました．実行コードはうまくまとめてくれている記事があったので，[こちら](https://rema424.github.io/remalab/posts/sentence-vector/)を参考にしました．
3行目の,


```python
from transformers.tokenization_bert_japanese import BertJapaneseTokenizer
```

は現在の```transformers```のバージョンではエラーが出てしまうので,


```python
from transformers import BertJapaneseTokenizer
```

のみに修正してください．ここの```main```関数内の内容を，```bert```という関数にかきます．ユーザーの入力テキストと，次のキャラのセリフを入力とし，類似度を返す関数です．

```python
def bert(user_text, synario_text):
    # GPUとCPUのどちらを利用するか判定
    device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
    # トークナイザーの読み込み
    tokenizer = BertJapaneseTokenizer.from_pretrained(
        "cl-tohoku/bert-base-japanese-whole-word-masking"
    )
    # 学習済みモデルの読み込み
    model = BertModel.from_pretrained("cl-tohoku/bert-base-japanese-whole-word-masking")

    vec_a = embedding(device, user_text, tokenizer, model)
    vec_b = embedding(device, synario_text, tokenizer, model)
    sim = cos_similarity(vec_a, vec_b).item()

    return sim
```

次のシナリオ候補を抽出する関数も追加します．ここでは，今のシナリオid以降から，発話順が「1」に該当するものを全て抽出します．ここで抽出したテキストと，ユーザーの入力テキストの類似度を比較し，最大となるものを次のシナリオidとして返します．

```python
def next_synario(now_synario, user_text):
    synario_text = user_uttenrance(int(now_synario))
    max = 0
    for i in range(len(synario_text)):
        sim = bert(user_text, synario_text[i][1])
        print(user_text + " : " + synario_text[i][1] + " : " + str(sim))
        if sim > max:
            max = sim
            next_synario = synario_text[i][0]
    input_text, user_text = "", ""
    return next_synario, input_text, user_text
```

上記の作業で，```main```関数は特に修正する必要はなく，ユーザーのテキストの内容から，次のシナリオパターンを自動で分類するシステムの完成です！

## まとめ
こちらの記事では，ユーザーのテキスト入力ができる簡易なノベルゲームをpygameで作ってみました．興味のある方はぜひ試してみてください！今回は対話の精度はみておりませんので，今後対話の分類精度を高める手法を検討してみたいですね！！