《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為1255
- 直接在數據本體部分以一個2位十六進制數指定BPM(01
- 通道編號08:指定BPM索引的速度變化
- 首先在Header部分中定義索引和對應的BPM,然後在Main Data部分通過指定索引而不是直接指定BPM來改變節奏
- 這個方法可以指定任意的BPM變化,索引值為0
9、AZ組合而成的2位36進制數字
- 通道編號02:拍子變化
- 這個命令與其他Main Data不同,這個命令是在分隔符「:」之後的用一個實數值指定
- 這是轉換為實數的拍號的分數,比如在4分之3拍號的情況下,該實數為3 / 4 = 0.75
- 沒有這個命令的小節均以4分之4拍號來解釋
- 通道編號03:直接指定BPM型的速度變化
- Header部分
- #BPMnn bpm
- 定義Main Data部分的通道編號為08的數據的BPM Index。nn就是該BPM的索引,然後用一個半角空格分隔開的,就是該index下對應的BPM。
- #BPMnn bpm
- Main Data部分
- 建立一個包含了變速和變拍元素的例子譜面

- 該譜面的bms文件內容如下圖:

-
在該譜面中,共有6個拍子變化以及5個速度變化
- 首先是拍子變化(通道編號02),該譜面的拍子變化過程如下表:
-
# 小節 通道 : Data本體 代表的拍子 # 000 02 : 0.75 3 / 4拍子 # 001 02 : 1 4 / 4拍子 # 002 02 : 1.75 7 / 4拍子 # 003 02 : 1.375 11 / 8 拍子 # 004 02 : 0.9375 15 / 16拍子 # 005~999 02 : 1 4 / 4拍子 -
在上表中,實際顯示在bms文件裡的只有000、002、003、004四個小節的拍子變化,但實際上,由於這些拍子變化並不會延續到下一拍,所以,沒有明確指定拍子數的小節,一律會重置為4/4拍子。
- 因此,4個明確指定的變拍小節(000、002、003、004)加上2個默認拍子的小節(001、005~999),共6次拍子變化
-
- 然後是速度變化
- 兩個直接指定型(通道編號03)
-
# 小節 通道 : Data本體 代表的BPM # 000 03 : 78 78(16) = 120(10) # 002 03 : 00 00 00 00 FF 00 00 FF(16) = 255(10) -
這種類型的速度變化,BPM以01~FF的16進制數表示,因此具體BPM需要將16進制數轉換為10進制數才能知道。
-
這裡的變速發生時機與一般notes一樣,“00”也是休止符的意思。在第2小節中,該小節被分成7等分的四分音符,前面4個四分音符甚麼也不做,而在第5拍(第5個四分音符)的開始把BPM變成255。
-
- 三個索引指定型(通道編號08)
-
Header部分
-
行 索引 數據本體 #BPM01 120.2 01 120.2 #BPM02 256 02 256 #BPM03 65535.9999 03 65535.9999 #BPM04 0.1 04 0.1
-
-
Main Data部分
-
# 小節 通道 : 數據本體 # 001 08 : 00 01 00 00 # 003 08 : 00 00 00 00 02 00 03 00 00 00 00 # 004 08 : 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的拍子為止
- 兩個直接指定型(通道編號03)
- 首先是拍子變化(通道編號02),該譜面的拍子變化過程如下表:
-
要讀取包含拍子與速度變化的譜面,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}"); }
-
