メインコンテンツへスキップ

【リズムゲームの作り方】 #11 音ズレの修正

目次
リズムゲームの作り方 - シリーズ
Part 11: この記事

音ズレの訪れ
#

前回まででリズムゲームとして必要な機能はすべて実装しました。

しかし、実際にプレイしてみて、「なんかプレイするたびに微妙に判定がズレている気がする」と思った方もいるのではないでしょうか。今のコードだと、環境によっては体感できるくらいの音ズレが発生する場合があります。

そのため今回は、時間をより正確に管理できるようにコードを少し変えていきます。

音ズレの修正
#

音ズレが発生する原因は、「音楽の再生」の部分にあります。音楽を再生するのは負荷が高い作業で、再生にかかる時間が毎回少しだけずれることによって音ズレが発生します。

そのため、「音楽をできるだけ正確な時間で再生すること」「音楽に合わせて譜面を流すこと」の2つが重要になります。

音楽をできるだけ正確な時間で再生する
#

前回までで書いたコードは、曲の再生をaudioSource.Play()audioSource.PlayDelayedで行っていました。これをより正確に時間を管理するPlayScheduled()で再生することによって正確な時間で再生できるようにします。

MusicPlayerに変更を加えていきます。

MusicPlayer.cs
 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}

chartStartDspTimeaudioSource.PlayScheduled()を使い、正確な時間で再生できるようにしました。

また、遅れのない再生をするためにはバッファとなる時間も必要です。この時間を取るために、startDelaySecとして2秒間のバッファを入れています。

音楽に合わせて譜面を流す
#

これまでは時刻をTime.timeNotesMoverstartTimeを使って管理していました。しかし、リズムゲームのような音楽と画面をぴったり同期させる必要があるゲームではさらに正確に時刻を管理する必要があります。

そこで、音楽を再生するMusicPlayerが正確な時刻を持ち、他のスクリプトがこれを参照するという形にしていきます。

MusicPlayer.cs
 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メソッドを作り、外から参照できるようにしました。これは音楽の正確な時間を返すので、譜面や判定がこれを基準にして動けば音ズレがなくなります。

NotesMoverJudgementManagerがこれを参照して動くようにコードを変更しましょう。

NotesMover.cs
 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を使うようにコードを変更します。

JudgementManager.cs
  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 PlayerNotesMoverJudgementManagerの2箇所)にHierarchyウィンドウのAudio Sourceをドラッグ&ドロップしましょう。

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

MusicPlayerSongOffsetSecを調整してシーンを再生すると、毎回同じタイミングでノーツが降ってきて判定されます!

まとめ
#

これにて、【リズムゲームの作り方】シリーズでのリズムゲーム作りは終了です!お疲れ様でした!

次回は書きたいことを少し書いただけですが、良ければ見ていってください~

リズムゲームの作り方 - シリーズ
Part 11: この記事