Unity初心者がWebGLで動くタイピングゲームを作ってみた

# はじめに

Furyu Advent Calendar 16日目を担当するf_nksknです。
普段の業務ではピクトリンクのモバイルアプリ開発を行っています。

今回はUnityを使ってWebGLで動くタイピングゲームを作ったので、そのことを書こうと思います。
（なお、本業務とは関係ないただの趣味です）

タイトルにもあるように初心者なので設計素人なのと、基本ゴリ押し実装であることをご了承ください。

# 概要

## 開発環境
- Unity Hub 3.5.2
- Visual Studio Code
    - Script実装用
    - Github copilotプラグイン導入済み

## 作ったもの
- シンプルなタイピングゲーム
    - ノベルゲームっぽい？見た目
    - 問題の表示は日本語＆ローマ字、入力済かが分かる様にする
    - 時間制限あり。タイムアップで終了し、スコアを表示
- 画面は3画面（タイトル→ゲーム→スコア）

完成したゲームの動画はこちら。

![マイムービー-1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/3a60eafb-6cec-b7cf-fbf9-817865daf789.gif)

# 実装
## プロジェクト準備
- UnityHubから2Dプロジェクトを新規作成
- Sceneファイルは３つ作成
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/fe84ba0f-3889-bf86-06ca-6531813bab69.png" width=80%>


## タイトル画面(Title Scene)
### 画面
![スクリーンショット 2023-12-15 2.06.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/980e5a40-5e2e-2f0e-0e75-87cbb60eb190.png)

### 主な機能
- Enterキーを押してスタート
- （音ON/OFF 調整）

### 実装内容
#### Enterキー入力でゲーム画面に遷移させる
タイトル画面での入力を処理するScriptファイルを作成し、canvasにアタッチします。
キー入力を監視するために、update関数に以下のコードを記載しました。
```csharp
// Enterキーが押されたら次のシーンに遷移
if (Input.GetKeyDown(KeyCode.Return))
{
    Initiate.Fade("Game", Color.black, 2.5f);
}
```

とても簡単です。
なお、次のシーンに移動する際にフェード効果を入れたかったので、こちらの記事を参考に、`Simple Fade Scene Transition System`アセットを使用しています。
https://vanikki.com/asset-simple-fade-scene-transition-system/

# ゲーム画面(Game Scene)
### 画面
![スクリーンショット 2023-12-15 2.07.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/983a468a-9f02-fe17-2636-3f8e227739b5.png)

### 主な機能
- （開始前カウントダウン）
- （問題はランダム。正解後、すぐ次の問題へ）
- 制限時間表示。タイムリミットでスコア画面へ
- 問題の表示は日本語＆ローマ字
- 入力中は入力済/未入力文字が分かるようにする


### 実装内容
#### 制限時間表示
Sliderオブジェクトを画面に配置して制限時間をバーで表示させます。
通常のSliderではツマミ部分が表示されてしまうので、Sliderの子オブジェクトに対して、以下の対応をします。
- Handle Slide Areaを削除
- Fill Area->Rect Transformで、Left/Rightを0にする
- BackGroundのColorを任意の色で指定

制限時間を管理するスクリプトは以下のように実装しました。
`Time.deltaTime`で前回のフレームからの経過秒数を取得できるので、制限時間から減算し、割合に応じてSliderのvalue(0~1)を変動させています。

```C#
public Slider timeLimitSlider; // UIバー（Slider）をInspectorから割り当てる

private int timeLimit = 30;
private float currentTime;

void Start()
{
    currentTime = timeLimit;
}

void Update()
{
    if (currentTime > 0)
    {
        currentTime -= Time.deltaTime;
        // Sliderの表示更新
        float fillAmount = currentTime / timeLimit;
        timeLimitSlider.value = fillAmount;
    }
    else
    {
        // 時間切れの処理(スコア画面に遷移）
    }
}
```
時間経過に応じて表示が変化するバーができました。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/27b5c1c1-0864-ec7d-e862-678f328bfe88.gif" width=80%>


なお、時間切れ時にはスコアの集計を行い、即座にスコア画面に遷移するようにしています。

