8-6 時間からBMSカウントを算出

前回はテンポ変更のある曲に対応するために、
1フレームごとにBMSカウント値を加算する方法を説明しました。

しかしこの場合、60fpsで動作するプログラムの場合は1フレームあたり
最大で16.6ms(=1000/60)ずつズレていってしまい、
特にテンポチェンジの多い曲ではその都度ズレが発生して、
徐々に譜面と一致しなくなってしまうといった問題が起こります。

さらに一番の問題として、もし処理落ちなどで60fpsが出せなかった場合、
その瞬間にテンポチェンジがあるとその間の時間がさらに長くなってしまうため、
もっと大きなズレが発生してしまいます。

これを回避するためにここではフレームごとに加算する方法をやめて、
経過時間からBMSカウント値を算出する方法を考えてみます。

これにより処理落ちなどでフレームレートが変わったとしても、
常にその瞬間の時間でBMSカウント値を求めることが出来るため、
ズレの無い完璧な同期を行うことが出来るようになります。

■BMSカウントの算出に必要な要素

時間からBMSカウント値を算出するにはテンポ変更リスト(テンポチェンジの個数分の配列)が必要です。

まず時間とはゲームの開始時を0とした時間です。
これはゲーム開始時の時間をゲーム中でのその瞬間の時間から差し引くことで求めることが出来ます。

ちなみにBGMとして始終1つの曲を鳴らすようなゲームの場合、
このサウンドの現在の再生時間を経過時間として使うことも出来ます。
DirectSoundでは再生中の時間を取得することが出来るため、
これを利用することで完璧に同期を取ることも可能となります。

テンポリストについてはBMSのロードにて読み込んだテンポ情報がそのまま利用出来ます。
ちなみにこのテンポ情報には「テンポ変更時のBMSカウント値」と「変更後のテンポ値」が含まれます。

時間からBMSカウント値を求めるということは、このテンポリストを解析することで行います。


■BMSカウントの算出はCBmsProクラス内で行う

BMSカウントを求めるにはテンポリストが必要と説明しました。
そしてこのテンポリストはCBmsProクラスの外部から参照することが出来ますが、
オブジェクト指向的にはBMSデータを管理するクラスに入れておいた方が何かと便利です。

もしこの計算処理をグローバル関数として作るとしたら、その関数にテンポリストを渡したりと
結局はCBmsPro内のデータを渡すことになるため、結果的には余計面倒になるだけです。

このためCBmsProクラスの関数として作っておけば、このクラスを別プロジェクトで使う際にも、
この関数がそのまま使用出来るといった利点があります。

ということでここではCBmsProに以下のような関数名で定義します。

class CBmsPro {
    :
public:
    :
    LONG GetCountFromTime( double sec );                    // 時間からBMSカウント値を計算
    :
};


この関数の引数のsecには経過時間として秒を与えます。
またこの値はdouble型のため、1秒未満の細かな時間も指定することが出来ます。

例えばtimeGetTime()は現在の時間をミリ秒として取得出来るので、この値を秒に変換する、
つまり1000で割る(doubleなのでキャストを忘れない)ことでこの関数の引数に渡すことが出来ます。
※正確には開始時が0となるように補正して渡すこと

またこの時間を高解像度カウンタを使って算出すれば、1ms以下の精度からBMSカウント値を求めることも出来ます。

この関数の戻り値はLONG型のBMSカウント値となりますが、
ここで求めたBMSカウント値を判定や描画に使用することになります。


■概要

肝心の関数内ではどのような処理を行うかですが、ここで簡単に概要を説明します。

まず最初にトータル時間を保存するための変数を用意します。
またこの変数は最初は0に初期化しておきます。

次にテンポ配列を1つずつ確認していきますが、
この時現在のテンポから次のテンポまでの間の時間を求め、
これを用意したトータル時間変数にどんどん加算していきます。

もし途中でこのトータル時間が求めたい時間を越えるようならいったんそこで加算をやめます。
するとトータル時間変数にはそれまでに計算したテンポ間の時間の合計が入っていることになります。

今度はそこから求めたい時間までの差分の時間を求めますが、
これは求めたい時間からそれまでに加算したトータル時間を差し引くことで算出できます。

またこの時一番最後に処理したテンポ配列に保存されているテンポ値というのは、
つまり求めたい時間までのテンポ値であることが分かります。

これらから残った差分時間でのBMSカウント値を算出することが出来ます。

最後にこのBMSカウント値と最後のテンポチェンジに記録されているBMSカウント値を足せば、
求めたい時間におけるBMSカウント値を算出することが出来るというわけです。

■アルゴリズム

ここでは上記の概要を実際にプログラミングするためのアルゴリズムについて説明します。

まずは下記の図を見てください。

※この図は正確では無いためおよその図として見てください
※ここではBMSカウントは1小節を9600とした値になっています

横軸が時間となっておりこの時間は一定の速度で進みます。
また赤い縦のラインではテンポチェンジが存在しており、その時のテンポ情報が上部の四角内に記載されています。
この例では初期テンポを含め、合計3つのテンポチェンジ情報があります。
※3つ目のテンポチェンジ以降は常にそのテンポ(BPM=150)のままとなります

最初の
青の区間ですがこれはテンポが180となっており、四分音符が4つ=1小節ということなので、
このときの経過時間を計算すると「60÷180×4」で「1.333秒」となります。
※「60÷180」は1拍分の時間を計算しており、それに4拍分を掛けることでトータル時間としています(詳細はこちら)

