4-3 BMSデータ管理方法

ここではBMSファイルの音符データの管理方法ついて説明しています。

■BMSデータの記憶方法 OLD VERSION (過去の方法)

まず最初に説明することはBMSデータの扱い方についてです。
昔、自分が音ゲーを作り始めたころ、BMSの仕様についてほとんど知らなかった時にやったやり方が以下の方法でした。

1小節は必ず16分割するため、1小節あたり16個の配列を使用します。
つまりプログラム的には int sound[16*bar]; (barは小節の数)という定義方法でした。
時間ごとに配列を進めていき、そのときの配列が0でなければ音が存在するというやり方です。

たとえば以下のような配列にデータが入っていたとすると・・・

int sound[16] → [0] [1] [2] [3] [4] [5] [6] [7] [8] [9] [A] [B] [C] [D] [E] [F]
                 00  00  00  00  01  00  00  00  00  00  00  00  01  00  00  00

これは、下のようなデータとなります。

この方法だと以前説明したような連符に対応できないことや、最小の音符が16分音符になってしまうという欠点がありました。

また、さらに00が入った配列というのは無駄な領域であり、メモリを1小節分確保すると、
少なくともsizeof(int)*16(intは4バイトなので4x16=64)個のバイト数を食ってしまいます。

そして、実際にゲーム中でスクロールを行う場合、小節間の長さから16分割して座標を求めて・・・
というような複雑な処理をしてから画面に表示しなければならずとても大変だったことを覚えています。

しかしこの方法はCPUを食わないため、遅いマシンでもそれなりに処理速度は速かったのです。



■BMSデータの記憶方法 NEW VERSION (現在の方法)

そしてもう1つの方法ですが、1個のデータにつき1個の構造体を使うというやり方です。
構造体には鳴らすデータとその鳴らすデータのタイミング情報を記憶させます。

たとえば、下のような構造体を考えます。

typedef struct _OBJECT {
    long lTime;
    int iData;
} OBJECT;

lTimeとは先頭を0とした場合のカウント値、つまり譜面上での位置となります。

ここでは1小節あたり9600とした場合の値が入ることとします。
※OLD方法でいうとここが16となる

なお、配置データの値をこのサイトでは
BMSカウント値と表現します。
今後BMSカウント値と言ったら
1小節が9600である値と思ってください。
※なぜ9600かというと、これは2分音符や4分音符、8分音符、16分音符といった一般的な音符の他に、
 3分音符や12分音符(3連符)、20分音符(5連符)などの少し特殊な音符でも割り切れるためです。
 ちなみに昔からあるMIDI規格でも、分解能として192や480、960というように割り切れる値が使われています。


例として以下のような譜面なら、たった2個分の構造体で定義できます。

OBJECT obj[2];
obj[0].lTime = 2400;    // 1小節が9600だとすると2拍は2400となる
obj[0].data = 0x01;   // 鳴らすデータ
obj[1].lTime = 7200;    // 4拍目なので2400*3となる
obj[1].data = 0x01;   // 鳴らすデータ

この方法では1小節あたり9600の分解能で表せるため、理論上9600分音符まで作ることが出来ます。
ただし、毎回forで全てのデータをチェックするという方法なため、データ量が増えれば増えるほどCPUに負荷がかかります。

といっても現在のCPUはめちゃめちゃ速いため、100個や1000個、10000個確保したからといって処理落ちはほぼありえません。
※Pentium3クラスのPCで「for( i=0;i<1000;i++ ) { }」というただのループプログラムは1msもかかりません。

また、ある方法を使用すればこの処理をとても軽くすることもできます。
この最適化方法はのちに説明するとして、これ以降はデータの保存方法としてこのやり方を採用することにします。



■ファイルロード関数について

ここではBMSファイルをロードするために使用する標準ファイル関数について説明します。
なお、ファイル関数はWindows関数を使うのではなく標準Cライブラリを使用します。
このため他のOSでも動作するプログラムにすることが出来ます。

stdio.hのファイル関数(fopenなど)を既に知っている人はこの章は読み飛ばしてください。

ここで使用する関数を使うにはstdio.h(Standard I/O、標準入出力)をインクルードします。
このヘッダはどのCコンパイラでも必ず入っています。
通常のプログラムでもよく使用する関数のため、すでに知っている場合は読み飛ばしてかまいません。

まず、ファイル関数はファイルを1つのメモリ(配列)領域として考えることが出来ます。
メモリにアクセスする場合はポインタと言う概念を使い、
現在指し示しているメモリの場所を記録しておき、それを利用してデータの入出力を行います。

ファイルも同じで、このポインタに相当するものにFILE構造体というのが存在します。
ファイルをオープンするときにこのFILEポインタを取得し、
このファイルポインタを使用して、次のデータをロードしたり追加したりします。

