アマゾンバナーリンク

DXライブラリとC#での音楽ゲームの作り方その5 もっと複雑なBMSファイルの読み込み

2020年2月24日

この章では前回説明しなかった小節幅の変更やBPMの途中変更、
ゲーム中で表示する小節バーなどの対応も考えてのプログラムの作り方を説明します。

BMSでの小節の長さの定義

BMSでは小節ごとに長さを変更することが出来ます。 (詳細はこちら)

このコマンドを使用すると、たとえば通常4/4拍子のリズムを途中で2/4に変更するということが出来ますが、これを時間的に考えると通常の半分の時間の小節ということになります。

図にすると以下のような感じになります。

※9600や14400などの値は以前説明した1小節を9600とした場合のBMSカウント単位です

これを踏まえて前章のプログラムを見ると、小節の幅はまったく考慮されていないため、
このようなデータをロードすると以下のようなとんでもない曲になってしまいます。

2小節目が四分音符ではなく二分音符となっているため曲が伸びてしまっています。

ということでまずはこれに対応するための概要を説明します。

まず前提としてBMSの仕様ではどの場所にも命令は書けます。
つまり例えば小節番号が若いものを後方に持ってくることが出来るわけですが、
同じく小節長コマンドももしかしたら実データのあとに記述されている可能性があります。

もし小節長コマンドがあとに記述されていた場合、最初は1小節を9600のまま計算し、
そのあとで小節長コマンドにて小節長が変更された場合、その小節に該当するデータはもちろん、
それ以降のデータの開始位置も修正する必要があります。

別にこういったプログラムも出来ないわけではないのですが、
なんとなく要領が悪いのでここでは小節長コマンドを先にロードしておき、
実データの解析時にはロード済みの小節長を使うようにしてみます。

ちなみに、この小節長リストはあとで説明する小節バーの表示にも利用されます。

小節長クラス

BMSの仕様上、小節番号は最大で999までとなっています。
例えば小節長の管理だけであれば以下のように定義出来ます。

float  Scale[1000+1];

まずは最初に全ての配列を1.0fで初期化しておきます。

そしてコマンド解析時に小節長コマンドが現れたら、そのコマンドの小節番号を配列番号としその配列に今回の小節長を記録させます。
もし複数の小節長コマンドが存在した場合は常に後方の行にある小節長で上書きされるため、
後方の情報が優先されるといったBMSの仕様にも合致します。

完成した小節長のリストを実データのロード時に参照することで、そのデータの小節番号から即座にその小節の長さが分かるようになります。

しかし実はこのリストだけだと少し不便で、例えばある音符のBMSカウント値を求めるには、
それまでの小節長もすべて考慮してその小節の開始カウントを計算しなければなりません。

例としてデータが10小節目を指している場合、まず10小節目の先頭のBMSカウント値を求めなければなりません。そのためにはその手前までの小節(ここでは0~9)の長さを全て足して求めます。

// 10小節目の開始カウントの算出
longstart = 0;
for( int i=0;i<10;i++ ) {
    start += 9600 * Scale[i];
}

これで10小節目の開始カウントが分かったので、あとはその小節内での音符のカウントを加算することで最終的なBMSカウント値を求めることが出来ます。

しかしよく考えると全ての小節長は配列にまとめられており、また各小節の開始位置というのは実データの解析時には変わることはありません。
ということは、実データの解析前に小節ごとの開始位置を最初から求めておけばよいことになります。

これを実現するためにまずは以下のような構造体を定義します。

// 小節情報
public class CBmsBar {
    float       Scale;                     // この小節の長さ倍率
    LONG        Times;                      // この小節の開始位置(BMSカウント値)
    LONG        Length;                    // この小節の長さ(BMSカウント値)
} ;

そしてこれを最大小節分確保します。

CBmsBar  BmsBar[1000+1];                // 小節データ(999小節時に1000番目も小節バーとして使うため+1しておく)

上記同様初期状態でfScaleを1.0fで初期化しておきますが、TimesとLengthはここではまだ0のままにしておきます。

// 小節の長さを1.0で初期化
for( int i=0;i< BmsBar.Length;i++ ) 
{
     BmsBar[i].Scale = 1.0f;
}

小節長の解析

ではこの小節長コマンドの解析処理を組み込んでみましょう。
組み込み場所は実データのロード前に入れる必要があるので、ここではヘッダロードの部分で行います。

また、ついでにゲームの終了を判断するための情報として、ここで最大小節数も取得しておきます。
れはデータに含まれる小節番号の中で、単に最大の値を保存するだけです。

以下がヘッダロード部分に追加した場合のプログラムです。
※mBmsBarは上記のように初期化されているとします