#### 日本語表示
テキスト表示エリアにはTextMeshProのオブジェクトを配置しています。
特に何も指定しないと日本語は全て豆腐になるため、日本語表示用のフォントアセットを用意する必要があります。
今回はこちらの記事を参考に、Google Fontsから`NotoSansJP-Regular.otf`を取得して、アセットを作成しました。
https://www.midnightunity.net/textmeshpro-japanese-font/

なお、`Custom Character List`について、https://gist.github.com/kgsi/ed2f1c5696a2211c1fd1e1e198c96ee4 で提供されている全ての文字を含めても良いのですが、ファイルサイズが大きくなる為、全く使わない文字や記号は省くなどの工夫があった方が良いかと思います。

#### 問題文作成
続いてタイピングゲームの主軸となる、問題文についてです。
ローマ字表示はするものの、台詞が長い文、日本語を見ながらタイピングする形になりそうなので、`ふ = fu/hu`、`ちょ = cho/tyo` の様にある程度の柔軟性は持たせたいです。
と言うわけで、問題文となるSentenceクラスはこのように定義しました。

```csharp
// 問題文
public class Sentence{
    public string raw;// 問題原文
    public List<Word> words;// 文字リスト
    
    public Sentence(string raw, string japanese, string roman){
        this.raw = raw;
        this.words = new List<Word>();

        // '|'区切りで分割
        string[] japaneseArray = japanese.Split('|');
        string[] romanArray = roman.Split('|');

        // 文字ごとにWordクラスを生成してリストに格納
        for(int i = 0; i < japaneseArray.Length; i++){
            this.words.Add(new Word(japaneseArray[i], romanArray[i]));
        }
    }
}

// 問題文の中の1文字
public class Word{
    public string japanese;
    public string roman;
    public string? roman2;//ローマ字表記揺れ許容（最大２パターンまで）
    public Word(string japanese, string roman){
        this.japanese = japanese;

        // ローマ字が2通りある場合「/」で区切られるので、roman2に格納する
        if(roman.Contains("/")){
            string[] romanArray = roman.Split('/');
            this.roman = romanArray[0];
            this.roman2 = romanArray[1];
        }else{
            this.roman = roman;
            this.roman2 = null;
        }
    }
}
```
そして実際に格納する問題文の例はこちら。
```c#
new Sentence("メリークリスマス！良い子にしてたかい？",
            "メ|リ|ー|ク|リ|ス|マ|ス！|良|い|子|に|し|て|た|か|い？",
            "me|ri|-|ku|ri|su|ma|su|yo/i|i|ko|ni|si/shi|te|ta|ka|i"
),
```
全体的に力技感がぷんぷんしますが一旦良しとします。
別の問題を同様に作成するのですが、毎回区切り記号を入れてられないので、ここはcopilotの力を借ります。

![名称未設定.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/4c35419d-141c-57eb-2054-3908215cf1c2.gif)

多少修正が必要ではありますが、実装済の引数のフォーマットに合わせた形式で予測文字列を出してくれるので大変重宝しました。


#### 入力中は入力済/未入力が分かるようにする
日本語文字、ローマ字共に色を変えます。
TextMeshProを使用しているため、テキストとして表示する文字列を以下の様にcolorタグで囲む事で、任意の範囲の文字の色を変えることができます。

```
 <color=red>nyuuryokuzumi</color>minyuuryoku
```
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/1ae977af-2e7e-7f1c-138e-fc9f2c267e3a.png" width=400>

入力済の文字のindexは、下記コードの様に、キーボードから正解文字が入力される度更新されます。
WordListのindexに該当する文字までをcolorタグで囲み、それ以外をデフォルト色とすればどこまで入力できているかを表示することができます。

```csharp

private void Update()
{
    char? nextChar = null;
    
    // ここまで入力した文字のindexから次の正解文字をnextCharに格納
    nextChar = sentence.words[currentWordIndex].roman[currentRomanIndex];
    
    // キーボードの入力を取得
    if (nextChar != null && Input.GetKeyDown(nextChar.ToString()))
    {
        // 正しい場合、正解済のindex及び表示を更新（正解済の文字をタグで囲む）
        updateInput();
    }else if(Input.anyKeyDown){
        // ミス時の処理
    }
}
```

