Unityで音ゲーを作りたい

この記事は、[木更津高専 Advent Calendar 2021](https://qiita.com/advent-calendar/2021/nit_kisarazu) の25日目（最終日！）の記事です。

前→[リア充にリア充を台無しにさせたい](https://qiita.com/tos-up/items/127aaee0da58f53242ad) by @tos-up

# はじめに
私はプログラミング研究同好会に所属していますが、先日行われた祇園祭で「ゲームセンタープロ研」のスタッフをしているときに思いました。

**音ゲー作って展示したら盛り上がるのでは？**

こうして、私は音ゲーを作ることを決心しました。

# 音ゲーとは？
ご存知の方も多いと思いますが、音ゲーとは音楽ゲームの略称で、音楽に合わせて何らかのアクションを起こすゲームのことです。
有名なものには、

- 太鼓の達人
- CHUNITHM
- プロジェクトセカイ カラフルステージ！ feat. 初音ミク

などがあります。

# プレイ画面の作成
今回は縦画面の6レーンで、単押しの音符（以下、ノーツ）のみ上から下に降ってくるものを作ります。長押しノーツは実装が大変なので作りません。また、他のレイアウトに流用しやすくするため、3Dで作成します。

まずは、譜面が流れるためのレーンと判定の位置を示す判定ラインを用意します。これらの詳しい作り方については、レイアウトによりかなり異なるのでここでは説明しません。
<img width="400" alt="レーンと判定ライン" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1647235/33f6d264-012b-e664-cff9-894c5a6130ee.png">
このようにレーン等が用意できたら、背景がデフォルトなのも嫌なので後ろに背景のブロックを用意します。
<img width="400" alt="背景を用意" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1647235/8a294603-7d7c-5b64-41ca-f42affb393da.png">
一色塗りが嫌な場合は、ブロックに画像を貼ったり、立体的なオブジェクトを設置するのも良いと思います。この辺りは個人の好みに合わせて調整してください。
しかし、このままではノーツが判定ラインを通り過ぎてもそのまま下に行ってしまい、レーンを飛び出してしまうので、以下のように目隠し（兼HUD表示エリア）を設置しました。
<img width="400" alt="目隠しを用意" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1647235/7771d68d-20a8-0d49-c246-553cd87eb2c1.png">
以上が私の作ったプレイ画面です。

ちなみに、CHUNITHMやプロセカのようにノーツを画面奥から手前に来るようにしたいときは、カメラの角度を上向きにして、位置を調整するといい感じになると思います。レーン自体に角度をつける方法は、この後紹介するノーツを生成するスクリプトの実装が難しくなるので、あまりおすすめはしません。

# CSVデータの読み取り
今回は譜面のデータをCSVで1行に`ノーツを叩く時間(s),ノーツが流れるレーン`と記述し、それをノーツの個数分用意する形で管理しました。

```score.csv
0.000000,1
0.370370,2
0.740741,3
1.111111,4
1.481481,5
1.851852,6
2.222222,1
2.592593,2
2.962963,3
```

まず、Assetsフォルダ内にResourcesフォルダを作成し、その中にこのファイルを入れます。
次に、`GameController.cs`を作り以下のように編集します。

```GameController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;

public class GameController : MonoBehaviour {

    public string filePass;

    TextAsset csvFile;
    private List<float> noteTiming = new List<float>();
    private List<int> noteType = new List<int>();
    private string[] csvDatas = new string[2];

    void Start() {
        LoadScore();
    }

    void LoadScore() {
        csvFile = Resources.Load(filePass) as TextAsset;
        StringReader reader = new StringReader(csvFile.text);

        while(reader.Peek() != -1) {
            string line = reader.ReadLine();
            csvDatas = line.Split(',');

            noteTiming.Add(float.Parse(csvDatas[0]));
            noteType.Add(int.Parse(csvDatas[1]));
        }
    }
}

```

その後、空のGameObject `GameController` を作成してスクリプトを適用します。
すると、このオブジェクトのInspector画面に`File Pass`の項目が出現するので、先ほど作成したCSVファイルのファイル名を入力してください。

このスクリプトには特に出力は用意していませんが、変数`noteTiming`と`noteType`に値が代入されたのちに`Debug.Log`等で値を出力させると、実際に値が代入されていることが確認できます。

# ノーツの生成
CSVで読み取った値をもとに、実際にノーツを生成していきます。
まず、ノーツの原型となるオブジェクト`Note`を作成し、Resourcesファイルへドラッグしてください。（GameObjectのPrefab化）
次に、先ほど作成した`GameController.cs`を以下のように書き換えてください。

```GameController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;

public class GameController : MonoBehaviour {

    public string filePass;
    public Vector3 noteOffset;
    public float laneInterval;
    public float highSpeed;

    TextAsset csvFile;
    private List<float> noteTiming = new List<float>();
    private List<int> noteType = new List<int>();
    private string[] csvDatas = new string[2];

    void Start() {
        LoadScore();
        Initialize();
    }

    void LoadScore() {
        csvFile = Resources.Load(filePass) as TextAsset;
        StringReader reader = new StringReader(csvFile.text);

        while(reader.Peek() != -1) {
            string line = reader.ReadLine();
            csvDatas = line.Split(',');

            noteTiming.Add(float.Parse(csvDatas[0]));
            noteType.Add(int.Parse(csvDatas[1]));
        }
    }

    void Initialize() {
        GameObject obj = (GameObject)Resources.Load("Note");
        int noteLane = 0;
        for(int i = 0; i < noteTiming.Count; i++) {
            string noteName = "Note" + (i+1).ToString();
            noteLane = noteType[i];
            GameObject note = Instantiate(obj, new Vector3(noteOffset.x + (noteLane - 1) * laneInterval, (noteTiming[i] + noteOffset.y) * highSpeed, noteOffset.z), Quaternion.identity);
            note.name = noteName;
        }
    }
}
```

書き換えられたら、`GameController`のInspector画面に追加された項目に以下の値を入力していきます。

- `Note Offset` 0秒、1レーンの位置（初期位置）
- `Lane Interval` レーン間の距離
- `High Speed` ノーツの移動する速さ

これらを設定し、ゲームを実行すると実際にノーツが生成されます。
<img width="400" alt="ノーツ生成" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1647235/06251a6a-e351-ec2a-3b2b-9a2678ba07b4.png">


# ノーツの動作
ノーツを動かす際に、`RididBody.velocity`等を使用すると簡単に記述することができますが、音ゲーというゲームの性質上、必ずしも音楽（＝現実の時間）と同期しないこの機能はあまり適していません。そこで、今回は`Update()`内で各ノーツについて現在の時間から座標を計算し、その地点にオブジェクトを移動させる方法を使用します。

まず、`GameController.cs`を以下のように書き換えます。

```GameController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.IO;

public class GameController : MonoBehaviour {

    public string filePass;
    public Vector3 noteOffset;
    public float laneInterval;
    public float highSpeed;

    TextAsset csvFile;
    public float startTime = 0.0f;
    private List<float> noteTiming = new List<float>();
    private List<int> noteType = new List<int>();
    private string[] csvDatas = new string[2];


    void Awake() {
        LoadScore();
        Initialize();
        startTime = Time.time;
    }

    void LoadScore() {
        csvFile = Resources.Load(filePass) as TextAsset;
        StringReader reader = new StringReader(csvFile.text);

        while(reader.Peek() != -1) {
            string line = reader.ReadLine();
            csvDatas = line.Split(',');

            noteTiming.Add(float.Parse(csvDatas[0]));
            noteType.Add(int.Parse(csvDatas[1]));
        }
    }

    void Initialize() {
        GameObject obj = (GameObject)Resources.Load("Note");
        int noteLane = 0;
        for(int i = 0; i < noteTiming.Count; i++) {
            string noteName = "Note" + (i+1).ToString();
            noteLane = noteType[i];
            GameObject note = Instantiate(obj, new Vector3(noteOffset.x + (noteLane - 1) * laneInterval, (noteTiming[i] + noteOffset.y) * highSpeed, noteOffset.z), Quaternion.identity);
            note.name = noteName;
        }
    }
}
```

曲の開始時間を計測し、ノーツを移動するスクリプトのために`Start()`で行っていた動作を`Awake()`で行うよう変更しました。

次に`NoteMovement.cs`を以下のように作成し、Resourcesフォルダ内の`Note`に適用します。

```NoteMovement.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class NoteMovement : MonoBehaviour
{
    private Vector3 pos;
    private float offset;
    private float songTime;
    private float diffTime;
    private float highSpeed;

    GameController gc;

    void Start() {
        GameObject gcobj = GameObject.Find ("GameController");
        gc = gcobj.GetComponent<GameController>();

        pos = this.transform.position;
        songTime = gc.startTime;
        highSpeed = gc.highSpeed;
    }

    void Update() {
        diffTime = Time.time - songTime;
        pos.y -= diffTime * highSpeed;
        this.transform.position = pos;
        songTime += diffTime;
    }
}
```

そして、ゲームを実行するとこのようにノーツが生成された後、移動を始めます。
この移動の速さは`GameController`の`High Speed`の値によって変更することができます。
<img width="400" alt="ノーツ動作" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1647235/e4d6c415-9efa-a3c1-5c50-7b2e0828d826.gif">


# 今後実装したいこと

- ノーツの判定
- 音楽の再生
- 様々なエフェクト

これらは時間がなく、実装することができませんでした。
ですが、来年度の祇園祭にて完成版を展示することを予定していますので、ご期待ください。
~~出来なかったらすまん~~

P.S.
出来なかった...

#参考にしたサイト
https://mynavi-agent.jp/it/geekroid/2018/02/unity15-3.html


# あとがき
今回初めてQiitaの記事を執筆しました。拙い点も多々あったと思いますが、ここまで閲覧していただきありがとうございました。それでは。

**Happy Holidays!**