← 筆記

《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(01FF),其可能BPM為1255
      • 通道編號08:指定BPM索引的速度變化
        • 首先在Header部分中定義索引和對應的BPM,然後在Main Data部分通過指定索引而不是直接指定BPM來改變節奏
        • 這個方法可以指定任意的BPM變化,索引值為09、AZ組合而成的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}");
        }