private bool LoadHeader(string file_name)
{
    FileInfo fi = new FileInfo(file_name);
    string text_data = string.Empty;
    try
    {
        using (StreamReader sr = new StreamReader(fi.OpenRead(), System.Text.Encoding.UTF8))//GetEncoding("Shift_JIS") ) )
        {
            text_data = sr.ReadToEnd();

            sr.Close();
        }
    }
    catch (Exception e)
    {
        MessageBox.Show(file_name + " Error");
    }

    //		byte[] byte_data = File.ReadAllBytes(file_name); // 読み込み
    //		text_data = System.Text.Encoding.GetEncoding("Shift_JIS").GetString(byte_data);

    // 空の要素を削除するためのオプション.
    System.StringSplitOptions option = System.StringSplitOptions.RemoveEmptyEntries;

    // 改行コードで1行ごとに切り出す..
    LineStr = text_data.Split(new char[] { '\r', '\n' }, option);

    // " " で1文字ごとに切り出す.
    char[] spliter = new char[] { ' ', ':' };

    string dir_name = Path.GetDirectoryName(file_name);

    for (int i = 0; i < BMSMAXBUFFER; ++i)
    {
        BmsDataList[i] = new List<CBmsData>();
    }
    //var query = from line in LineStr
    //            let str = line.Substring(0, 1)
    //            where str == "#"
    //            select line.Substring(1);
    foreach (string line in LineStr)
    {
        if (line.Substring(0, 1) != "#")
        {
            continue;
        }

        string[] data = line.Split(spliter, option);

        if (data.Length < 2)
        {
            MessageBox.Show("data.Lengthが2より小さいです=" + data[0]);
            continue;
        }

        string value = string.Empty;
        for (int i = 1; i < data.Length; ++i)
        {
            value += data[i];
            if (i < data.Length - 1)
            {
                value += " ";
            }
        }

        int com = GetCommand(data[0].Substring(1));
        int num10, num16;
        string exp;
        // data[0]にコマンドstrにパラメータが入っている
        switch (com)
        {
            case 0:     // PLAYER
                BmsHeader.Player = int.Parse(value);
                break;
            case 1:     // GENRE:
                BmsHeader.Genre = value;
                break;
            case 2:     // TITLE
                BmsHeader.Title = value;
                break;
            case 3:     // ARTIST
                BmsHeader.Artist = value;
                break;
            case 4:     // BPM
                if (data[0].Length <= 4)
                {
                    // ヘッダなら
                    // 例 #BPM 250
                    BmsHeader.Bpm = float.Parse(value);
                    AddData(BMS_TEMPO, 0, (int)BmsHeader.Bpm);
                }
                else
                {
                    if (!IsHexString(data[0].Substring(4)))
                    {
                        MessageBox.Show(data[0] + "は16進数ではありません");
                        continue;
                    }
                    // インデックスなら
                    // 例 #BPM01 62.5
                    num16 = System.Convert.ToInt32(data[0].Substring(4), 16);
                    num10 = System.Convert.ToInt32(num16.ToString(), 10);
                    BmsHeader.BpmIndex[num10] = float.Parse(value);
                }
                break;
            case 5:     // MIDIFILE
                BmsHeader.MidiFile = value;
                break;
            case 6:     // PLAYLEVEL
                BmsHeader.PlayLevel = int.Parse(value);
                break;
            case 7:     // RANK
                BmsHeader.Rank = int.Parse(value);
                break;
            case 8:     // VOLWAV
                BmsHeader.WavVol = int.Parse(value);
                break;
            case 9:     // TOTAL
                BmsHeader.Total = int.Parse(value);
                break;
            case 10:    // STAGEFILE
                BmsHeader.StagePic = value;
                break;
            case 11:    // WAV
                if (!IsHexString(data[0].Substring(4)))
                {
                    MessageBox.Show(data[0] + "は16進数ではありません");
                    continue;
                }
                // #WAVxx:******* のxx部分
                num16 = System.Convert.ToInt32(data[0].Substring(4), 16);
                num10 = System.Convert.ToInt32(num16.ToString(), 10);

                exp = Path.GetExtension(value);
                if (string.Compare(exp, ".wav", true) == 0 ||
                   string.Compare(exp, ".ogg", true) == 0 ||
                   string.Compare(exp, ".mp3", true) == 0)
                {
                    // フォルダ名を取得
                    string folder_name = Path.GetDirectoryName(file_name);
                    MusicHandles[num10] = DX.LoadSoundMem(folder_name + "\\"+ value);
                    if (MusicHandles[num10] == -1)
                    {
                        MessageBox.Show($"{folder_name + "\\" + value}が読み込めません");
                    }
                }
                else
                {
                    MessageBox.Show("wavかoggかmp3のみ対応してます" + data[0].Substring(4) + ":" + value);
                    continue;
                }
                break;
            case 12:    // BMP
                if (!IsHexString(data[0].Substring(4)))
                {
                    MessageBox.Show(data[0] + "は16進数ではありません");
                    continue;
                }
                // #BMPxx:******* のxx部分
                num16 = System.Convert.ToInt32(data[0].Substring(4), 16);
                num10 = System.Convert.ToInt32(num16.ToString(), 10);

                exp = Path.GetExtension(value).ToLower();
                if (string.Compare(exp, ".jpg", true) == 0 ||
                   string.Compare(exp, ".png", true) == 0 ||
                   string.Compare(exp, ".bmp", true) == 0 ||
                   string.Compare(exp, ".dds", true) == 0 ||
                   string.Compare(exp, ".argb", true) == 0 ||
                   string.Compare(exp, ".tga", true) == 0
                   )
                {
                    // フォルダ名を取得
                    string folder_name = Path.GetDirectoryName(file_name);
                    GraphicHandles[num10] = DX.LoadGraph(folder_name + "\\" + value);
                    if (GraphicHandles[num10] == -1)
                    {
                        MessageBox.Show($"{folder_name + "\\" + value}が読み込めません");
                        continue;
                    }
                }
                else
                {
                    MessageBox.Show("pngかjpgかbmpのみ対応してます" + data[0].Substring(4) + ":" + value);
                    continue;
                }
                break;
            case 13:    // GUID

                break;
            case 14:    // MOVIE
                //BmsHeader.Movie = value;
                exp = Path.GetExtension(value).ToLower();
                if (string.Compare(exp, ".avi", true) == 0 ||
                   string.Compare(exp, ".mp4", true) == 0 ||
                   string.Compare(exp, ".mpg", true) == 0 ||
                   string.Compare(exp, ".wmv", true) == 0
                   )
                {
                    // フォルダ名を取得
                    string folder_name = Path.GetDirectoryName(file_name);
                    MovieHandle = DX.LoadGraph(folder_name + "\\" + value);
                    continue;
                }
                else
                {
                    MessageBox.Show("aviかmp4かmpgのみ対応してます" + data[0].Substring(4) + ":" + value);
                    continue;
                }
                break;
            default:
                if (!IsNumeric(data[0].Substring(1, 1)))
                {
                    MessageBox.Show(data[0] + "= 無効な命令文です");
                    continue;
                }
                // 実データと小説幅変更命令はここを通る 
                // 普通の実データ 例 #00111:0101
                // 小節幅変更命令 例 #00102:0.5 ←1小節目を半分にする
                // 小節番号の取得
                // #xxx01:*******のxxx部分
                int line_num = int.Parse(data[0].Substring(1, 3));
                // チャンネル番号の取得
                // #001xx:*******のxx部分
                int channel = int.Parse(data[0].Substring(4, 2));

                // 小節の倍率変更命令の場合
                if (channel == BMS_STRETCH)
                {
                    BarMagniList.Add(new CBarMagni(line_num, float.Parse(value)));
                }

                // 小節番号の最大値を記憶する
                if (BmsHeader.EndBar < line_num)
                {
                    BmsHeader.EndBar = line_num;
                }
                break;
        }
    }
    return true;
}

