← 筆記

《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);
            }