《Unityで作るリズムゲーム》學習筆記(六):「BMS文件讀取與解析」

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

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

譜面(沒有拍子與速度變化)的讀取

  • 譜面(沒有拍子與速度變化)的腳本讀取,主要步驟為兩大步:

    • 讀取文件頭數據(Header Data)

      • 建立文件頭的正則表達式

        • //Header數據格式的正則表達式
          static List<string> headerPattern = new List<string>
          {
             @"#(PLAYER) (.*)",
             @"#(GENRE) (.*)",
             @"#(TITLE) (.*)",
             @"#(ARTIST) (.*)",
             @"#(BPM) (.*)",
             @"#(BPM[0-9A-Z]{2}) (.*)",
             @"#(PLAYLEVEL) (.*)",
             @"#(RANK) (.*)"
          };
      • 建立Header數據映射

        • //Header數據的名字與數據映射
          public Dictionary<string, string> headerData = new Dictionary<string, string>();
      • 遍歷bms文件中的所有行和Header正則,把符合正則的數據放到數據映射字典中

        • ///
          /// 解析HEADER數據
          ///
          void LoadHeaderLine(string line)
          {
             foreach (string pattern in headerPattern)
            {
                 Match match = Regex.Match(line, pattern);
                 if(match.Success)
                {
                     //Groups[0].Value為整個表達式
                     //Groups[1].Value = Header名
                     string headerName = match.Groups[1].Value;
                     //Groups[2].Value = 數據
                     string data = match.Groups[2].Value;
                     headerData[headerName] = data;
                     return;
                }
            }
          }
    • 讀取文件主要數據(Main Data)

      • 建立讀取MainData數據的正則表達式

        • //MainData數據格式的正則表達式
          //小節編號 - 000~999
          //通道編號 - 00~ZZ
          //分隔符 - :
          //數據本體 - 長度為2的倍數的任意字符
          static string mainDataPattern = @"#([0-9]{3})([0-9A-Z]{2}):(.*)";
      • 建立通道編號個位數與實際軌道編號映射

        • //通道編號的個位數(軌道編號)與實際的軌道編號映射
          Dictionary<char, int> lanePairs = new Dictionary<char, int>
          {
            {'1', 0 },
            {'2', 1 },
            {'3', 2 },
            {'4', 3 },
            {'5', 4 },
          };
      • 建立保存譜面所有note信息和LongNote頭押拍子位置的緩存集合

        • //所有notes信息
          public List<NoteProperty> noteProperties = new List<NoteProperty>();
          //每個軌道打開(開始)Long Note的最後一個節拍(Long Note頭押的一拍)
          //關閉(結束)Long Note時將其重設為-1(Long Note尾押)
          //用於判斷該軌道是否開始了長押
          float[] longNoteBeginBuffers = new float[] { -1, -1, -1, -1, -1 };
      • 建立每小節長度(4拍)的小節數組,長度為可能小節編號000~999 = 1000

        • //每小節長度,每小節4拍
          //小節編號取值為000~999,共1000小節
          float[] measureLengths = Enumerable.Repeat(4f, 1000).ToArray();
      • 建立代表有甚麼Note類型的枚舉

        • //數據(Note)類型
          public enum DataType
          {
             Unsupported,
             SingleNote,
             LongNote
          }
      • 建立通過通道編號來得到Note類型的方法

        • //通過通道編號來得到數據類型
          //1: SingleNote ; 5: LongNote
          DataType GetDataType(string channel)
          {
             switch (channel[0])
            {
                 case '1':
                     return DataType.SingleNote;                
                 case '5':
                     return DataType.LongNote;                
                 default:
                     throw new Exception($"Can't get note type which is not specified.");                
            }
          }
      • 遍歷文件每一行,並對符合主數據正則的數據進行解析和讀取

        • 如果讀取成功,先把無法識別的數據排除

          • ///
            /// 解析MAIN DATA數據
            ///
            void LoadMainDataLine(string line)
            {                
               Match match = Regex.Match(line, mainDataPattern);
               //數據格式
               //nnn: 小節編號 => match.Groups[1].Value
               //xx: 通道編號 => match.Groups[2].Value
               //match.Groups[3].Value => 數據本體
               if (match.Success)
              {
                   //小節編號
                   int measureNum = Convert.ToInt32(match.Groups[1].Value);
                   //通道編號
                   string channel = match.Groups[2].Value;
                   //Data本體
                   string body = match.Groups[3].Value;

                   //通過通道編號得到數據的類型(Single/Long/etc)
                   DataType dataType = GetDataType(channel);
                   if (dataType == DataType.Unsupported) { return; }
                   //...
              }
            }
        • 然後再從小節開始進行計算,如該note所在小節的開始拍子、該小節被分成多少等分,然後遍歷所有「物體」(2個字元 = 1個物體),計算這些物體自身的開始拍子(小節的開始拍子 + 自身在當前小節的位置 * (小節長度/物體數量))

          • void LoadMainDataLine(string line)
            {
               //...
               else if(dataType == DataType.SingleNote ||
                   dataType == DataType.LongNote)
            {
                   //該小節開始的拍子 = 小節編號 * 4
                   float measureStartBeat = measureLengths.Take(measureNum).Sum();
                   //該小節被分為多少等分(有多少個物體)
                   int objCount = body.Length / 2;
                   //遍歷所有物體
                   for (int i = 0; i < objCount; i++)
                  {
                       //每兩個數字為1個物體
                       //02, 02, 00, 02...
                       string objNum = body.Substring(i * 2, 2);
                       //00 => 休止符
                       if(objNum == "00") { continue; }

                       //該物體在整首曲子中的所在拍子 = 所在小節開始的拍子 +
                       //(自身索引(i) * 當前小節長度(measureLengths[measureNum]) / 物體數量)
                       float beat = measureStartBeat +
                          (i * measureLengths[measureNum] / objCount);
                       //...
                  }
              }      
            }
        • 最後再分別對兩種類型的note進行初始化Long Note的初始化稍微會比較特別,當其所屬軌道上的長押緩存為-1時,代表該物體是長押的開始,並把拍子加到緩存;當下一個該軌道上的物體出現時,這個物體就是長押的結束,到這裡才能確定長押的beatStart(之前緩存下來的拍子)和beatEnd(當前拍子),並將其實例化出來。

          • void LoadMainDataLine(string line)
            {
               //...
               if(dataType == DataType.SingleNote ||
                  dataType == DataType.LongNote)
              {
                   //通道編號的個位數 = 具體軌道
                   int lane = lanePairs[channel[1]];
                   switch (dataType)
                  {
                       case DataType.Unsupported:
                           break;
                       case DataType.SingleNote:
                           noteProperties.Add(new NoteProperty(beat, beat, lane, NoteType.Single));
                           break;                                
                       case DataType.LongNote:
                           //當前軌道上的長押緩存為OFF(沒有LongNote)
                           if(longNoteBeginBuffers[lane] < 0)
                          {
                               //代表當前長押beat為Long Note的開始,保存該beat到緩存中
                               longNoteBeginBuffers[lane] = beat;
                          }
                           //當前軌道上的長押緩存為ON(已有LongNote的起點加入)
                           else
                          {
                               //代表現在要加入的是Long Note的終點
                               //該軌道上的長押緩存beat作為起點,當前beat為終點
                               noteProperties.Add(new NoteProperty(
                                   longNoteBeginBuffers[lane], beat, lane, NoteType.Long
                              ));
                               //Long Note的長押結束,重置緩存值為OFF(-1)
                               longNoteBeginBuffers[lane] = -1;
                          }
                           break;
                       default:
                           break;
                  }
              }            
            }
    • BmsLoader總體代碼如下

      • using System.Text.RegularExpressions;
        using System.Collections;
        using System.Collections.Generic;
        using UnityEngine;
        using System.Linq;
        using System.IO;
        using System.Text;
        using System;

        //數據(Note)類型
        public enum DataType
        {
           Unsupported,
           SingleNote,
           LongNote
        }

        public class BmsLoader
        {
           //MainData數據格式的正則表達式
           //小節編號 - 000~999
           //通道編號 - 00~ZZ
           //分隔符 - :
           //數據本體 - 長度為2的倍數的任意字符
           static string mainDataPattern = @"#([0-9]{3})([0-9A-Z]{2}):(.*)";

           //Header數據格式的正則表達式
           static List<string> headerPattern = new List<string>
          {
               @"#(PLAYER) (.*)",
               @"#(GENRE) (.*)",
               @"#(TITLE) (.*)",
               @"#(ARTIST) (.*)",
               @"#(BPM) (.*)",
               @"#(BPM[0-9A-Z]{2}) (.*)",
               @"#(PLAYLEVEL) (.*)",
               @"#(RANK) (.*)"
          };

           //通道編號的個位數(軌道編號)與實際的軌道編號映射
           Dictionary<char, int> lanePairs = new Dictionary<char, int>
          {
              {'1', 0 },
              {'2', 1 },
              {'3', 2 },
              {'4', 3 },
              {'5', 4 },
          };

           //Header數據的名字與數據映射
           public Dictionary<string, string> headerData = new Dictionary<string, string>();
           //所有notes信息
           public List<NoteProperty> noteProperties = new List<NoteProperty>();
           //速度變化信息
           public List<TempoChange> tempoChanges = new List<TempoChange>();

           //每小節長度,每小節4拍
           //小節編號取值為000~999,共1000小節
           float[] measureLengths = Enumerable.Repeat(4f, 1000).ToArray();

           //每個軌道打開(開始)Long Note的最後一個節拍(Long Note頭押的一拍)
           //關閉(結束)Long Note時將其重設為-1(Long Note尾押)
           //用於判斷該軌道是否開始了長押
           float[] longNoteBeginBuffers = new float[] { -1, -1, -1, -1, -1 };

           ///
           /// 讀取Bms文件構造函數
           ///
           public BmsLoader(string filePath)
          {
               //讀取Bms文件,將每一行保存在一個數組中
               string[] lines = File.ReadAllLines(filePath, Encoding.UTF8);

               //讀取Header數據
               foreach (string line in lines) { LoadHeaderLine(line); }

               tempoChanges.Add(
                   //設置0拍開始的BPM
                   new TempoChange(0, Convert.ToSingle(headerData["BPM"])));

               //讀取Main Data數據
               foreach (string line in lines) { LoadMainDataLine(line); }

               //把不同的速度(BPM)段根據其所發生的具體節拍(beat)從小到大排序
               tempoChanges = tempoChanges.OrderBy(x => x.beat).ToList();
          }

           ///
           /// 解析HEADER數據
           ///
           void LoadHeaderLine(string line)
          {
               foreach (string pattern in headerPattern)
              {
                   Match match = Regex.Match(line, pattern);
                   if(match.Success)
                  {
                       //Groups[0].Value為整個表達式
                       //Groups[1].Value = Header名
                       string headerName = match.Groups[1].Value;
                       //Groups[2].Value = 數據
                       string data = match.Groups[2].Value;
                       headerData[headerName] = data;
                       return;
                  }
              }
          }

           ///
           /// 解析MAIN DATA數據
           ///
           void LoadMainDataLine(string line)
          {                
               Match match = Regex.Match(line, mainDataPattern);
               //數據格式
               //nnn: 小節編號 => match.Groups[1].Value
               //xx: 通道編號 => match.Groups[2].Value
               //match.Groups[3].Value => 數據本體
               if (match.Success)
              {
                   //小節編號
                   int measureNum = Convert.ToInt32(match.Groups[1].Value);
                   //通道編號
                   string channel = match.Groups[2].Value;
                   //Data本體
                   string body = match.Groups[3].Value;

                   //通過通道編號得到數據的類型(Single/Long/etc)
                   DataType dataType = GetDataType(channel);
                   if (dataType == DataType.Unsupported) { return; }
                   else if(dataType == DataType.SingleNote ||
                           dataType == DataType.LongNote)
                  {
                       //該小節開始的拍子 = 小節編號 * 4
                       float measureStartBeat = measureLengths.Take(measureNum).Sum();
                       //該小節被分為多少等分(有多少個物體)
                       int objCount = body.Length / 2;
                       //遍歷所有物體
                       for (int i = 0; i < objCount; i++)
                      {
                           //每兩個數字為1個物體
                           //02, 02, 00, 02...
                           string objNum = body.Substring(i * 2, 2);
                           //00 => 休止符
                           if(objNum == "00") { continue; }

                           //該物體在整首曲子中的所在拍子 = 所在小節開始的拍子 +
                           //(自身索引(i) * 當前小節長度(measureLengths[measureNum]) / 物體數量)
                           float beat = measureStartBeat +
                              (i * measureLengths[measureNum] / objCount);

                           if(dataType == DataType.SingleNote ||
                              dataType == DataType.LongNote)
                          {
                               //通道編號的個位數 = 具體軌道
                               int lane = lanePairs[channel[1]];
                               switch (dataType)
                              {
                                   case DataType.Unsupported:
                                       break;
                                   case DataType.SingleNote:
                                       noteProperties.Add(new NoteProperty(beat, beat, lane, NoteType.Single));
                                       break;                                
                                   case DataType.LongNote:
                                       //當前軌道上的長押緩存為OFF(沒有LongNote)
                                       if(longNoteBeginBuffers[lane] < 0)
                                      {
                                           //代表當前長押beat為Long Note的開始,保存該beat到緩存中
                                           longNoteBeginBuffers[lane] = beat;
                                      }
                                       //當前軌道上的長押緩存為ON(已有LongNote的起點加入)
                                       else
                                      {
                                           //代表現在要加入的是Long Note的終點
                                           //該軌道上的長押緩存beat作為起點,當前beat為終點
                                           noteProperties.Add(new NoteProperty(
                                               longNoteBeginBuffers[lane], beat, lane, NoteType.Long
                                          ));
                                           //Long Note的長押結束,重置緩存值為OFF(-1)
                                           longNoteBeginBuffers[lane] = -1;
                                      }
                                       break;
                                   default:
                                       break;
                              }
                          }
                      }
                  }            
              }
          }

           //通過通道編號來得到數據類型
           //1: SingleNote ; 5: LongNote
           DataType GetDataType(string channel)
          {
               if (channel[0] == '1') { return DataType.SINGLE_NOTE; }
               else if (channel[0] == '5') { return DataType.LONG_NOTE; }
               else { throw new Exception($"DataType Not Found With Channel: {channel}"); }
          }
        }
  • 要具體使用上述腳本對bms格式文件進行讀取,還需要對Beatmap腳本以及PlayerController腳本進行修改。

    • Beatmap:添加一個接收文件路徑的構造方法,在Beatmap被實例化時,會同時實例化一個BmsLoader,並bms文件路徑傳過去讓BmsLoader完成note數據和速度段數據的讀取和初始化

      • ///
        /// 實例化BeatMap時讓BmsLoader讀取並解析bms文件
        ///
        public BeatMap(string filePath)
        {
           BmsLoader bmsLoader = new BmsLoader(filePath);
           noteDatas = bmsLoader.noteProperties;
           tempoChangeDatas = bmsLoader.tempoChanges;
        }
    • PlayerController:設置文件路徑,在Awake中實例化beatmap並把譜面路徑傳遞過去。在beatmap通過bmsLoader完成譜面的初始化後, PlayerController就可以再通過beatmap中的noteDatas得到完成數據初始化後的每個notes,再將他們依次實例化到遊戲場景中,譜面(沒有變速)的讀取和解析就完成了

      • string beatmapDirectory = Application.dataPath + "/../Beatmaps";
        beatMap = new BeatMap(beatmapDirectory + "/sample1.bms");


        //Spawning Notes
        foreach (NoteProperty noteProperty in beatMap.noteDatas)
        {
           Instantiate(noteProperty.noteType == NoteType.Single ?
                       singleNotePrefab : longNotePrefab)
              .GetComponent<NoteControllerBase>()
              .noteProperty = noteProperty;
        }
    • Bms文件中的譜面

    • 遊戲場景中的譜面