上記の内容を説明すると、まず1行ロードしたらこれが実データかどうか判定します。

実データであればそれが小節長コマンドかをチェックします。

ちなみに小節長コマンドはチャンネル番号として2と定義されているため、
これが2だった場合は小節長コマンドとしてその値を小節長リストに登録します。
※この方法により同じ小節に対して複数の小節長コマンドが存在する場合は、一番最後に処理した小節長が適用されます

ちなみに実データには小節番号が必ず含まれるため、ここで一番大きい値をlEndBarに記録しています

小節長リストの構築

全ての小節長の情報がロードできたら、最後にこの小節長構造体リストに定義した
小節ごとの開始カウント値(Time)と、その小節の長さ(Length)を算出します。

なお、今回ロードした最大の小節数は既にBmsHeader.EndBarに入っているため、
計算はこの小節まで行えばよい事になりますが、ゲームの仕様上、曲の最後の小節も表示したいため、
実際はここでは最後の小節+1までの小節リストを構築することにします。

また、リスト構築時にゲームの終了時のカウント値も一緒に求められるので、
ここではついでにその最大BMSカウント値を保存する変数も用意しておきます。

この計算はヘッダロードの処理が全て完了した後に行います。
以下はそのコードとなります。

private bool LoadHeader(string file_name)
{
    FileInfo fi = new FileInfo(file_name);
    string text_data = string.Empty;
    try
    {
        using (StreamReader sr = new StreamReader(fi.OpenRead(), System.Text.Encoding.UTF8))//GetEncoding("Shift_JIS") ) )
        {
            text_data = sr.ReadToEnd();

            sr.Close();
        }
    }
    catch (Exception e)
    {
        MessageBox.Show(file_name + " Error");
    }

    //		byte[] byte_data = File.ReadAllBytes(file_name); // 読み込み
    //		text_data = System.Text.Encoding.GetEncoding("Shift_JIS").GetString(byte_data);

    // 空の要素を削除するためのオプション.
    System.StringSplitOptions option = System.StringSplitOptions.RemoveEmptyEntries;

    // 改行コードで1行ごとに切り出す..
    LineStr = text_data.Split(new char[] { '\r', '\n' }, option);

    // " " で1文字ごとに切り出す.
    char[] spliter = new char[] { ' ', ':' };

    string dir_name = Path.GetDirectoryName(file_name);

    for (int i = 0; i < BMSMAXBUFFER; ++i)
    {
        BmsDataList[i] = new List<CBmsData>();
    }
    //var query = from line in LineStr
    //            let str = line.Substring(0, 1)
    //            where str == "#"
    //            select line.Substring(1);
    foreach (string line in LineStr)
    {
        if (line.Substring(0, 1) != "#")
        {
            continue;
        }

        string[] data = line.Split(spliter, option);

        if (data.Length < 2)
        {
            MessageBox.Show("data.Lengthが2より小さいです=" + data[0]);
            continue;
        }

        string value = string.Empty;
        for (int i = 1; i < data.Length; ++i)
        {
            value += data[i];
            if (i < data.Length - 1)
            {
                value += " ";
            }
        }

        int com = GetCommand(data[0].Substring(1));
        int num10, num16;
        string exp;
        // data[0]にコマンドstrにパラメータが入っている
        switch (com)
        {
            case 0:     // PLAYER
                BmsHeader.Player = int.Parse(value);
                break;
            case 1:     // GENRE:
                BmsHeader.Genre = value;
                break;
            case 2:     // TITLE
                BmsHeader.Title = value;
                break;
            case 3:     // ARTIST
                BmsHeader.Artist = value;
                break;
            case 4:     // BPM
                if (data[0].Length <= 4)
                {
                    // ヘッダなら
                    // 例 #BPM 250
                    BmsHeader.Bpm = float.Parse(value);
                    AddData(BMS_TEMPO, 0, (int)BmsHeader.Bpm);
                }
                else
                {
                    if (!IsHexString(data[0].Substring(4)))
                    {
                        MessageBox.Show(data[0] + "は16進数ではありません");
                        continue;
                    }
                    // インデックスなら
                    // 例 #BPM01 62.5
                    num16 = System.Convert.ToInt32(data[0].Substring(4), 16);
                    num10 = System.Convert.ToInt32(num16.ToString(), 10);
                    BmsHeader.BpmIndex[num10] = float.Parse(value);
                }
                break;
            case 5:     // MIDIFILE
                BmsHeader.MidiFile = value;
                break;
            case 6:     // PLAYLEVEL
                BmsHeader.PlayLevel = int.Parse(value);
                break;
            case 7:     // RANK
                BmsHeader.Rank = int.Parse(value);
                break;
            case 8:     // VOLWAV
                BmsHeader.WavVol = int.Parse(value);
                break;
            case 9:     // TOTAL
                BmsHeader.Total = int.Parse(value);
                break;
            case 10:    // STAGEFILE
                BmsHeader.StagePic = value;
                break;
            case 11:    // WAV
                if (!IsHexString(data[0].Substring(4)))
                {
                    MessageBox.Show(data[0] + "は16進数ではありません");
                    continue;
                }
                // #WAVxx:******* のxx部分
                num16 = System.Convert.ToInt32(data[0].Substring(4), 16);
                num10 = System.Convert.ToInt32(num16.ToString(), 10);

                exp = Path.GetExtension(value);
                if (string.Compare(exp, ".wav", true) == 0 ||
                   string.Compare(exp, ".ogg", true) == 0 ||
                   string.Compare(exp, ".mp3", true) == 0)
                {
                    // フォルダ名を取得
                    string folder_name = Path.GetDirectoryName(file_name);
                    MusicHandles[num10] = DX.LoadSoundMem(folder_name + "\\"+ value);
                    if (MusicHandles[num10] == -1)
                    {
                        MessageBox.Show($"{folder_name + "\\" + value}が読み込めません");
                    }
                }
                else
                {
                    MessageBox.Show("wavかoggかmp3のみ対応してます" + data[0].Substring(4) + ":" + value);
                    continue;
                }
                break;
            case 12:    // BMP
                if (!IsHexString(data[0].Substring(4)))
                {
                    MessageBox.Show(data[0] + "は16進数ではありません");
                    continue;
                }
                // #BMPxx:******* のxx部分
                num16 = System.Convert.ToInt32(data[0].Substring(4), 16);
                num10 = System.Convert.ToInt32(num16.ToString(), 10);

                exp = Path.GetExtension(value).ToLower();
                if (string.Compare(exp, ".jpg", true) == 0 ||
                   string.Compare(exp, ".png", true) == 0 ||
                   string.Compare(exp, ".bmp", true) == 0 ||
                   string.Compare(exp, ".dds", true) == 0 ||
                   string.Compare(exp, ".argb", true) == 0 ||
                   string.Compare(exp, ".tga", true) == 0
                   )
                {
                    // フォルダ名を取得
                    string folder_name = Path.GetDirectoryName(file_name);
                    GraphicHandles[num10] = DX.LoadGraph(folder_name + "\\" + value);
                    if (GraphicHandles[num10] == -1)
                    {
                        MessageBox.Show($"{folder_name + "\\" + value}が読み込めません");
                        continue;
                    }
                }
                else
                {
                    MessageBox.Show("pngかjpgかbmpのみ対応してます" + data[0].Substring(4) + ":" + value);
                    continue;
                }
                break;
            case 13:    // GUID

                break;
            case 14:    // MOVIE
                //BmsHeader.Movie = value;
                exp = Path.GetExtension(value).ToLower();
                if (string.Compare(exp, ".avi", true) == 0 ||
                   string.Compare(exp, ".mp4", true) == 0 ||
                   string.Compare(exp, ".mpg", true) == 0 ||
                   string.Compare(exp, ".wmv", true) == 0
                   )
                {
                    // フォルダ名を取得
                    string folder_name = Path.GetDirectoryName(file_name);
                    MovieHandle = DX.LoadGraph(folder_name + "\\" + value);
                    continue;
                }
                else
                {
                    MessageBox.Show("aviかmp4かmpgのみ対応してます" + data[0].Substring(4) + ":" + value);
                    continue;
                }
                break;
            default:
                if (!IsNumeric(data[0].Substring(1, 1)))
                {
                    MessageBox.Show(data[0] + "= 無効な命令文です");
                    continue;
                }
                // 実データと小説幅変更命令はここを通る 
                // 普通の実データ 例 #00111:0101
                // 小節幅変更命令 例 #00102:0.5 ←1小節目を半分にする
                // 小節番号の取得
                // #xxx01:*******のxxx部分
                int line_num = int.Parse(data[0].Substring(1, 3));
                // チャンネル番号の取得
                // #001xx:*******のxx部分
                int channel = int.Parse(data[0].Substring(4, 2));

                // 小節の倍率変更命令の場合
                if (channel == BMS_STRETCH)
                {
                    BarMagniList.Add(new CBarMagni(line_num, float.Parse(value)));
                }

                // 小節番号の最大値を記憶する
                if (BmsHeader.EndBar < line_num)
                {
                    BmsHeader.EndBar = line_num;
                }
                break;
        }
    }

    // 小節分割倍率データを元に小節バーを生成
    int last_count = 0;
    // 最後の小節までを処理
    for (int i = 0; i <= BmsHeader.EndBar + 1; i++)
    {
        // 小節を追加
        BarInfoList.Add(new CBarInfo(true, last_count, -1));

        // 加算する数を定義
        int add = BMSDATA_RESOLUTION;

        // 現在の小節で倍率を変換しているか
        foreach (CBarMagni bm in BarMagniList)
        {
            // 倍率変更命令が存在する場合
            if (bm.Lines == i)
            {
                // 加算値をn倍する
                add = (int)((float)BMSDATA_RESOLUTION * bm.Magni);
                break;  // この小節の倍率変換は終わり
            }
        }

        last_count += add;  // ポジションを追加

        // 最大の拍数をついでに換算
        if (i < BmsHeader.EndBar && BmsHeader.MaxCount < last_count)
        {
            BmsHeader.MaxCount = last_count;
        }
    }

    return true;
}

