音ズレの訪れ #
前回まででリズムゲームとして必要な機能はすべて実装しました。
しかし、実際にプレイしてみて、「なんかプレイするたびに微妙に判定がズレている気がする」と思った方もいるのではないでしょうか。今のコードだと、環境によっては体感できるくらいの音ズレが発生する場合があります。
そのため今回は、時間をより正確に管理できるようにコードを少し変えていきます。
音ズレの修正 #
音ズレが発生する原因は、「音楽の再生」の部分にあります。音楽を再生するのは負荷が高い作業で、再生にかかる時間が毎回少しだけずれることによって音ズレが発生します。
そのため、「音楽をできるだけ正確な時間で再生すること」「音楽に合わせて譜面を流すこと」の2つが重要になります。
音楽をできるだけ正確な時間で再生する #
前回までで書いたコードは、曲の再生をaudioSource.Play()やaudioSource.PlayDelayedで行っていました。これをより正確に時間を管理するPlayScheduled()で再生することによって正確な時間で再生できるようにします。
MusicPlayerに変更を加えていきます。
1using UnityEngine;
2
3public class MusicPlayer : MonoBehaviour
4{
5 [SerializeField] float songOffsetSec;
6 float startDelaySec = 2.0f;
7 double chartStartDspTime;
8
9 void Start()
10 {
11 AudioSource audioSource = GetComponent<AudioSource>();
12 chartStartDspTime = AudioSettings.dspTime + startDelaySec;
13
14 if (songOffsetSec >= 0f)
15 {
16 audioSource.time = 0f;
17 audioSource.PlayScheduled(chartStartDspTime + songOffsetSec);
18 }
19 else
20 {
21 float seekTime = Mathf.Min(-songOffsetSec, audioSource.clip.length - 0.01f);
22 audioSource.time = Mathf.Max(0f, seekTime);
23 audioSource.PlayScheduled(chartStartDspTime);
24 }
25 }
26}chartStartDspTimeとaudioSource.PlayScheduled()を使い、正確な時間で再生できるようにしました。
また、遅れのない再生をするためにはバッファとなる時間も必要です。この時間を取るために、startDelaySecとして2秒間のバッファを入れています。
音楽に合わせて譜面を流す #
これまでは時刻をTime.timeやNotesMoverのstartTimeを使って管理していました。しかし、リズムゲームのような音楽と画面をぴったり同期させる必要があるゲームではさらに正確に時刻を管理する必要があります。
そこで、音楽を再生するMusicPlayerが正確な時刻を持ち、他のスクリプトがこれを参照するという形にしていきます。
1using UnityEngine;
2
3public class MusicPlayer : MonoBehaviour
4{
5 [SerializeField] float songOffsetSec;
6 float startDelaySec = 2.0f;
7 double chartStartDspTime;
8
9 void Start()
10 {
11 AudioSource audioSource = gameObject.GetComponent<AudioSource>();
12 chartStartDspTime = AudioSettings.dspTime + startDelaySec;
13
14 if (songOffsetSec >= 0f)
15 {
16 audioSource.time = 0f;
17 audioSource.PlayScheduled(chartStartDspTime + songOffsetSec);
18 }
19 else
20 {
21 float seekTime = Mathf.Min(-songOffsetSec, audioSource.clip.length - 0.01f);
22 audioSource.time = Mathf.Max(0f, seekTime);
23 audioSource.PlayScheduled(chartStartDspTime);
24 }
25 }
26
27 public float ElapsedChartTime()
28 {
29 return Mathf.Max(0f, (float)(AudioSettings.dspTime - chartStartDspTime));
30 }
31}ElapsedChartTimeメソッドを作り、外から参照できるようにしました。これは音楽の正確な時間を返すので、譜面や判定がこれを基準にして動けば音ズレがなくなります。
NotesMoverやJudgementManagerがこれを参照して動くようにコードを変更しましょう。
1using UnityEngine;
2
3public class NotesMover : MonoBehaviour
4{
5 public float noteSpeed;
6 [SerializeField] MusicPlayer musicPlayer;
7
8 void Start()
9 {
10
11 }
12
13 void Update()
14 {
15 float elapsedTime = musicPlayer.ElapsedChartTime();
16 transform.position = new(0.0f, 0.0f, -noteSpeed * elapsedTime);
17 }
18}JudgementManagerでは、これまで使っていたnotesMover変数は不要になるため削除し、新しくmusicPlayerを使うようにコードを変更します。
1using System.Collections.Generic;
2using UnityEngine;
3using UnityEngine.InputSystem;
4
5public class JudgementManager : MonoBehaviour
6{
7 List<NoteInfo> noteInfoList = new();
8
9 [SerializeField] TextController textController;
10 [SerializeField] MusicPlayer musicPlayer;
11
12 void Start()
13 {
14 noteInfoList = GetComponent<NotesGenerator>().noteInfoList;
15 }
16
17 void Update()
18 {
19 JudgeLostNote();
20
21 // 現在のキーボード情報
22 var current = Keyboard.current;
23
24 // キーボードが接続されていなければ無視
25 if (current == null)
26 {
27 return;
28 }
29
30 // Dキーが押された瞬間なら
31 if (current.dKey.wasPressedThisFrame)
32 {
33 Judge(1);
34 }
35
36 // Fキーが押された瞬間なら
37 if (current.fKey.wasPressedThisFrame)
38 {
39 Judge(2);
40 }
41
42 // Jキーが押された瞬間なら
43 if (current.jKey.wasPressedThisFrame)
44 {
45 Judge(3);
46 }
47
48 // Kキーが押された瞬間なら
49 if (current.kKey.wasPressedThisFrame)
50 {
51 Judge(4);
52 }
53 }
54
55 void Judge(int lane)
56 {
57 // 最も近いノーツを探す
58 NoteInfo nearestNote = FindNearestNote(lane);
59
60 // もしノーツが見つからなかったら何もしない
61 if (nearestNote == null)
62 {
63 return;
64 }
65
66 // ノーツのベストタイミングを渡して、判定評価を取得する
67 string evaluation = GetNoteEvaluation(nearestNote.time);
68
69 // 判定対象外(NonTarget)でなければ、判定結果をコンソールに表示し、対応するノーツを削除する
70 if (evaluation != "NonTarget")
71 {
72 Debug.Log($"レーン{lane}: {evaluation}!");
73 nearestNote.gameObject.SetActive(false);
74 noteInfoList.Remove(nearestNote);
75 textController.UpdateTexts(evaluation);
76 }
77 }
78
79 NoteInfo FindNearestNote(int lane)
80 {
81 // リズムゲーム開始からの経過時間を求める
82 float elapsedTime = musicPlayer.ElapsedChartTime();
83
84 NoteInfo nearestNote = null;
85 float minDiff = float.MaxValue;
86
87 // 1. リストに入っているすべてのノーツに対して処理
88 foreach (NoteInfo noteInfo in noteInfoList)
89 {
90 // 2. レーンが一致していなければ無視
91 if (noteInfo.lane != lane)
92 {
93 continue;
94 }
95
96 // 3. 時間のズレを計算
97 float currentDiffTime = Mathf.Abs(elapsedTime - noteInfo.time);
98
99 // 4. 今までの最小のズレより小さければ更新
100 if (currentDiffTime < minDiff)
101 {
102 minDiff = currentDiffTime;
103 nearestNote = noteInfo;
104 }
105 }
106
107 return nearestNote;
108 }
109
110 string GetNoteEvaluation(float noteTime)
111 {
112 // 時間のズレを計算
113 float elapsedTime = musicPlayer.ElapsedChartTime();
114 float diffTime = Mathf.Abs(elapsedTime - noteTime);
115
116 // 判定評価を返す
117 if (diffTime < 0.05f)
118 {
119 return "Perfect";
120 }
121 else if (diffTime < 0.1f)
122 {
123 return "Good";
124 }
125 else if (diffTime < 0.15f)
126 {
127 return "Miss";
128 }
129 else
130 {
131 return "NonTarget";
132 }
133 }
134
135 void JudgeLostNote()
136 {
137 // 1. スルーされた(Missになった)ノーツを探してリストに一時保存する
138 List<NoteInfo> lostNotes = new();
139 float elapsedTime = musicPlayer.ElapsedChartTime();
140
141 foreach (NoteInfo noteInfo in noteInfoList)
142 {
143 float diffTime = elapsedTime - noteInfo.time;
144 // 判定線を過ぎていて、かつ判定範囲外まで離れてしまったノーツ
145 if (diffTime > 0.0f && GetNoteEvaluation(noteInfo.time) == "NonTarget")
146 {
147 lostNotes.Add(noteInfo);
148 }
149 }
150
151 // 2. 見つかったノーツをMiss扱いにして画面とリストから消す
152 foreach (NoteInfo lostNote in lostNotes)
153 {
154 Debug.Log($"レーン{lostNote.lane}: Miss!");
155 lostNote.gameObject.SetActive(false);
156 noteInfoList.Remove(lostNote);
157 textController.UpdateTexts("Miss");
158 }
159 }
160}elapsedTimeを、Time.time - startTimeではなく、musicPlayer.ElapsedChartTime()を参照するように書き換えました。
コードを書き換えたら、HierarchyウィンドウでNotesParentを選択して、InspectorウィンドウのMusic Player(NotesMoverとJudgementManagerの2箇所)にHierarchyウィンドウのAudio Sourceをドラッグ&ドロップしましょう。

また、HierarchyウィンドウでAudio Sourceを選択して、Song Offset Secを調整しましょう。私の環境では-0.15でちょうど良さそうでした。

MusicPlayerのSongOffsetSecを調整してシーンを再生すると、毎回同じタイミングでノーツが降ってきて判定されます!
まとめ #
これにて、【リズムゲームの作り方】シリーズでのリズムゲーム作りは終了です!お疲れ様でした!
次回は書きたいことを少し書いただけですが、良ければ見ていってください~