譜面(有拍子與速度變化)的讀取

  • 如果一個譜面裡有速度(BPM)或拍子變化,其bms文檔中的數據可能會出現以下幾種變化

    • Main Data部分

      • 通道編號03:直接指定BPM型的速度變化

        • 直接在數據本體部分以一個2位十六進制數指定BPM(01~FF),其可能BPM為1~255

      • 通道編號08:指定BPM索引的速度變化

        • 首先在Header部分中定義索引和對應的BPM,然後在Main Data部分通過指定索引而不是直接指定BPM來改變節奏

        • 這個方法可以指定任意的BPM變化,索引值為0~9、A~Z組合而成的2位36進制數字

      • 通道編號02:拍子變化

        • 這個命令與其他Main Data不同,這個命令是在分隔符「:」之後的用一個實數值指定

        • 這是轉換為實數的拍號的分數,比如在4分之3拍號的情況下,該實數為3 / 4 = 0.75

        • 沒有這個命令的小節均以4分之4拍號來解釋

    • Header部分

      • #BPMnn bpm

        • 定義Main Data部分的通道編號為08的數據的BPM Index。nn就是該BPM的索引,然後用一個半角空格分隔開的,就是該index下對應的BPM

  • 建立一個包含了變速和變拍元素的例子譜面

  • 該譜面的bms文件內容如下圖:
  • 在該譜面中,共有6個拍子變化以及5個速度變化

    • 首先是拍子變化(通道編號02),該譜面的拍子變化過程如下表:

      • #小節通道:Data本體代表的拍子
        #00002:0.753 / 4拍子
        #00102:14 / 4拍子
        #00202:1.757 / 4拍子
        #00302:1.37511 / 8 拍子
        #00402:0.937515 / 16拍子
        #005~99902:14 / 4拍子
      • 在上表中,實際顯示在bms文件裡的只有000、002、003、004四個小節的拍子變化,但實際上,由於這些拍子變化並不會延續到下一拍,所以,沒有明確指定拍子數的小節,一律會重置為4/4拍子。

        • 因此,4個明確指定的變拍小節(000、002、003、004)加上2個默認拍子的小節(001、005~999),共6次拍子變化

    • 然後是速度變化

      • 兩個直接指定型(通道編號03)

        • #小節通道:Data本體代表的BPM
          #00003:7878(16) = 120(10)
          #00203:00 00 00 00 FF 00 00FF(16) = 255(10)
        • 這種類型的速度變化,BPM以01~FF的16進制數表示,因此具體BPM需要將16進制數轉換為10進制數才能知道。

        • 這裡的變速發生時機與一般notes一樣,”00″也是休止符的意思。在第2小節中,該小節被分成7等分的四分音符,前面4個四分音符甚麼也不做,而在第5拍(第5個四分音符)的開始把BPM變成255。

      • 三個索引指定型(通道編號08)

        • Header部分

          • 索引數據本體
            #BPM01 120.201120.2
            #BPM02 25602256
            #BPM03 65535.99990365535.9999
            #BPM04 0.1040.1
        • Main Data部分

          • #小節通道:數據本體
            #00108:00 01 00 00
            #00308:00 00 00 00 02 00 03 00 00 00 00
            #00408:00 00 00 00 00 04
        • 在Header中指定了的索引可以直接在Main Data裡面使用。具體的發生時機考慮與一般的notes一樣。

          • 比如在第3小節(11/8拍),小節被分成了11等分的8分音符,在第5拍,也就是第5個8分音符時,BPM會變成256;到第7個8分音符時,BPM會變成65535.9999

      • 速度變化不同於拍子變化,一旦速度被指定,該速度會一直延續至下一次明確指定BPM的拍子為止

  • 要讀取包含拍子與速度變化的譜面,BmsLoader在解析bms格式文件時,需要進行一定的修改

    • 添加新的數據類型:拍子改變(通道編號02)、直接指定型BPM改變(通道編號03)、索引指定型BPM改變(通道編號08)

      • //數據(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
        }

        //通過通道編號來得到數據類型
        //1: SingleNote; 5: LongNote
        //02: 拍子改變; 03: 直接指定型BPM改變; 08: 索引指定型BPM改變
        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 { throw new Exception($"DataType Not Found With Channel: {channel}"); }
        }
    • 增加對拍子變化數據的處理:修改小節長度數組中,該拍子變化發生的小節中的小節長度。具體修改為將其數據(表示的4分之n拍的小數)* 4,轉換成實際的拍子數。

      • void LoadMainDataLine(string line)
        {
           //...
           if(match.Success)
          {
               //...
               DataType dataType = GetDataType(channel);
               //...
               if(dataType == DataType.MEASURE_CHANGE)
              {
                   /*
                        * 修改該小節的長度
                        * 小節長度默認為4拍,拍子改變,小節長度改變
                        * 表示拍子改變的數據本體(body)是一個小數
                        * 這個小數是「一小節中被分成多少等分的四分音符」的分數轉換而成
                        * 比如4分之4拍,小節被等分成4個4分音符,分數表示為4/4 = 1
                        * 而4分之3拍,小節被等分為3個4分音符,分數表示為3/4 = 0.75
                        * 因此,要知道一小節有多少拍(小節長度)就需要把該小數乘以4
                        */                
                   measureLengths[measureNum] = Convert.ToSingle(body) * 4f;            
              }
          }
           //...
        }
    • 增加對BPM變化數據的處理:讀取BPM的數據,並將其加到速度變化信息列表(tempoChanges)中。

      • void LoadMainDataLine(string line)
        {
           //...
           if(match.Success)
          {
               //...
               if(dataType == DataType.MEASURE_CHANGE)
              {
                   //...
              }
               else if(dataType == DataType.SINGLE_NOTE ||
                      dataType = DataType.LONG_NOTE ||
                      dataType = DIRECT_TEMPO_CHANGE ||
                      dataType = INDEXED_TEMPO_CHANGE)
              {
                   for(int i = 0; i < objCount; i++)
                  {
                       //...
                       if(dataType == DataType.SINGLE_NOTE ||
                         dataType == DataType.LONG_NOTE)
                      {
                           //...
                      }
                       else if(dataType == DataType.DIRECT_TEMPO_CHANGE)
                      {
                           //直接指定型的BPM範圍為01~FF的16進制數(10進制下為1 - 255)
                           //把物體編號轉換為10進制BPM數字
                           float tempo = Convert.ToInt32(objNum, 16);
                           tempoChanges.Add(new TempoChange(beat, tempo));
                      }
                       else if(dataType == DataType.INDEXED_TEMPO_CHANGE)
                      {
                           //索引指定型使用Header中定義的索引:BPM來修改BPM
                           //當前物體編號 = Header中的索引
                           //因此需要從Header數據中取得該索引下的BPM
                           float tempo = Convert.ToSingle(headerData[$"BPM{objNum}"]);
                           tempoChanges.Add(new TempoChange(beat, tempo));                    
                      }
                  }            
              }
          }  
        }
  • 然後嘗試修改一下PlayerController要讀取的bms文件為”sample2.bms”,並輸出該譜面的變速與拍子信息

    • foreach (TempoChange tempoChange in beatMap.tempoChangeDatas)
      {
         Debug.Log($"{tempoChange.beat} : {tempoChange.tempoAfterChanged}");
      }