上記コメントにあるように、lEndBarは実データに記述された最大の小節番号が入りますが、
一般的にには最大小節番号より最大小節数として考えるのが普通なので、
ここでは最大の小節番号に+1することで最大小節数としています。
※例えば0~9の小節があった場合、個数で言えば10個ということになります

次に各小節の開始カウントとその小節の長さの算出を行いますが、
まず現在の小節のカウント値を把握するためにcntという変数を用意し、これを0で初期化しておきます。
これに各小節の長さを順番に足していくことで、次の小節の開始位置が求められるので、
これを最大小節の数までループして各小節の開始カウントを決定して行きます。

この時ついでに実データの解析で使用するため、各小節の長さも一緒に計算しておきます。

そして、最後の小節の開始位置というのはゲームの終了位置を指しているので、
これをlMaxCountに記録しています。

これで今回プレイする曲の最大小節までの小節情報が全て揃ったので、
次の実データのロードでは、このリストを参照して小節長変更に対応した処理を行います。

実データロード部分に組み込む

ここでは小節長が変更された状態で実データをロードする処理を実装してみます。

さて、以前紹介したロード部分ではロードした実データに対して必ず1小節を9600として処理していました。
この9600が固定だったため小節長の変更に対応出来なかったわけですが、
上記のヘッダロード処理にてそれぞれの小節の最終的な長さが既に計算されているので、
その音符の小節番号が分かれば、その小節の長さもすぐに分かります。