ファイルポインタは、通常は現在のポインタからデータをロードすると、
自動的にロードした次のファイルポインタへ移動します。
このため、自分でファイルのポインタを移動させるということは必要ありません。
またこのような順番に次のデータを処理することをストリーム処理と言います。

FILE *fopen( const char *filename, const char *mode );
指定のファイルを開きます。
filenameにはNULLで終わるファイル名を指定します。
絶対パスでも相対パスでも指定できます。
modeには以下の文字列を指定できます。
"r" 読み出しモードで開きます。
"w" 書き込みモードで開きます。既存のファイルは上書きされ、
ファイルサイズが0から始まります。
"a" 追加で開きます。ファイルが無ければ新規で作成します。
その他に読み書き両方出来るモードがありますがここでは割愛します。
また、上記の文字にさらに追加で"t"や"b"をつけると、改行の変換を制御できます。
"t" では改行コード0x0d 0x0aを'¥n'に変換してロードしますが、"b"ではそのままの
データを返します。つまり、テキストファイルを処理するときは"t"を、
バイナリデータを処理するには"b"を指定すると良いと言うことです。
ちなみに、初期状態では"t"が選択されているため、省略した場合はテキストとして
開くと言う意味になります。デフォルトを変えるための関数もありますが、
通常は変更しないので、ここでは紹介しません。(自分が使ったこと無いからw)
例) "rb" : 読み込みをバイナリデータとして開く
  "wb" :書き込みをバイナリデータとして開く
int fclose( FILE *stream );
指定したファイルポインタを閉じます。
streamにはfopenで返されたポインタを指定します。
もし閉じ忘れても、アプリケーションを終了させるときに自動で閉じられますが、
この間は他のソフトからアクセスできなくなったりする場合があるので、
通常は、何か操作をするとき以外は閉じておきましょう。
int fseek( FILE *stream, long offset, int origin);
指定された位置にファイル ポインタを移動します。
移動が成功すると0が返り、次回から読み込みや書き込みはここから開始されます。
streamにはfopenで返されたポインタを指定します。
offsetにはoriginで指定したところからのバイト単位の場所を指定します。
originには以下の指定が出来ます。
SEEK_CUR 現在のファイルポインタの位置から
SEEK_END ファイルの終端から
SEEK_SET ファイルの先頭から
この関数を使用する時というのは、先頭からもう一度やり直すときや、
ファイルのサイズを取得したいとき(ウラワザ)に使います。
long ftell( FILE *stream );
現在のファイルポインタの位置を取得します。
普通は使いませんが、以下のようにするとファイルのサイズを取得することが出来ます。
 fseek( fp,0,SEEK_END );:
 size = ftell( fp );
なお、当然ファイルポインタが変わってしまうので、取得し終わったらポインタを先頭に持ってきましょう。
size_t fread( void *buffer, size_t size, size_t count, FILE *stream );
ストリームからデータを読み出します。
bufferは取得したデータを保存するバッファ。
sizeはロードしたいデータの数。
countにはsizeを1ブロックとしてそれを何個ロードするか。
streamにはfopenで開いたポインタ。
戻り値に返る値は、countで指定した数が返れば正常にロードされたと判断できます。
このcountはバイト単位ではなく、ブロック数を返すためちょっとややこしいですが、
通常はsizeで指定したものを1個ロードすればいいはずなので、
countには1を入れ、戻り値が1じゃなければ失敗したと判断します。
size_t fwrite( const void*buffer, size_t size, size_t count, FILE *stream );
ストリームにデータを書き込みます。
引数はfreadと同様です。
int feof( FILE *stream );
ファイルの終わりかどうかを返します。
ファイルの終わりなら0以外が返ります。
char *fgets( char *string, int n, FILE *stream );
ストリームから文字列を読み出します。
stringにはメモリバッファのポインタを、nにはそのバイト数を入れます。
成功するとstringには1行のデータが返されます。
なお、このとき改行も最後に含まれます。
fopenでテキストモードで開いたものに使用します。
でないと改行を自分で制御しなければならないので大変です。

nはバッファのサイズなので、ファイルの1行がこれ以上だと改行が判断できません。
このため誤動作を防ぐために、バッファは大きめにしましょう。

この他にも文字列を追加したり、1文字(1バイト)だけをロードしたりする関数もありますが、
ここでは使用しないので紹介はしません。

またファイルと言うのはストリームなので、
ちょくちょくファイルポインタを移動するというのは通常はしてはいけません。
こうするとパフォーマンスが落ちてしまいます。

BMSで使用する関数ではさらにバイナリデータは扱わないため、
実際はfopen()、fclose()、fgets()の3つの関数のみを使用します。