はじめに
最近「〇〇の達人」にはまってしまい、音ゲーってどうやって作ってるのだろう?
そんな疑問からUnityで作ってみることにしました。
Unityでのボタンの配置、ゲームオブジェクト作成等、基本的な操作はできる想定で説明していきます。
音ゲーについての知識が全くない状態で作ります。
なので実装において非効率な箇所等出てくると思いますが、知っている方はコメントで指摘していただけると嬉しいです。
パート2、パート3はこちら
【Unity】音ゲーの仕組みを学び「〇〇の達人」をUnityで作る パート2
【Unity】音ゲーの仕組みを学び「〇〇の達人」をUnityで作る パート3
音ゲーとは
音ゲーは、音符がどこからか流れてきて、タイミングよく弾くことでスコアが伸びていくゲームです。
また、音符はノーツと呼ばれ、曲で出てくるノーツの情報が詰まったものを譜面といいます。
つまり、音ゲーは1曲ごとに譜面データと音楽データが存在していて、プレイヤーは音楽データを聴きながら、譜面をもとに流れてくるノーツを弾いているということになります。
ここで肝となるロジックは、譜面からノーツを出現させるロジック、ノーツを動かすロジック、プレイヤーがノーツをタイミングよく弾いたかチェックするロジックです。
プロジェクトの準備
Unityでプロジェクトを作成する
3Dモードではなく、2Dモードでプロジェクトを作成してください。
使用するライブラリを導入する
アセットストアからUniRxをインポートしましょう。
UniRxでReactive Extensionsを導入し、マウスクリックなどイベントを検知し関数型的にプログラムを書くことができます。詳しく説明している記事あったので貼っときます。
githubからMiniJson、JsonNodeを落とし、「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ファイルを読み込む際、文字列にしないエラーになってしまうので、数値も文字列にしています。
{
"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を作成します。
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がノーツを弾く位置になります。
「Assets → Prefabs」にDonという名前のPrefabとKaという名前のPrefabを作成しましょう。
DonとKaはノーツになります。
※自分のノーツはアウトです。みなさんはまねしないでください(イラレで作成)。
UIにSetChart、Playという名前のButtonを設置してください。
SetChartを押すことで譜面データを読み込ませます。
最後に、GameManagerのGameManager(Script)の変数をセットしましょう。
FilePathは譜面のJsonが設置しているパスを直接入力してください。
他は、ドラッグアンドドロップで登録していってください。
ノーツを動かすロジックの一部分(ノーツを動かし始めるタイミングの管理)の実装は後で行います。
NoteControllerを作る
NoteControllerではノーツの動きの管理をさせます。
肝となるロジックであるノーツを動かすロジックが含まれます。
ノーツを動かすロジックの実装
「Plugin → Scripts」に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)をアタッチしてください。
GameManagerに追記
先ほど作成したNoteControllerのsetParameter
とgo
を用いて、実際にノーツが動くようにします。
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++;
});
動きを確認する
ここまで実装できれば、実際に動かしてみましょう。
音もならないし、ノーツを弾くこともできないけれども、ノーツが流れていくのはみれるはずです。
最後に
音ゲーはタイミングが命なので、できる限りずれが生じないように実装しなければいけません。
UnityはGameObjectを作るときの処理が重いので、ゲーム開始前にノーツを読み込ませる等ちょっとした工夫がとても大切なので意識しておきましょう。
次回は実際にノーツを弾けるようにします。