さらに、既にその小節の開始時のBMSカウント値も求められているため、mBmsBarの配列を小節番号で参照するだけで、
一瞬でその小節の開始カウントと長さが分かります。

あとはその小節内での音符の位置を計算し開始カウントに足すだけで、
簡単にその音符の最終的なBMSカウント値を求めることが出来ます。

例として以下のような譜面があったとします。

ここでは1小節目と3小節目が2/4拍子となっており、それ以外は4/4拍子のままです。
またこの例では最大の小節番号は4(#00411)となるので、小節数としては5個存在することになります。

これをLoadHeader()で解析すると、結果的に以下のような小節長リストが構築されます。

小節番号BMSBAR構造体
fScalelTimelLength
01.0f09600
10.5f96004800
21.0f144009600
30.5f240004800
41.0f288009600

このリストを参照して音符の位置を計算するプログラムは以下のようになります。

// BMSデータの読み込み
private bool LoadBmsData()
{
    // 空の要素を削除するためのオプション.
    System.StringSplitOptions option = System.StringSplitOptions.RemoveEmptyEntries;

    // " " で1文字ごとに切り出す.
    char[] spliter = new char[] { ' ', ':' };

    foreach (string line in LineStr)
    {
        if (line.Substring(0, 1) != "#")
        {
            continue;
        }

        string[] data = line.Split(spliter, option);

        int com = GetCommand(data[0].Substring(1));

        // 実データ以外は飛ばす
        if (com != -1)
        {
            continue;
        }

        string value = string.Empty;
        for (int i = 1; i < data.Length; ++i)
        {
            value += data[i];
            if (i < data.Length - 1)
            {
                value += " ";
            }
        }

        // チャンネル番号の取得
        // #001xx:*******のxx部分
        int channel = int.Parse(data[0].Substring(4, 2));

        // 小節の倍率変更命令の場合はキャンセル
        if (channel == BMS_STRETCH)
        {
            continue;
        }

        // パラメータが存在するかチェック
        if (value.Length < 1)
        {
            MessageBox.Show(value + "パラメータが定義されていない箇所が存在");
            continue;
        }

        // 小節番号の取得
        // #xxx01:*******のxxx部分
        int line_num = int.Parse(data[0].Substring(1, 3));

        // パラメータが偶数かチェック
        if (value.Length % 2 == 1)
        {
            MessageBox.Show(line_num + "小節目の" + value + "のパラメータが偶数個ではない");
            continue;
        }

        // 小節区分からデータ配置場所を特定
        int start_count = 0;    // その小節の最初のカウント値
        int chkline = 0;        // 計算中の小節(0小節目から現在の1つ前までを加算していく)

        // 現在の小節の1つ前までを処理
        while (chkline < line_num)
        {
            // 加算するポインタ数を定義
            int add = BMSDATA_RESOLUTION;

            // 現在チェック中の小節に倍率変更が存在しないか検索する
            foreach (CBarMagni bm in BarMagniList)
            {
                // 倍率変更命令が存在する場合
                if (bm.Lines == chkline)
                {
                    // 加算値をn倍する
                    add = (int)((float)BMSDATA_RESOLUTION * bm.Magni);
                    break; // 見つかったので終わり
                }
            }

            // 現在までの小節カウントを加算
            start_count += add;

            // 次の小節へ
            chkline++;
        }

        // パラメータを追加する
        int len = value.Length / 2;   // 数値の数

        // 現在の小節の幅を求める
        float now_bar_reso = (float)BMSDATA_RESOLUTION / (float)len; // デフォルトの1音符分のカウント値

        foreach (CBarMagni bm in BarMagniList)
        {
            // その小節に倍率変更命令が存在するか
            if (bm.Lines == chkline)
            {
                // 存在した場合は分割数を再計算
                now_bar_reso = ((float)BMSDATA_RESOLUTION * bm.Magni) / len;
                break; // 見つかったので終わり
            }
        }

        for (int i = 0; i < len; ++i)
        {
            if (!IsHexString(value.Substring(i * 2, 2)))
            {
                MessageBox.Show(value.Substring(i * 2, 2) + "は16進数ではありません");
                continue;
            }
            int num16 = System.Convert.ToInt32(value.Substring(i * 2, 2), 16);
            int num10 = System.Convert.ToInt32(num16.ToString(), 10);

            if (0 < num10)
            {
                // データが存在する場合
                AddData(channel, start_count + (int)(now_bar_reso * (float)i), num10);
            }
        }
    }

    // ソート
    for (int i = 0; i < BMSMAXBUFFER; ++i)
    {
        Sort(i);
    }

    return true;
}

赤字の場所が小節長リストを参照している部分ですが、ここではint lineに今回の小節番号が入っているので、これをそのままBmsBarの配列番号にアクセスし、その小節の長さとその小節の開始カウントを参照しています。

あとは小節長を音符の分割数で割ることで1音符分の長さを算出し、その小節の開始カウントと各音符の位置とを加算することで、最終的なBMSカウント値を算出しています。

なお、AddData()関数についてはあとで詳しく説明しますが、簡単に言えば指定のチャンネルに音符を追加するだけの関数です。

全チャンネル対応

1つのチャンネルだけでなく複数のチャンネルに対応する時のことを考えてみます。

List<CBmsData>  BmsDataList = new List<CBmsData>();   // 1つのチャンネルのデータのリスト(11のみを見ると仮定)

全チャンネル対応するために配列のリストを使います。

List<CBmsData>[] BmsDataList = new List<CBmsData>[BMSMAXBUFFER];      // 実データ

詳しくはこちら

AddData()関数について

上で全チャンネル用のメモリを確保できたので、ここではそれに実際の音符データを追加する関数を考えます。と言ってもここまで来たらそんなに難しいことはありません。

BMSのデータ仕様では実は小節長コマンドのみ特殊で、それ以外のチャンネルは全て音符データとして処理出来ます。そしてここでは音符データのみの追加を行う関数とするため、小節長コマンドは無視するようにします。

以下は小節長コマンド以外の全チャンネルを、pBmsDataに追加していくプログラムです。

// BMSデータ情報
public class CBmsData
{
    public bool Flag;// 存在しているか
    public int Times;// 鳴らすタイミングデータ(BMSカウント値)
    public int iData;// 鳴らすデータ(0~255)
    public float fData;// 小数点データ(テンポなど)

    public CBmsData(bool flag, int times, int idata, float fdata)
    {
        Flag = flag;
        Times = times;
        iData = idata;
        fData = fdata;
    }
}

////////////////////////////////////////////////////////////////////////////////////////
// 1つのデータを追加
////////////////////////////////////////////////////////////////////////////////////////
private bool AddData(int ch, int start_pos, int dat)
{
    // チャンネル番号をチェック
    if (ch < 0 || ch > 255)
        return false;

    if (ch == BMS_STRETCH)
        return false;

    if (dat == 0)
        return true;

    switch (ch)
    {
        case BMS_BPMINDEX: // 0x03
            BmsDataList[BMS_TEMPO].Add(new CBmsData(true, start_pos, (int)BmsHeader.BpmIndex[dat], BmsHeader.BpmIndex[dat]));
            break;
        default:
            if (BmsDataList[ch] != null)
            {
                BmsDataList[ch].Add(new CBmsData(true, start_pos, dat, (float)dat));
            }
            else
            {
                MessageBox.Show(ch + "チャンネルがnullです");
            }

            break;
    }

    return true;
}

まずCBmsDataクラスに2つの変数を追加しています。

1つは新仕様の小数値対応テンポのためfloat型のfData変数、もう1つはゲームで使用するbool型のFlag変数です。

Flagに関しては、音符の判定が既に終わったらFlagをfalseにして行って、すでに使用したデータかどうか判断するためのものです。

AddData()関数ではまず最初に対象のチャンネル番号か、データが正しく指定されているかをチェックし、
問題が無ければそのチャンネルに対してAdd関数を使って、そこにカウント値とデータをセットしています。

なお、新仕様のインデックス指定のテンポに関しては、ヘッダロードで解析したテンポリストに入っているテンポを参照して追加しますが、ここで重要なのは追加先はテンポインデックスチャンネルの0x08ではなく、通常のテンポ変更チャンネルの0x03に対して行います。

こうすることで、旧仕様の0x03チャンネルのテンポチェンジと0x08チャンネルのテンポインデックスリストを、全て0x03チャンネルにまとめることが出来ます。

データのソート

ここまででヘッダ部と実データ部のロードはほぼ完了しましたが、実はこのプログラムにはまだ欠陥があります。

それはAddDataは単にデータを後方に追加していくものであり、もしBMSファイル内のデータの小節番号が
途中で若い数値になった場合でも、常に後方に追加されてしまいます。
ということは、このデータを順番に羅列してみるとBMSカウント値が途中で若くなる箇所が出てきます。

別に順番に並んでいなくてもゲームを作ることは可能ですが、このままではゲームの最適化が行えなくなり、いろいろと効率の悪いゲームプログラムとなってしまいます。

このため、ここでは全てのデータをBMSカウント値で昇順になるように並び替えを行い、
あとで最適化が行えるようにデータを整理しておきます。

ちなみにこの並び替えはAddData()関数内で行うことも出来ますが、
ここではデータの追加処理と並び替え処理を明確に分けて考えているので、
並び替えの部分だけを別途用意することにします。

※ちなみにAddData内で行いたい場合は常に後方に追加するのではなく、
 既に登録済みのデータに対してBMSカウント値が順番になる位置を検索し、
 その位置に新しいデータを挿入するようにします。
 こうすれば挿入の段階で既に並び替えが終了した状態にすることが出来ます。

以下は指定のチャンネルにあるデータを並び替える関数です。
LINQのOrderByでソートしToListでリスト化してソートしてます。
なおロードに一回だけなので、速度が遅いLINQのソートを使ってますが、他のソート手段で行ってもOKです。

////////////////////////////////////////////////////////////////////////////////////////
// 指定チャンネルのデータを昇順に並び替える
////////////////////////////////////////////////////////////////////////////////////////
private bool Sort(int ch)
{
     if (ch < 0 || ch > BMSMAXBUFFER - 1)
     {
         MessageBox.Show("SortError");
         return false;
     }

     BmsDataList[ch] = BmsDataList[ch].OrderBy(s => s.Times).ToList();
}

並び替えに参照される値はCBmsDataのTimesの値となります。
この値を昇順に並べ替えます。

この処理は全ての実データをロードし終わった後に行います。
また、並び替えは全てのチャンネルについて行う必要があるため、
以下のようにforで全チャンネルを指定して行わせます。

////////////////////////////////////////////////////////////////////////////////////////
// BMSデータの読み込み
////////////////////////////////////////////////////////////////////////////////////////
BOOL LoadBmsData( const char *file )
{
    ~~~~~~ 省略 ~~~~~~

    while(1) {

        ~~~~~~ 省略 ~~~~~~

        // 実データを追加
        for (int i = 0; i < len; ++i)
        {
            if (!IsHexString(value.Substring(i * 2, 2)))
            {
                MessageBox.Show(value.Substring(i * 2, 2) + "は16進数ではありません");
                continue;
            }
            int num16 = System.Convert.ToInt32(value.Substring(i * 2, 2), 16);
            int num10 = System.Convert.ToInt32(num16.ToString(), 10);

            if (0 < num10)
            {
                // データが存在する場合
                AddData(channel, start_count + (int)(now_bar_reso * (float)i), num10);
            }
        }
    }

    fclose( fp );

    // ソート
    for( i=0;i<256;i++ )
        Sort( i );

    return TRUE;
}

小節バーの定義

ゲームでは各小節の区切りとして小節バーが表示されます。

そのためには小節の位置を管理するための小節バーリストが必要となりますが、
実はこの小節バーのリストというのは、最初の方で定義していたBMSBAR配列そのものです。

CBarInfoは以下のように定義していました。

// 小節情報
public class CBarInfo
{
    public bool Flag;       // 存在するか(無視)
    public int Times;       // 表示する時間
    public int Data;        // 小節についての追加情報(無視)

    public CBarInfo(bool flag, int times, int data)
    {
        Flag = flag;
        Times = times;
        Data = data;
    }
}
private List<CBarInfo> BarInfoList = new List<CBarInfo>();
// 小節情報
typedef struct _BMSBAR {
    float       fScale;                     // この小節の長さ倍率
    LONG        lTime;                      // この小節の開始位置(BMSカウント値)
    LONG        lLength;                    // この小節の長さ(BMSカウント値)
} BMSBAR,*LPBMSBAR;

BMSBAR      mBmsBar[1000+1];                // 小節データ(999小節時に1000番目も小節バーとして使うため+1しておく)

小節バーには特にサウンドIDや長さ情報は必要ないので、この中で必要な情報はlTimeだけです。

また小節数も既にlEndBarとして算出されているので、ゲーム上ではこのBMSBARを参照して、
必要な位置に必要な数だけ表示するだけなので、ここでは特に何もする必要はありません。