Edited at

【Unity】音ゲーの仕組みを学び「〇〇の達人」をUnityで作る パート1


はじめに

最近「〇〇の達人」にはまってしまい、音ゲーってどうやって作ってるのだろう?

そんな疑問からUnityで作ってみることにしました。

Unityでのボタンの配置、ゲームオブジェクト作成等、基本的な操作はできる想定で説明していきます。

音ゲーについての知識が全くない状態で作ります。

なので実装において非効率な箇所等出てくると思いますが、知っている方はコメントで指摘していただけると嬉しいです。

パート2、パート3はこちら

【Unity】音ゲーの仕組みを学び「〇〇の達人」をUnityで作る パート2

【Unity】音ゲーの仕組みを学び「〇〇の達人」をUnityで作る パート3


音ゲーとは

音ゲーは、音符がどこからか流れてきて、タイミングよく弾くことでスコアが伸びていくゲームです

また、音符はノーツと呼ばれ、曲で出てくるノーツの情報が詰まったものを譜面といいます。

つまり、音ゲーは1曲ごとに譜面データと音楽データが存在していて、プレイヤーは音楽データを聴きながら、譜面をもとに流れてくるノーツを弾いているということになります。

ここで肝となるロジックは、譜面からノーツを出現させるロジック、ノーツを動かすロジック、プレイヤーがノーツをタイミングよく弾いたかチェックするロジックです


プロジェクトの準備


Unityでプロジェクトを作成する

3Dモードではなく、2Dモードでプロジェクトを作成してください。


使用するライブラリを導入する

アセットストアからUniRxをインポートしましょう。

UniRxでReactive Extensionsを導入し、マウスクリックなどイベントを検知し関数型的にプログラムを書くことができます。詳しく説明している記事あったので貼っときます。

こわくないReactive Extensions超入門

githubからMiniJsonJsonNodeを落とし、「Assets → Plugins」 に「MiniJson」、「JsonNode」フォルダを作成し設置してください。

MiniJsonとJsonNodeを使って、譜面のJsonファイルを読み込ませます


譜面を作る


曲のテンポについて

譜面データを作成する前に、少し音楽のテンポについて勉強しましょう。

曲にはbpm(beats per minute)という曲のテンポを表現している指標があります。

bpmは1分間に拍数(音符のこと)が何個入るかの数値で、ノーツはbpmを参考に配置されています

例えば、bpm100の曲は、60 * 1000 / 100 = 600で、600(ms)単位でノーツを配置していくことになります。

ただしこれだけでは、ノーツ同士の間隔が広い上にノーツの数が少ないため面白くありません。なのでノーツを等分割して連符にすることで、間隔を狭く、数を増やします。

例えば、3連符にすると、600 / 3で、200(ms)単位でノーツを配置していくことになります。

音楽の知識がないため、間違っている可能性があるので鵜呑みにはしないでください


譜面データをJson形式で作る

譜面データには、曲全体の情報として、「タイトル」、「bpm」を書きます。

ノーツの情報として、「タイプ」、「タイミング(ms)」を書きます。

譜面データを適当にjson形式で作成してみましょう。

ここで一つ条件があります。timingは必ず"2000"以上にしてください、設計上そうしないと動きが変になってしまいます。

Unity(MiniJson・JsonNode)でJsonファイルを読み込む際、文字列にしないエラーになってしまうので、数値も文字列にしています。


Sample.json

{

"title": "sample",
"bpm": "120",
"notes": [
{"timing": "5000", "type": "don"},
{"timing": "5250", "type": "ka"},
{"timing": "5500", "type": "ka"},
{"timing": "6000", "type": "don"},
{"timing": "6250", "type": "don"},
{"timing": "6500", "type": "don"},
{"timing": "7000", "type": "don"},
{"timing": "7250", "type": "don"},
{"timing": "7500", "type": "don"},
{"timing": "7750", "type": "ka"}
]
}


GameManagerを作る

GameManagerではゲーム全体の管理をさせます。

肝となるロジックである譜面からノーツを出現させるロジックとノーツを動かすロジックの一部分(ノーツを動かし始めるタイミングの管理)が含まれます