次の
赤い区間ですがここではテンポが120となっており、これも同じく1小節分あるので
この間の時間は「60÷120×4」で「2.000秒」となります。

例えば
緑の先頭部分までの経過時間を求めるとしたら、これはの領域を加算したものなので、
「1.333+2.000」で「3.333秒」ということになります。

これを逆に考えてみると3.333秒の時のBMSカウンタはいくつなのかということになりますが、
上の図を見ると3.333秒目はテンポチェンジ直後であり、
そのテンポチェンジ情報にはBMSカウント値として「19200」と設定されているため、
つまり3.333秒目は19200カウントであるということが分かります。


これが理解出来ればあとは簡単です。


例えば上のように4.000秒目のBMSカウントを知りたい場合ですが、
図から3.333秒目以降にはテンポチェンジは存在しないため、
この時は3つ目のテンポ状態、つまり「BPM=150」であることがわかります。

あとはその前のテンポチェンジを含めた合計の時間が既に3.333秒と分かっているため、
4.000から3.333を引いた値「0.667秒」が緑色の部分の先頭からかかった時間となります。

テンポ150で0.667秒進んだ場合のBMSカウント値は以下の式で求められます。

BMSカウント値 = 0.667 × (9600 ÷ 4) × ( 150 ÷ 60 )
※この式は前にも説明していますが、要はまず1秒あたりのBMSカウント数を求め、
 これに時間をかけることでBMSカウント値が求められます
 (150÷60で1秒間の拍数、それに1拍あたりのBMSカウント値=9600÷4をかけることで、
  1秒あたりのBMSカウント値が算出されるので、あとは求めたい時間をかけるだけです)

最後に求めたBMSカウントと最後のテンポチェンジ時のBMSカウント値を足すことで、
最終的なBMSカウント値が計算出来たことになります。

ちなみにこの例では最後のテンポチェンジ時のBMSカウントが「19200」、
そして残りのBMSカウント値が「6000.667(切り捨てて6000)」となるので、
これらを足して「25200」が最終的なBMSカウント値となります。


■プログラム

上記のアルゴリズムをCBmsProに組み込むと下記のようなプログラムになります。

////////////////////////////////////////////////////////////////////////////////////////
// 時間からBMSカウント値を計算
////////////////////////////////////////////////////////////////////////////////////////
LONG CBmsPro::GetCountFromTime( double sec )
{
    LONG cnt = 0;           // BMSカウント
    double t = 0;           // BMS上の時間
    double bpm = 130;
    
    if( iBmsData[BMS_TEMPO]>0 ) {
        bpm = pBmsData[BMS_TEMPO][0].fData;     // 初期BPM
    }

    if( sec<0 )
        return 0;

    // 指定時間を越えるまでタイムを加算
    int i;
    for( i=0;i<iBmsData[BMS_TEMPO];i++ ) {

        // 1つ前の時間と新しい時間との経過時間から秒を算出
        double add = (double)( pBmsData[BMS_TEMPO][i].lTime - cnt ) / ( bpm / 60 ) / (BMS_RESOLUTION / 4);

        // 現在のテンポ値で時間が過ぎたら抜ける
        if( t+add>sec ) {
            break;
        }

        t += add;                                       // 経過時間を加算
        bpm = (double)pBmsData[BMS_TEMPO][i].fData;     // 次のBPMをセット
        cnt = pBmsData[BMS_TEMPO][i].lTime;             // 計算済みのカウントをセット
    }

    // 指定時間と1つ前までの時間の差分
    double sub = sec - t;

    // 差分からBMSカウント数を算出
    LONG cnt2 = (LONG)(sub * (BMS_RESOLUTION / 4) * ( bpm / 60 ));

    // BMSカウント値に加算
    cnt += cnt2;

    return cnt;
}


まず始めに計算に使用するテンポラリ変数を初期化しています。

ちなみにtは上の方で説明したトータル時間を保存する変数で、
cntとbpmは最後に使用したテンポ情報をバックアップするための変数です。

bpm変数はBMSの仕様ではテンポが定義されていない場合は130を設定するようになっているため、
最初にその値で初期化しておいてからテンポリストが1つ以上存在する場合のみ、
先頭のテンポ情報のテンポ値を初期値として設定しています。

そのあとはテンポリスト全てをチェックするためのループに移行します。

ループ内では1つ前のテンポリストとの差分からその間の時間を算出し、
今まで加算したトータル時間と今回の時間を足した時間が、
目的の時間をまだ超えていなければ次のタイマーをチェックします。

なお次のループにてその間の時間を算出するために、今回算出した時間をトータル時間に加算し、
さらに最後に処理したテンポとBMSカウント値をバックアップしています。

もしトータル時間と今回の時間を足した時間が目的の時間を超える場合はこのループを抜けますが、
このループを抜けた状態というのが、上で言う最後のテンポチェンジまでのトータル時間と
その時のBMSカウント値、そしてその時のテンポ値がバックアップされた状態となっています。

あとは残った時間と最後のテンポ値からBMSカウント値を算出し、
これを最後のテンポ情報のBMSカウント値に足すことで、
最終的に目的の時間でのBMSカウント値を求めることが出来ます。


ゲーム中にこの関数を呼び出すことで、その瞬間の時間から常に正確なBMSカウント値を求められるので、
これを使って描画や判定などに利用します。