《Unityで作るリズムゲーム》學習筆記(八):「曲目播放」

書名:《Unityで作るリズムゲーム》

作者:長崎大学マルチメディア研究会

播放曲目

  • 遊戲的效果音可以通過AudioSource.PlayClipAtPoint來播放,在NoteBaseController中添加一個AudioClip,並在OnKeyDown和OnKeyUp的時候進行調用

    • public abstract class NoteControllerBase : MonoBehaviour
      {
         //...
        [SerializeField] protected AudioClip clipHit = null; //效果音
      }
    • //SingleNoteController
      public override void OnKeyDown(JudgementType judgementType)
      {
         //...
         if(judgementType != JudgementType.MISS)
        {
             AudioSource.PlayClipAtPoint(clipHit, transform.position);
             //...
        }
      }

      //LongNoteController
      public override void OnKeyDown(JudgementType judgementType)
      {
         //...
         if(judgementType != JudgementType.MISS)
        {
             AudioSource.PlayClipAtPoint(clipHit, transform.position);
             //...
        }
      }
      public override void OnKeyUp(JudgementType judgementType)
      {
         //...
         AudioSource.PlayClipAtPoint(clipHit, transform.position);
         //...
      }
  • 曲目具體的樂曲播放

    • 在bms文件中,有專門播放音源的命令,通過把音源加到BGM通道上,從而把播放音源的命令添加到bms文件中。在把音源和對應的音源物件分配好後,bms文檔會多出一段指向音源文件的代碼

    • 在本案例中,編號01是音源文件的通道,只要把音樂文件分配到#WAV01號中,然後把01對象放置在BGM通道中,音樂即可播放

    • 音源添加流程

      • 把音源文件放到Beatmap的文件夾中

      • 創建一個Bms文件,並在#WAV區域內,把音源文件放到編號01的格子中

      • 把編號01的物體設置在BPM軌道的第0小節開始位置上

    • 該Bms文件的表述為:

      • *---------------------- HEADER FIELD

        #PLAYER 1
        #GENRE Electric
        #TITLE Vitality t+pazolite Remix
        #ARTIST t+pazolite & Mittsies
        #BPM 175
        #PLAYLEVEL 9
        #RANK 1


        #LNTYPE 1

        #WAV01 Vitality.wav


        *---------------------- MAIN DATA FIELD


        #00001:01
      • 音源文件名

        • #WAV01 Vitality.wav
      • 具體數據信息

        • #00001:01
          • 000小節

          • BGM通道(通道編號01)

          • 物體本身(物體編號01)

  • 讀取音源信息及文件
    • BmsLoader

      • Header正則部分添加一個讀取音源信息的正則表達式

        • //Header數據格式的正則表達式
          static List<string> headerPattern = new List<string>
          {
             //...
             @"#(WAV[0-9A-Z]{2}) (.*)" //對應WAV00-ZZ
          };
      • 新增一個BGM的數據類型以及對應的處理分支,在分支中,得到音源播放的偏移量

        • (「音源開始播放的拍子與第0拍的差」這個信息轉換成對應的秒數 = 音源開始播放的具體時間偏移量)

        • //數據(Note)類型
          public enum DataType
          {
             UNSUPPORTED,
             SINGLE_NOTE, //1L, L = Lane
             LONG_NOTE, //5L, L = Lane
             DIRECT_TEMPO_CHANGE, //直接指定型BPM變化:03
             INDEXED_TEMPO_CHANGE, //索引指定型BPM變化:08
             MEASURE_CHANGE, //拍子變化:02
             BGM //音源:01
          }

          //通過通道編號來得到數據類型
          DataType GetDataType(string channel)
          {
             if (channel[0] == '1') { return DataType.SINGLE_NOTE; }
             else if (channel[0] == '5') { return DataType.LONG_NOTE; }
             else if (channel == "02") { return DataType.MEASURE_CHANGE; }
             else if (channel == "03") { return DataType.DIRECT_TEMPO_CHANGE; }
             else if(channel == "08") { return DataType.INDEXED_TEMPO_CHANGE; }
             else if(channel == "01") { return DataType.BGM; }
             else { throw new Exception($"DataType Not Found With Channel: {channel}"); }
          }

          void LoadMainDataLine(string line)
          {
             //...
             //Note類型/BPM變化類型/音源播放類型
             else if(dataType == DataType.SINGLE_NOTE || dataType == DataType.LONG_NOTE ||
                     dataType == DataType.DIRECT_TEMPO_CHANGE || dataType == DataType.INDEXED_TEMPO_CHANGE ||
                     dataType == DataType.BGM)
            {
                 //...
                 //數據為BGM類型
                 else if(dataType == DataType.BGM)
                {
                     //「音源開始播放的拍子與第0拍的差」這個信息轉換成對應的秒數 = 音源開始播放的具體時間偏移量                      
                     audioOffset = BeatMap.BeatToSecWithVarTempo(beat, tempoChanges);
                }
            }
          }
    • Beatmap

      • 現在的音源文件名保存了在headerData[“WAV01”]中,我們可以通過該音源文件名,加上之前保留下來的bms文件夾絕對路徑,得出音源的絕對路徑。

        • public string audioFilePath = ""; //樂曲文件絕對路徑
          public float audioOffset; //音源播放偏移

          // 實例化BeatMap時讓BmsLoader讀取並解析bms文件
          public BeatMap(string filePath)
          {
             //bms文件夾的絕對路徑
             string bmsDirectory = new FileInfo(filePath).DirectoryName;
             //...
             //從bms文件中的HeaderData中得到音源文件名,讀取音源文件路徑
             if(bmsLoader.headerData.ContainsKey("WAV01"))
            {
                 //音源文件絕對路徑            
                 audioFilePath = bmsDirectory + "\\" + bmsLoader.headerData["WAV01"];    
            }
             //...
          }
    • PlayController

      • 添加AudioSource用以播放音源,並根據”file:///” + 音源文件路徑,並通過UnityWebRequestMultimedia.GetAudioClip得到本地的音源文件

        • 注意!只可以加載.wav/.ogg格式的文件,且文件名不能有特殊字符

        • IEnumerator LoadAudioFile(string filePath)
          {
             if (!File.Exists(filePath)) { yield break; }  

             var audioType = GetAudioType(filePath);        

             //讀取音頻文件(注意!只能讀取wav/ogg,且文件名不能有任何特殊字符,如空格、下划線等等)
             using (var request = UnityWebRequestMultimedia.GetAudioClip(
                 "file:///" + filePath, audioType))
            {            
                 yield return request.SendWebRequest();

                 if (!request.isNetworkError)
                {
                     //得到音頻文件
                     var audioClip = DownloadHandlerAudioClip.GetContent(request);
                     audioSource.clip = audioClip;
                }
            }                    
          }

          //只能處理Ogg或Wav
          AudioType GetAudioType(string filePath)
          {
             string ext = Path.GetExtension(filePath).ToLower();
             Debug.Log(ext);
             switch (ext)
            {
                 case ".ogg":
                     return AudioType.OGGVORBIS;                
                 case ".wav":
                     return AudioType.WAV;
                 default:
                     return AudioType.UNKNOWN;                
            }
          }
      • 添加一個startSecond記錄遊戲開始時的時間,以及一個標志位表示遊戲是否暫停。通過這兩個字段實現遊戲的暫停與重啟:

        • 遊戲暫停(AudioSettings.dspTime也會停止計算)時,會把開始秒數等同於當前Time.time;再開時,會使音頻在AudioSettings.dspTime的基礎上,加上譜面的偏移和音源的偏移後繼續,使音樂在正確的位置再開。

        • 同樣地,由於當前遊戲經過時間 = Time.time – 譜面偏移 – 開始時間,暫停時,開始時間 = Time.time,因此,當前遊戲經過會被設置為「譜面偏移」,間接使遊戲時間停止,同時確保基於當前拍子數(由當前經過秒數轉換)notes的位置正確

        • //...
          float startSecond = 0f; //譜面播放開始後秒數(用於暫停播放)
          bool isPlaying = false; //譜面暫停標志符

          private void Update()
          {
             //開始播放譜面和曲目
             if(!isPlaying && Input.GetKeyDown(KeyCode.Space))
            {
                 isPlaying = true;

                 //等待指定秒數(譜面開始時間 + 音源播放的偏移)後播放音源
                 //暫停時也要利用AudioSettings來暫停,它代表了音樂系統的時間            
                 audioSource.PlayScheduled(
                     AudioSettings.dspTime + startOffset + beatMap.audioOffset
                );
            }
             //暫停中,譜面開始播放秒數與Time.time保持一致
             //也就是說等同於譜面沒有開始
             if (!isPlaying)
            {            
                 startSecond = Time.time;
            }

             //當前已經過秒數等於 = 當前秒數 - 譜面下落的偏移 - 譜面開始時間
             currentSec = Time.time - startOffset - startSecond;

             //把當前秒數轉換成拍子
             currentBeat = BeatMap.SecToBeatWithVarTempo(currentSec, beatMap.tempoChangeDatas);
          }