譜面からノーツを出現させるロジックの実装

「Plugin → Scripts」にGameManager.csを作成します。


GameManager.cs

using System;

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UniRx;
using UniRx.Triggers;

public class GameManager : MonoBehaviour {

[SerializeField] string FilePath;

[SerializeField] Button Play;
[SerializeField] Button SetChart;

[SerializeField] GameObject Don;
[SerializeField] GameObject Ka;

[SerializeField] Transform SpawnPoint;
[SerializeField] Transform BeatPoint;

string Title;
int BPM;
List<GameObject> Notes;

void OnEnable() {
Play.onClick
.AsObservable()
.Subscribe(_ => play());

SetChart.onClick
.AsObservable()
.Subscribe(_ => loadChart());
}

void loadChart() {
Notes = new List<GameObject>();

string jsonText = Resources.Load<TextAsset>(FilePath).ToString();

JsonNode json = JsonNode.Parse(jsonText);
Title = json["title"].Get<string>();
BPM = int.Parse(json["bpm"].Get<string>());

foreach(var note in json["notes"]) {
string type = note["type"].Get<string>();
float timing = float.Parse(note["timing"].Get<string>());

GameObject Note;
if (type == "don") {
Note = Instantiate(Don, SpawnPoint.position, Quaternion.identity);
} else if (type == "ka") {
Note = Instantiate(Ka, SpawnPoint.position, Quaternion.identity);
} else {
Note = Instantiate(Don, SpawnPoint.position, Quaternion.identity); // default don
}

Notes.Add(Note);
}
}

void play() {
Debug.Log("Game Start!");
}
}


ヒエラルキーから値をセットできるように、filePath、Don、Ka、Play、SetChart、SpawnPointを作成する

FilePath ・・・ 譜面のJsonファイルのパス

Don ・・・ ノーツ

Ka ・・・ ノーツ

Play ・・・ ゲーム開始ボタン

SetChart ・・・ 楽譜を読み込むボタン

SpawnPoint ・・・ ノーツ配置位置

BeatPoint ・・・ ノーツを弾く位置

関数loadChart実行時、値がセットされるように、Title、BPM、Notesを作成する

Title ・・・ 曲のタイトル

BPM ・・・ 曲のBPM

Notes ・・・ 1曲分のノーツ全てが入った配列

Unityでは「Assets → Resources」にファイルを置くことで、スクリプトからResources.Load<TextAsset>(filePath).ToString()で文字列を取得することができます

先ほど作成した譜面のJsonを「Assets → Resources → Charts」にSample.jsonという名前で保存しておきましょう。

foreach文の中では、ノーツのタイプをチェックし、DonもしくはKaのGameObjectをSpawnPointの位置に新しく作成し、Notesに追加しています

foreach(var note in json["notes"]) {

string type = note["type"].Get<string>();
float timing = float.Parse(note["timing"].Get<string>());

GameObject Note;
if (type == "don") {
Note = Instantiate(Don, SpawnPoint);
} else if (type == "ka") {
Note = Instantiate(Ka, SpawnPoint);
} else {
Note = Instantiate(Don, SpawnPoint); // default don
}

Notes.Add(Note);
}

ボタンを押した時に、譜面を読み込む関数実行させるようにする

[ボタンオブジェクト].onClick.AsObservable().Subscribe(_ => 実行したい関数)で、オブザーバーがボタンを押したイベントを検知し、関数を実行してくれる。

void OnEnable() {

SetChart.onClick
.AsObservable()
.Subscribe(_ => loadChart());
}


オブジェクトを作成する

GameManagerという名前でGameObjectを作成し、GameManager(Scripts)をアタッチしてください。

右から左へノーツを流す想定なので、画面外の右側にSpawnPointという名前で、画面内の左側にBeatPointという名前でGameobject作成してください。

SpawnPointがノーツの初期位置、BeatPointがノーツを弾く位置になります

スクリーンショット 2019-01-30 21.12.48.png

「Assets → Prefabs」にDonという名前のPrefabとKaという名前のPrefabを作成しましょう。

DonとKaはノーツになります

スクリーンショット 2019-01-30 0.11.37.png

自分のノーツはアウトです。みなさんはまねしないでください(イラレで作成)。