# スコア画面(Score Scene)
### 画面
![スクリーンショット 2023-12-15 2.45.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/e9e6e6b8-8050-7419-16aa-bf1843a5e005.png)


### 主な機能
- 正解文字数・ミスタイプ数を表示
- 正解数をアイコンで表示
- （タイトルに戻る）

#### 正解文字数・ミスタイプ数を表示
Scene間を跨いで扱いたい数値については、今回は`PlayerPrefs`を使ってゲーム内で保持させることにします。
正解した文字数及びミスタイプ数は、ゲーム画面で記録している前提で、スコア画面にはその結果をテキストとして表示してあげるだけです。

```c#
public TextMeshProUGUI missText;
public TextMeshProUGUI correctText;

void Start()
{
    int miss = PlayerPrefs.GetInt("Miss");
    int correct = PlayerPrefs.GetInt("Correct");
    
    missText.text = "タイプミス数: " + miss;
    correctText.text = "正解文字数: " + correct;
}
```
#### 正解数をアイコンで表示
文字だけだと味気ないので、正解数をアイコンで表示させることにしました。

まずアイコンのオブジェクトを複製できるようにPrefab化し、続いて画面にHorizontalLayoutを配置します。
ScriptではStart時点（スコア画面表示時点）での正解数に応じてPrefabからGameObjectを複数生成し、HorizontalLayoutの子オブジェクトとして設定します。

```c#

public Transform horizontalLayout;
public GameObject iconSanta;

void Start()
{
    int correctSentences = PlayerPrefs.GetInt("Sentences");
    
    for (int i = 0; i < correctSentences; i++)
    {
        GameObject icon = Instantiate(iconSanta, horizontalLayout);
        icon.transform.SetParent(horizontalLayout, false);
    }
}
```
例えば正解数が３の場合、正解アイコンのcloneがHorizontalLayoutの子要素として３つ生成され、表示されるようになっています。

![スクリーンショット 2023-12-15 3.10.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/fadf3d1c-9439-9b6f-2465-737554f7c662.png)


# Webサイトに公開する

ざっくりタイピングゲームが出来たところで、Webサイト上に公開していきたいと思います。

## 事前準備
- アップロード先の選定・準備
    - UnityRoom
    - GitHub Pages
    - レンタルサーバ（★今回はこれ）

## webGL向けビルド
File->Build Settingsを開き、WebGLを対象に設定します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/08c5ff28-146a-9879-010f-0a3ba72c04dd.png" width=80%>

またこの時、
Player Settings -> Player -> WebGL -> Publish Settings
を開き、「Decompression Fallback」にチェックを入れておきます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/4ae0ff05-bc74-94f5-d49b-f91e121ffef2.png" width=80%>

これをしておかないと圧縮されたファイルが展開出来ず、サーバアップロード後の起動時にエラーが出ます。
※サーバ側の.htaccessファイルを編集する事でもエラー対策にはなる（そちらの方が圧縮が効くので起動が軽くなる）ようですが、その方法は未検証です。

設定完了後、Buildするとディレクトリ（以下の例だと`xda_v1_0_0`）が生成されるので、直下のディレクトリ/ファイルを全てサーバにアップロードしてあげれば公開作業完了です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66928/0423e841-739a-6328-37af-2e5dc6496976.png" width=80%>


# まとめ

簡単なタイピングゲームを作り、Web公開することができました。（検証に使ったのが個人で借りたサーバなのでURLは掲載しません）
Unityはチュートリアルを少し齧った程度の初心者ですが、簡単な２Dゲームの実装はそこまで難しくなく、作ったものを公開するハードルも相当低いなというのが所感です。

ただ本記事の中では触れなかったのですが、表示したい画像が増えるとロードに時間がかかったり、そもそもメモリ不足でゲーム自体読み込めなくなります。（何も考えずに高画質画像を複数枚突っ込んだらOut of memoryが発生した）
今回はシンプルなゲームだったのでそこまで考慮していないのですが、実際WebGLで動くそこそこリッチなゲームを作るためには、テクスチャの圧縮などメモリ使用量を削減する工夫が重要になってきそうです。