書名:《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本體 代表的拍子 # 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的拍子為止
要讀取包含拍子與速度變化的譜面,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}");
}