UIにSetChart、Playという名前のButtonを設置してください。

SetChartを押すことで譜面データを読み込ませます

スクリーンショット 2019-01-30 20.59.07.png

最後に、GameManagerのGameManager(Script)の変数をセットしましょう。

FilePathは譜面のJsonが設置しているパスを直接入力してください。

他は、ドラッグアンドドロップで登録していってください。

スクリーンショット 2019-01-30 21.15.32.png

ノーツを動かすロジックの一部分(ノーツを動かし始めるタイミングの管理)の実装は後で行います。


NoteControllerを作る

NoteControllerではノーツの動きの管理をさせます。

肝となるロジックであるノーツを動かすロジックが含まれます


ノーツを動かすロジックの実装

「Plugin → Scripts」にNoteController.csを作成します。


NoteController.cs

using System;

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using UniRx.Triggers;

public class NoteController : MonoBehaviour {
string Type;
float Timing;

float Distance;
float During;

Vector3 firstPos;
bool isGo;
float GoTime;

void OnEnable() {
isGo = false;
firstPos = this.transform.position;

this.UpdateAsObservable()
.Where(_ => isGo)
.Subscribe(_ => {
this.gameObject.transform.position = new Vector3 (firstPos.x - Distance * (Time.time * 1000 - GoTime)/During, firstPos.y, firstPos.z);
});
}

public void setParameter(string type, float timing) {
Type = type;
Timing = timing;
}

public string getType() {
return Type;
}

public float getTiming() {
return Timing;
}

public void go(float distance, float during) {
Distance = distance;
During = during;
GoTime = Time.time * 1000;

isGo = true;
}
}


ノーツの移動スピード、発射タイミングを管理するため、Type、Timing、Distance、During、firstPos、isGo、GoTimeを作成する

Type ・・・ ノーツのタイプ

Timing ・・・ ノーツを発射させるタイミング

Distance ・・・ ノーツの初期位置から弾く位置までの距離

During ・・・ ノーツの初期位置から弾く位置までにかける時間

firstPos ・・・ ノーツの初期位置

isGo ・・・ ノーツが発射しているか

TypeとTimingは譜面データに、DistanceとDuringはGameManagerが管理しているので、GameManagerから値を登録できるようにしています。

setParameterはノーツを出現させるときに、goはノーツ発射のタイミングでGameManagerから実行させます

public void setParameter(string type, float timing) {

Type = type;
Timing = timing;
}

...

public void go(float distance, float during) {
Distance = distance;
During = during;
GoTime = Time.time * 1000;

isGo = true;
}

this.UpdateAsObservable()Update関数と同じ働きをします。

ここでは、ノーツが発射しているかをチェックし、発射していればノーツの位置を計算して動かしています

void OnEnable() {

isGo = false;
firstPos = this.transform.position;

this.UpdateAsObservable()
.Where(_ => isGo)
.Subscribe(_ => {
this.gameObject.transform.position = new Vector3 (firstPos.x - Distance * (Time.time * 1000 - GoTime)/During, firstPos.y, firstPos.z);
});
}


ノーツのプレハブにNoteController(Script)をアタッチする

DonとKaに作成したNoteController(Script)をアタッチしてください。

スクリーンショット 2019-02-01 15.13.55.png


GameManagerに追記

先ほど作成したNoteControllerのsetParametergoを用いて、実際にノーツが動くようにします


GameManager.cs

using System;

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UniRx;
using UniRx.Triggers;

public class GameManager : MonoBehaviour {

[SerializeField] string FilePath;

[SerializeField] Button Play;
[SerializeField] Button SetChart;

[SerializeField] GameObject Don;
[SerializeField] GameObject Ka;

[SerializeField] Transform SpawnPoint;
[SerializeField] Transform BeatPoint;

// ノーツを動かすために必要になる変数を追加
float PlayTime;
float Distance;
float During;
bool isPlaying;
int GoIndex;

string Title;
int BPM;
List<GameObject> Notes;

void OnEnable() {
// 追加した変数に値をセット
Distance = Math.Abs(BeatPoint.position.x - SpawnPoint.position.x);
During = 2 * 1000;
isPlaying = false;
GoIndex = 0;

Debug.Log(Distance);

Play.onClick
.AsObservable()
.Subscribe(_ => play());

SetChart.onClick
.AsObservable()
.Subscribe(_ => loadChart());

// ノーツを発射するタイミングかチェックし、go関数を発火
this.UpdateAsObservable()
.Where(_ => isPlaying)
.Where(_ => Notes.Count > GoIndex)
.Where(_ => Notes[GoIndex].GetComponent<NoteController>().getTiming() <= ((Time.time * 1000 - PlayTime) + During))
.Subscribe(_ => {
Notes[GoIndex].GetComponent<NoteController>().go(Distance, During);
GoIndex++;
});
}

void loadChart() {
Notes = new List<GameObject>();

string jsonText = Resources.Load<TextAsset>(FilePath).ToString();

JsonNode json = JsonNode.Parse(jsonText);
Title = json["title"].Get<string>();
BPM = int.Parse(json["bpm"].Get<string>());

foreach(var note in json["notes"]) {
string type = note["type"].Get<string>();
float timing = float.Parse(note["timing"].Get<string>());

GameObject Note;
if (type == "don") {
Note = Instantiate(Don, SpawnPoint.position, Quaternion.identity);
} else if (type == "ka") {
Note = Instantiate(Ka, SpawnPoint.position, Quaternion.identity);
} else {
Note = Instantiate(Don, SpawnPoint.position, Quaternion.identity); // default don
}

// setParameter関数を発火
Note.GetComponent<NoteController>().setParameter(type, timing);

Notes.Add(Note);
}
}

// ゲーム開始時に追加した変数に値をセット
void play() {
PlayTime = Time.time * 1000;
isPlaying = true;
Debug.Log("Game Start!");
}
}


ノーツを動かすために必要になる変数を管理するため、PlayTime、Distance、During、isPlaying、GoIndexを作成する

PlayTime ・・・ ゲーム開始時の時間

Distance ・・・ ノーツの初期位置から弾く位置までの距離

During ・・・ ノーツの初期位置から弾く位置までにかける時間

isPlaying ・・・ ゲーム中か

GoIndex ・・・ Notesの発射対象のノーツのインデックス

今回はノーツの初期位置から弾く位置までにかける時間を2000(ms)として、GameManager読み込み時に変数に値をセット。

// 追加した変数に値をセット

Distance = Math.Abs(BeatPoint.position.x - SpawnPoint.position.x);
During = 2 * 1000;
isPlaying = false;
GoIndex = 0;

ゲーム開始時のゲーム時間をPlayTimeにセットします。

また isPlayingをtrueにし、OnEnableで書かれているthis.UpdateAsObservable()を動かし始めます。

// ゲーム開始時に追加した変数に値をセット

void play() {
PlayTime = Time.time * 1000;
isPlaying = true;
Debug.Log("Game Start!");
}

isPlayingがtrueになったタイミングで、発射するノーツがあるかチェック、発射させるタイミングかどうかをチェック、全てのチェックを通れば実際にNoteControllerのgoを発火させています

// ノーツを発射するタイミングかチェックし、go関数を発火

this.UpdateAsObservable()
.Where(_ => isPlaying)
.Where(_ => Notes.Count > GoIndex)
.Where(_ => Notes[GoIndex].GetComponent<NoteController>().getTiming() <= ((Time.time * 1000 - PlayTime) + During))
.Subscribe(_ => {
Notes[GoIndex].GetComponent<NoteController>().go(Distance, During);
GoIndex++;
});


動きを確認する

ここまで実装できれば、実際に動かしてみましょう。

音もならないし、ノーツを弾くこともできないけれども、ノーツが流れていくのはみれるはずです。

taiko.mov.gif


最後に

音ゲーはタイミングが命なので、できる限りずれが生じないように実装しなければいけません。

UnityはGameObjectを作るときの処理が重いので、ゲーム開始前にノーツを読み込ませる等ちょっとした工夫がとても大切なので意識しておきましょう。

次回は実際にノーツを弾けるようにします。

【Unity】音ゲーの仕組みを学び「〇〇の達人」をUnityで作る パート2