ICON公式ブログ

ICON公式ブログ

静岡大学コンテンツ制作サークルICONの公式ブログです

C言語とDxLibで音ゲーのような何かをつくってみるvol.3【アドベントカレンダー2019 5日目】

この記事は ICON Advent Calendar 2019 5日目の記事です。

f:id:iconcreator:20191231210102p:plain

はじめに

こんばんは、プロ班の 杞憂 です。

本当はソフトウェア設計の話をしようと思っていたのですが、なんやかんやあって今回も音ゲー記事です。

(C言語の文字列の取り扱いに苦しめられて遅刻してしまいました、大変申し訳ございません)

では参りましょう。

音ゲーのような何かをつくってみるvol.3

これまでのおさらい

vol.1ではノーツや判定ラインの描画とテンポ合わせ、vol.2ではノーツと曲のテンポを合わせて、さらにノーツを叩けるようにしました。一応音ゲーのような何か...というより音ゲーの基本的な仕組みが出来ました。

しかし、音ゲーはただリズムに合わせて等間隔でボタンをポチポチするだけのゲームではないです。そんな擬似メトロノームみたいなことをしても面白くありません。どのタイミングでどんな操作をするか、曲によって違う「譜面」があることで音ゲーは面白くなるのです。

ですので、今回は譜面について考えていこうと思います。

ところで、vol.1, 2では読者としてC初心者を想定し、構造体や関数を使用しないという縛りをしていました。しかし、これらを使用しないとコードがめっちゃ見づらくなるという問題や、そもそも構造体や関数を使用しないことが本当にC初心者のためになるのかという疑問がわいたため、今回からは構造体や関数を使っていきます。

ちなみに、前回の最後のコードを構造体や関数を使用し、かつコード全体をもう少し見やすく整理したものがこちらです。

コードを見る

#include "DxLib.h"

#define WIN_W 800 // ウインドウの横幅
#define WIN_H 600 // ウインドウの縦幅
#define LANE_NUM 4 // レーンの数
#define NOTE_NUM 1000 // ノーツの最大数
#define NOTE_WIDTH 72 // ノーツの幅
#define NOTE_HEIGHT 20 // ノーツの高さ
#define JUDGE_Y 500 // 判定ラインのY座標

static const int KEYS[LANE_NUM] = {
    KEY_INPUT_Z,
    KEY_INPUT_X,
    KEY_INPUT_C,
    KEY_INPUT_V
};

struct NOTE
{
    bool flag = false;
    float x = 0.f;
    float y = 0.f;
};

void initNotes(int perfect_time_size[LANE_NUM], NOTE notes[LANE_NUM][NOTE_NUM]) {
    for (int col = 0; col < LANE_NUM; col++) {
        int lane_length = perfect_time_size[col];
        for (int row = 0; row < lane_length; row++) {
            notes[col][row].flag = true;
            notes[col][row].x = 200.f + 150.f * col;
        }
    }
}

void updateNotes(double current_time, double perfect_times[LANE_NUM][NOTE_NUM], NOTE notes[LANE_NUM][NOTE_NUM]) {
    // ノーツ座標更新
    for (int col = 0; col < LANE_NUM; col++) {
        for (int row = 0; row < NOTE_NUM; row++) {
            if (notes[col][row].flag)
                notes[col][row].y = WIN_H * (float)(current_time - perfect_times[col][row]) / 2 + JUDGE_Y;
        }
    }

    // 画面外かつ判定範囲外に出たノーツを削除
    for (int col = 0; col < LANE_NUM; col++) {
        for (int row = 0; row < NOTE_NUM; row++) {
            if (notes[col][row].flag
                && WIN_H + NOTE_HEIGHT < notes[col][row].y
                && 0.3 < current_time - perfect_times[col][row])
            {
                notes[col][row].flag = false;
            }
        }
    }
}

void judgeNotes(double current_time, double perfect_times[LANE_NUM][NOTE_NUM], NOTE notes[LANE_NUM][NOTE_NUM], char buf[256]) {
    // レーンに対応するキーが押されていて、かつ判定範囲内であればノーツを削除
    for (int col = 0; col < LANE_NUM; col++) {
        for (int row = 0; row < NOTE_NUM; row++) {
            if (buf[KEYS[col]] == 1
                && notes[col][row].flag
                && -0.3 < current_time - perfect_times[col][row]
                && current_time - perfect_times[col][row] < 0.3)
            {
                notes[col][row].flag = false;
            }
        }
    }
}

void drawNotes(NOTE notes[LANE_NUM][NOTE_NUM]) {
    for (int col = 0; col < LANE_NUM; col++) {
        for (int row = 0; row < NOTE_NUM; row++) {
            if (notes[col][row].flag) {
                DrawBoxAA(notes[col][row].x, notes[col][row].y - NOTE_HEIGHT / 2,
                    notes[col][row].x + NOTE_WIDTH, notes[col][row].y + NOTE_HEIGHT / 2, GetColor(0, 255, 0), TRUE);
            }
        }
    }
}

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) {

    char buf[256]; //キー押下状態格納用配列

    double perfect_times[LANE_NUM][NOTE_NUM] = {
        { 1.0, 1.5 },
        { 1.5, 3.0 },
        { 1.0},
        { 2.0, 2.5, 3.5 }
    };
    int perfect_time_size[LANE_NUM] = { 2, 2, 1, 3 };

    NOTE notes[LANE_NUM][NOTE_NUM];

    SetMainWindowText("OtogeNoYounaNanika"); //ウインドウのタイトルを設定
    ChangeWindowMode(TRUE); //ウィンドウモードで起動
    SetGraphMode(WIN_W, WIN_H, 32); //画面の解像度指定
    SetWindowSizeChangeEnableFlag(FALSE); //画面サイズ変更不可
    if (DxLib_Init() == -1) { return -1; } //DxLib初期化処理
    SetDrawScreen(DX_SCREEN_BACK); //描画先を裏画面に設定

    initNotes(perfect_time_size, notes);

    int bgmHandle = LoadSoundMem("音ファイルのパス");
    PlaySoundMem(bgmHandle, DX_PLAYTYPE_BACK);

    LONGLONG start_count = GetNowHiPerformanceCount();

    //while(裏画面を表画面に反映, メッセージ処理, 画面クリア)
    while (ScreenFlip() == 0 && ProcessMessage() == 0 && ClearDrawScreen() == 0) {
        
        GetHitKeyStateAll(buf);
        if (buf[KEY_INPUT_ESCAPE] == 1) break; // 終了

        LONGLONG now_count = GetNowHiPerformanceCount();
        double current_time = (now_count - start_count) / 1000000.0;
        //double current_time = 1.5;

        updateNotes(current_time, perfect_times, notes);

        judgeNotes(current_time, perfect_times, notes, buf);

        DrawLine(0, JUDGE_Y, WIN_W, JUDGE_Y, GetColor(255, 255, 255));

        DrawFormatString(10, 10, GetColor(255, 255, 255), "t:%f", current_time);
        
        drawNotes(notes);
    }
    DxLib_End(); //DxLibの終了処理
    return 0; //正常終了
}

...まあ、整理どころか明らかに違う箇所が結構ありますね。解説していきます。

まず、前回の記事で、プログラム中ではbarと書きつつコメントや記事の本文ではノーツと言っていたので、プログラム中でbarと書いていたものをnoteに書き換えました。

LANE_NUM 4NOTE_NUM 1000は元々LANE_SIZEBAR_SIZEと書いていましたが、SIZEだと配列の数ではなく「大きさ」ともとれてしまうため、より「数」ということを強調してNUMとしました。ただこれも少し微妙な語句な気がします。

また以前は%(剰余算)を利用したカウントのループ(0, 1, ..., 39, 0, 1, ...)を利用していたのですが、分かりづらいので最初から全て入れられるようにNOTE_NUMの値を大きくしています。

static const int KEYS[LANE_NUM]は前のプログラムのkey[LANE_SIZE]とほぼ同じです。定数なのでconstを付け、ついでに定数は1つだけでいいのでstaticを付けています。が、staticはなくてもいい気がします。

struct NOTEは前のプログラムでbar_fbar_yとしていたものをNOTEという名前をつけてまとめたものです。ついでにX座標も追加しています。

次に、いくつか関数がありますがとりあえず今は置いておいて、WinMain関数内の説明に入ります。

perfect_times[LANE_NUM][NOTE_NUM]は前のプログラムではbar_cとしていました。ノーツが判定ラインに到達すべき時刻を入れた配列が各レーンごとに用意されています。

ちなみに、値を代入していないperfect_times[0][4]やperfect_times[2][381]などの値は全て0で初期化されています。

perfect_time_size[LANE_NUM]は初めましてですね。今回のプログラムではperfect_timesに0が入る可能性があり、初期化された際に入った0と区別がつかないため、各レーンごとの有効な要素の個数をperfect_time_sizeに入れています。
今回の場合、perfect_times[0][0]とperfect_times[0][1]を指定している(有効な)のでperfect_time_size[0]は2になり、perfect_times[3][0]とperfect_times[3][1]、perfect_times[3][2]を指定しているのでperfect_time_size[3]は3となります。

NOTE notes[LANE_NUM][NOTE_NUM];でノーツを必要数つくっています。

initNotes関数では、必要な分だけnotesのフラグを立たせています。また、この記事でつくる音ゲーは上から下にノーツが降ってくる、つまりメインループ中ではY座標しか変化しないため、このタイミングでX座標も代入しています。

ちなみに、for文ではよくiやjが用いられますが、今回のような2次元配列だとperfect_times[i][j]なのかperfect_times[j][i]なのか分かりづらくなるため、col(列、column)とrow(行)を用いています。

そういえば、普通にboolを使用していますが、boolはC++言語の機能(仕様?)だったりします。まあintの0,1でやるよりもはるかにわかりやすいのでいいでしょう。

WinMain関数内に戻って、
double current_time = (now_count - start_count) / 1000000.0;は前のプログラムではcurrentTime = (int)((now_time - start_time) / 1000) - offset;と書いていました。前のプログラムではms単位の整数で時間を取得していたのであのように書きましたが、今回はs単位の小数で取得したかったのでこのように書いています。
GetNowHiPerformanceCount関数はマイクロ秒を整数で返してくれるので、その値を1000で2回割れば秒になりますね。

updateNotes関数ではnotesのY座標を更新し、さらにノーツの判定範囲外(今回の判定範囲は-0.3~0.3、詳しくはjudgeNotes関数内)であり、かつnotesのY座標が画面外に出ている、つまりウインドウの高さであるWIN_Hよりも大きい時にノーツを削除しています。ただし、WIN_H < yとしてしまうと画面の端に到達した途端にノーツが消えてしまってやや不自然なので、WIN_Hにノーツの高さであるNOTE_HEIGHTを足した値よりもY座標が大きい時にノーツを消しています。厳密にはNOTE_HEIGHT/2を足せばいいんですけどね。

judgeNotes関数では、対応するキーの入力があったタイミングとperfect_timesを比較して判定を行っています。今回はキー入力があったタイミングとperfect_timesの差が0.3[s]未満であれば「ノーツを叩いた」としています。結構ゆるいです。

DrawFormatString(10, 10, GetColor(255, 255, 255), "t:%f", current_time);で曲が開始してからの時間を表すcurrent_timeの値を表示しています。

drawNotes関数ではnotesのx,yを利用してノーツを画面に描画しています。ただ、画面外のノーツを描画するのは無駄なので、if (0 - NOTE_HEIGHT < notes[col][row].y && notes[col][row].y < WIN_H + NOTE_HEIGHT)という条件を追加してもいいかもしれません。画面外の描画はDxLib側がはじいてくれるんでしたっけ?記憶があいまいです。調べておきます。

これで説明を終わります、お疲れ様でした。

ちなみに、double current_time = (now_count - start_count) / 1000000.0;コメントアウトして、その下の//double current_time = 1.5;//を消して実行してみると、current_timeが1.5のときのノーツの様子が見られます。...そりゃそうですよね。1.5のところを他の値に変えてみると、任意の時刻のノーツの様子が見られます。私はこれ地味に感動したんですけど、伝わってます?えっ?伝わってない?あっ、そうですか。

f:id:iconcreator:20191231203701p:plain

...あれ?まだ本題の譜面データ形式の話に入っていませんね。おかしいですね?

譜面データ形式

上の記事でも言及されていますが、音ゲーの基本的な仕組みは「ノーツを叩くべきタイミングでボタンやらキーやらを押すとノーツが消える」です。まあ消えなくてもいいのですけど、何かしら反応が返ってきます。つまり、直接必要なデータは「叩くべきタイミング」だということはなんとなく想像がつくと思います。

それならば、

double perfect_times[LANE_NUM][NOTE_NUM] = {
    { 1.0, 1.5 },
    { 1.5, 3.0 },
    { 1.0},
    { 2.0, 2.5, 3.5 }
};

としていたところを、ファイルで

1.0, 1.5
1.5, 3.0
1.0
2.0, 2.5, 3.5

として、あとはこのデータをそのまま配列に代入すればできますよね。別にこれで動きます。

しかし、この形式には問題があります。

まず、人間にとってめっちゃ見づらいこと。

このファイルの中身を見ても、譜面がどんな感じなのか全く想像できません。「嘘だぁ、俺は出来るよ」という人もいるのかもしれませんが、今回は簡単のために1.0や1.5などのきりの良い数字にしているだけで、実際は23.947583とかがいっぱい並びます。少なくとも私は「???」です。しかも今の小数はfloat型の範囲内で、double型なら小数点以下の桁数がもっと増えます。なんとなく...は読める、のかもしれませんが、それは「頑張って読める」です。何で頑張らなくちゃいけないんですか。時間をかける場所を間違えています。

そして、譜面をつくりにくいこと。

譜面って、たいてい手動でファイルに書いてつくります。譜面づくりをサポートしてくれるツールをつくるのもアリ、というかそんな便利ツールをつくれるならその方がいいのですが、たいてい優先度が便利ツール<音ゲーになってしまって、譜面は手で書くケースが多いです。その際、譜面を書く負担が少ないほど効率的に譜面をつくることが出来るので、譜面作成者の負担を減らせるような形式がいいと思います。

あと細かい話ですが、桁数の多い小数をずらずら並べるため、ファイルサイズが大きくなるという問題もあります。

別に、プログラムが正しく動いてゲームができれば、どんなデータ形式であろうと構いません。しかし、私の経験上、音ゲーには他のゲームと違って、「譜面をつくること」にも面白さがあるのだと思います。

やっぱり、人間にとってわかりやすく、かつ譜面作成者の負担を減らせるといいですよね。どんな譜面データ形式にすればいいのでしょう?

まず、小数をやめましょう。あと、人間にとってわかりやすい、ということはある程度実際のノーツの様子を模した形式がいいですかね。

こんな感じですかね?

1,0,1,0
1,1,0,0
0,0,0,1
0,0,0,1
0,1,0,0
0,0,0,1

譜面作成者の負担軽減を考慮するなら、

1,0,1
1,1
0,0,0,1
0,0,0,1
0,1
0,0,0,1

こんな感じで省略できるようにしてもいいかもしれません。

ただ、この場合「叩くべきタイミング」はどのように求めるのでしょう?

...これだけだと情報が足りないようですね。

そういえば、bpmとoffsetの話がありましたね。これを利用しましょう。今回の場合はoffsetは1.0、bpmは120ですね。

1.0,120
1,0,1
1,1
0,0,0,1
0,0,0,1
0,1
0,0,0,1

1行目の1つ目がoffset(単位はs)、2つ目がbpmです。 あとは、60 * 行番号 / bpm + offset(行番号は2行目を0、3行目を1...とする)を計算すれば叩くべきタイミングを求められますね。

この形式は私が高校時代につくった音ゲーに採用しました。まあ実際にはファイル入出力が面倒で直接文字列に1010_1100_0001_...と書いたんですけどね...

個人的にはこれがわかりやすいかなと思っています。

さて、譜面作成者の負担を軽減するにはどうしよう、と考えていた私は「そもそも既存の譜面を流用してテストできたら楽じゃね?」という思考にたどり着きました。

既存の譜面。私は「太鼓さん次郎」を思い出しました。あちらは1レーンなので、こちらでは4つのレーンのうち1つのレーンにしか適用できないのですが、たとえ1レーンだけでも同じ曲ならすぐに遊べるのは大きなメリットがあるような気がします。

そのままでも使用でき、かつ4レーンに拡張することもできる形式...

BPM:120
OFFSET:-1.0
#START
1100,0100,1,0011
0,1,0,0100

こんな感じですかね。#STARTの次の行からは、

1レーン目の1小節目,2レーン目の1小節目,3レーン目の1小節目,4レーン目の1小節目
1レーン目の2小節目,2レーン目の2小節目,3レーン目の2小節目,4レーン目の2小節目
...

といった形式です。また、各小節は
1 ... 1分音符が1つ
11 ... 2分音符が2つ
1111 ... 4分音符が4つ
...
例: 1001 ... 4分音符、2分休符、4分音符 例: 10010100 ... 8分音符、4分休符、8分音符、8分休符、8分音符、4分休符 といった感じで表しています。

(詳細は太鼓さん次郎の譜面形式を調べてください)

もちろん省略できるようにもしてあり、太鼓さん次郎の譜面をそのまま使うと1レーン目だけノーツが流れます。

この形式だと、"11001_1011011"のような、音楽用語でいう「4分の4拍子ではない」譜面を簡単に書くことが出来ます。先ほどの形式は4分の4拍子を前提としていたので、そもそも実現できません。先ほどの形式を拡張させ、1小節あたり何拍必要かを指定するようにしたとしても、1小節あたり5x7=35拍必要になる、つまり35行必要になります。譜面はほとんど0ばかりになるとしても。

ただし、元々1レーンだけを想定した形式だからか、レーンを増やすと若干読みづらくなります。(慣れればそうでもないです)

この形式は先日のデジゲー博で展示した音ゲーに採用しました。

他のやり方としては、

上の記事で紹介されているような形式もあります。

また、譜面作成ツールを公開している方もいます。ツール作成者に最大限の敬意と感謝をしながら、これらを利用して譜面をつくるのもアリだと思います。もちろんその場合はそのツールが出力する譜面データ形式に合わせるなり変換するなりします。後述しますが、譜面データ形式の変更は実はそこまで音ゲープログラムに影響しません。

ここまでいろいろ語ってきましたが、これはあくまで私がおかれている状況でのベターな形式です。人によってベターな選択は変わると思います。あなたにとってベターなのはどんな譜面データ形式でしょうか?ぜひ考えてみてください。

ファイル入出力

次のコードは太鼓さん次郎拡張形式の譜面データファイルを読み込むプログラムです。

データファイルの名前はtest.txt、中身は

TITLE:Test title
WAVE:test.wav
BPM:120
OFFSET:-1.0
#START
1100,0100,1,0011
0,1,0,0100

こうです。TITLE(曲名)とWAVE(音楽ファイルのパス)が追加されています。

コードを見る

#include "DxLib.h"

#define WIN_W 800 // ウインドウの横幅
#define WIN_H 600 // ウインドウの縦幅
#define MAX_READ 2000
#define LANE_NUM 4 // レーンの数
#define NOTE_NUM 1000 // ノーツの最大数
#define NOTE_WIDTH 72 // ノーツの幅
#define NOTE_HEIGHT 20 // ノーツの高さ
#define JUDGE_Y 500 // 判定ラインのY座標

static const int KEYS[LANE_NUM] = {
    KEY_INPUT_Z,
    KEY_INPUT_X,
    KEY_INPUT_C,
    KEY_INPUT_V
};

struct MUGIC_DATA
{
    double bpm = 150;
    double offset = 0;
    char song_name[MAX_READ] = "unknown";
    char music_file_path[MAX_READ];
    double perfect_times[LANE_NUM][NOTE_NUM] = { 0 };
    int perfect_time_size[LANE_NUM] = { 0 };
};

bool loadHumenOptions(MUGIC_DATA* music_data, FILE* fp) { // 引数に余計なものが入りすぎ
    char str[MAX_READ], * next_token = NULL, tstr[MAX_READ];
    while ((fgets(str, MAX_READ, fp)) != NULL) {
        strncpy_s(tstr, MAX_READ, str, 6);
        if (strcmp(tstr, "#START") == 0) {
            return true;
        }

        char* first = strtok_s(str, ":", &next_token);
        if (first == NULL) continue;

        if (strcmp(first, "BPM") == 0) {
            char* second = strtok_s(NULL, ":", &next_token);
            music_data->bpm = atof(second);
        }
        else if (strcmp(first, "OFFSET") == 0) {
            char* second = strtok_s(NULL, ":", &next_token);
            music_data->offset = atof(second);
        }
        else if (strcmp(first, "TITLE") == 0) {
            char* second = strtok_s(NULL, "\n", &next_token);
            strcpy_s(music_data->song_name, second);
        }
        else if (strcmp(first, "WAVE") == 0) {
            char* second = strtok_s(NULL, "\n", &next_token);
            strcpy_s(music_data->music_file_path, second);
        }
    }
    return false;
}

void loadHumen(MUGIC_DATA* music_data, FILE* fp) {
    char line[MAX_READ], * next_token = NULL, delim[4] = ", \n";

    for (int col = 0; col < LANE_NUM; col++) {
        music_data->perfect_time_size[col] = 0;
    }

    for (int row = 0; (fgets(line, MAX_READ, fp)) != NULL; row++) {
        char* str = strtok_s(line, delim, &next_token);
        if (str == NULL) continue;

        for (int col = 0; str != NULL; col++) {
            int length = strlen(str);
            for (int i = 0; i < length; i++) {
                if ('1' <= str[i] && str[i] <= '9') {
                    music_data->perfect_times[col][music_data->perfect_time_size[col]++] = 60.0 * 4 * (row + (double)i / length) / music_data->bpm - music_data->offset;
                }
            }
            str = strtok_s(NULL, delim, &next_token);
        }
    }
}

bool loadHumenData(MUGIC_DATA* music_data, const char* file_name) {
    FILE* fp;

    if ((fopen_s(&fp, file_name, "r")) != 0 || fp == 0) { // "fpが0である可能性があります"というエラー対策
        return false; // boolはC++
    }

    bool loadable = loadHumenOptions(music_data, fp);

    if (loadable) loadHumen(music_data, fp);

    fclose(fp);
    return true;
}

struct NOTE
{
    bool flag = false;
    float x = 0.f;
    float y = 0.f;
};

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) {

    MUGIC_DATA music_data;
    NOTE notes[LANE_NUM][NOTE_NUM];

    SetMainWindowText("OtogeNoYounaNanika"); //ウインドウのタイトルを設定
    ChangeWindowMode(TRUE); //ウィンドウモードで起動
    SetGraphMode(WIN_W, WIN_H, 32); //画面の解像度指定
    SetWindowSizeChangeEnableFlag(FALSE); //画面サイズ変更不可
    if (DxLib_Init() == -1) { return -1; } //DxLib初期化処理
    SetDrawScreen(DX_SCREEN_BACK); //描画先を裏画面に設定

    loadHumenData(&music_data, "test.txt");

    //while(裏画面を表画面に反映, メッセージ処理, 画面クリア)
    while (ScreenFlip() == 0 && ProcessMessage() == 0 && ClearDrawScreen() == 0) {
        
        DrawFormatString(50, 100, GetColor(0, 255, 0), "BPM=%f\nOFFSET=%f\n%s\n%s",
            music_data.bpm, music_data.offset, music_data.song_name, music_data.music_file_path);

        for (int col = 0; col < LANE_NUM; col++) {
            for (int row = 0; row < 10; row++) {
                if (music_data.perfect_times[col][row] != 0)
                    DrawFormatString(50 + 150 * col, 300 + 30 * row, GetColor(0, 255, 0), "%f", music_data.perfect_times[col][row]);
            }
        }
    }
    DxLib_End(); //DxLibの終了処理
    return 0; //正常終了
}

f:id:iconcreator:20191231203850p:plain

説明していきます。

必要なデータをMUSIC_DATA構造体にまとめました。WinMain関数内でこの型の変数music_dataを宣言し利用しています。

loadHumenData関数は、music_dataと譜面データファイルの名前を受け取り、ファイルのopen/closeをしています。

loadHumenOptions関数は、譜面データファイルの#STARTより前の情報を読み込んでいます。fgets関数で1行づつ読み込んでstrに受け取っています。

while内では、まずstrncpy_s(tstr, MAX_READ, str, 6);でstrの文字列の1文字目から6文字目までをtstrにコピーしています。そしてif (strcmp(tstr, "#START") == 0)でtstrの値?が"#START"かどうかを比較しています。"#START"であれば、trueを返します。

C言語ではtstr == "#START"と書くことは出来ないので、文字列の比較をするにはstrcmp関数を用います。この関数が0を返したら同じ、それ以外を返したら違う、ということになります。

char* first = strtok_s(str, ":", &next_token);でstrの文字列を:で区切った単語群の1つ目をfirstに入れています。また、firstにNULLが返ってくる可能性があるのでif (first == NULL) continue;としています。

それ以降のif文では、firstに入っている文字列が"BPM"、"OFFSET"ならばmusic_data->bpm、music_data->offsetに値を小数に変換してから代入、firstに入っている文字列が"TITLE"、"WAVE"ならばmusic_data->song_name、music_data->music_file_pathに文字列をstrcpy_s関数で入れています。

C言語ではmusic_data->song_name = secondと書いて代入することは出来ないので、文字列の代入(コピー?)にはstrcpy関数を使います。私はVisual Studioに怒られてしまったのでstrcpy_s関数を使っています。

ところで、ほかの言語にあるsplit関数の代わりに、C言語ではstrtok関数を用います。私はVis(ry。このstrtok関数、使い方がちょっと面白くて、例えば

char str[100] = "apple:grapes:banana";
char *next_token = NULL, *first = NULL, *second = NULL;
first = strtok_s(str, ":", &next_token);
printf("%s\n", next_token);
second = strtok_s(NULL, ":", &next_token);
printf("%s\n", next_token);
printf("%s %s", first, second);

と書くと、

(output)
grapes:banana
banana
apple grapes

と出力されます。(たぶん)

分割した単語群の1つ目を取り出す場合は第一引数に元の文字列を指定しますが、2つ目以降を取り出す場合はNULLを指定します。

えっじゃあ2つ目以降はどういう挙動してるの?となると思いますが、まずfirst = strtok_s(str, ":", &next_token);でfirstに1つ目の単語"apple"を入れた際に、実はnext_tokenに切り取った残りの文字列"grapes,banana"を返しているのです。(もしかしたら"grapes,banana\n"かもしれない)

second = strtok_s(NULL, ":", &next_token);では、next_tokenを利用して2つ目の単語を切り出しsecondに入れ、残りがあればまたnext_tokenに入れます。第一引数に文字列を指定するかNULLを指定するかで挙動が変わるのです。わかれば使えるようになるのですが、私はしばらくnext_tokenの意味がわからず「???」と混乱していました。

(strtok_s関数の挙動については、厳密に実験したわけではないため間違いがあるかもしれないです...。あくまで私が使ってみた経験上、こうではないかな?と推測も交えながら予測したものです)

loadHumen関数では、#START以降を読み込んでいます。まず、最初のfor文でmusic_data->perfect_time_sizeの全ての要素に0を代入しています。一応、MUSIC_DATA内のint perfect_time_size[LANE_NUM] = { 0 };で0初期化をしてはいるのですが、0初期化しているかどうかはloadHumen関数は知らないので一応0を代入しています。まあなくてもいいです。

次のfor (int row = 0; (fgets(line, MAX_READ, fp)) != NULL; row++)はちょっと複雑です。

まず、C言語におけるfor文の挙動をおさらいしてみましょう。

for (int i = 0; i < 3; i++) {
    printf("hoge");
}

こんなfor文があったとき、内部では、
1. int i = 0;をする
2. i < 3をして、真だったらfor文のブロック内({}で囲まれた部分)の処理をする
3. printf("hoge");をする
4. i++;をする(iは1になる)
5. i < 3をする。iは1なので続行
6. 繰り返し
7. i < 3をする。iは3なので終了
となります。

これにしたがうと、
1. int row = 0;をする。
2. (fgets(line, MAX_READ, fp)) != NULLをして、真だったらfor文のブロック内の処理をする
3. ブロック内の処理
4. row++をする。
5. (fgets(line, MAX_READ, fp)) != NULLをする。
6. 繰り返し
となりますね。要は毎ループの最初にfgets関数を実行して、返り値がNULLでなければブロック内の処理をするってことです。

ブロック内では、まずchar* str = strtok_s(line, delim, &next_token);でfgets関数で読み込んだ文字列をdelimで区切った単語群の1つ目をstrに入れています。delimには", \n"が入っていますが、これは"apple, \ngrapes, \nbanana"のような文字列を区切るのではなく、","、" "、"\n"のどれかがあったら区切る、という挙動をします。譜面製作者が譜面を読みやすくするために半角スペースを入れる、という可能性を考慮しています。"\n"も加えているのは、文末に改行文字が紛れ込んだ場合も想定しているからです。

for (int col = 0; str != NULL; col++) {内では、まずstrの文字長を取得しています。そしてfor (int i = 0; i < length; i++) {内でまずif ('1' <= str[i] && str[i] <= '9')をしています。このif文の条件はちょっと特殊に見えるかもしれません。

char型は内部的には文字コードを持っていて、例えば'1'であれば49、'2'であれば50、'9'であれば57となります。つまりあの条件はif (49 <= str[i] && str[i] <= 57)となります。よってこの条件をみたすのは"1","2","3","4","5","6","7","8","9"の9つになります。

次のmusic_data->perfect_times[col][music_data->perfect_time_size[col]++] = 60.0 * 4 * (row + (double)i / length) / music_data->bpm - music_data->offset;はヤバいですね。これは結果を見た方が早いかもしれません。

1100,0100,1,0011
0,1,0,0100

が、perfect_timesにはこう入ります。

\ 0 1 2 3
0 1.0 1.5 1.0 2.0
1 1.5 3.0 2.5
2 3.5

...見てもわかんねえな...

rowは行番号です。1行進むと1小節進むので、まあこれはいいでしょう。(double)i / lengthは、たとえば1100のときは4分音符4分音符2分休符だったので、先頭の1は0/4小節分の時間が加えられます。2番目の1には1/4小節分の時間が加えられます。i/length小節分の時間が加えられるわけです。1100なら(bpm120、先頭を0とすると)叩くべきタイミングは0,0.5となりますね。1011なら0,1.0,1.5となります。10100101なら0,0.5,1.25,1.75となります。

music_data->perfect_times[col][music_data->perfect_time_size[col]++]music_data->perfect_time_size[col]++ですが、これはperfect_timesにデータをつめて入れるための工夫です。この++が分かりづらくしていますが、これは

int count = music_data->perfect_time_size[col];
music_data->perfect_times[col][count] = 60.0 * 4 * (row + (double)i / length) / music_data->bpm - music_data->offset;
music_data->perfect_time_size[col]++;

と書いても同じ動きをします。

str = strtok_s(NULL, delim, &next_token);で次の単語を取得しています。以下ループ。

ちなみに、// 引数に余計なものが入りすぎとコメントしたように、MUSIC_DATAにはloadHumenOptions関数やloadHumen関数内では使わない変数が含まれているため、各関数に変数を渡しすぎな気もします。ただ、このへんはあまりにも細かい話なのでそこまで気にしなくてもいいです。

テキストファイルから譜面データを読み込む

音ゲープログラムに譜面データ読み込みを追加したものがこちらです。ちなみに譜面データ形式は太鼓さん次郎拡張形式です。

コードを見る

#include "DxLib.h"

#define WIN_W 800 // ウインドウの横幅
#define WIN_H 600 // ウインドウの縦幅
#define MAX_READ 2000
#define LANE_NUM 4 // レーンの数
#define NOTE_NUM 1000 // ノーツの最大数
#define NOTE_WIDTH 72 // ノーツの幅
#define NOTE_HEIGHT 20 // ノーツの高さ
#define JUDGE_Y 500 // 判定ラインのY座標

static const int KEYS[LANE_NUM] = {
    KEY_INPUT_Z,
    KEY_INPUT_X,
    KEY_INPUT_C,
    KEY_INPUT_V
};

struct MUGIC_DATA
{
    double bpm = 120;
    double offset = 0;
    char song_name[MAX_READ] = "unknown";
    char music_file_path[MAX_READ];
    double perfect_times[LANE_NUM][NOTE_NUM] = { 0 };
    int perfect_time_size[LANE_NUM] = { 0 };
};

bool loadHumenOptions(MUGIC_DATA* music_data, FILE* fp) { // 引数に余計なものが入りすぎ
    char str[MAX_READ], * next_token = NULL, tstr[MAX_READ];
    while ((fgets(str, MAX_READ, fp)) != NULL) {
        strncpy_s(tstr, MAX_READ, str, 6);
        if (strcmp(tstr, "#START") == 0) {
            return true;
        }

        char* first = strtok_s(str, ":", &next_token);
        if (first == NULL) continue;

        if (strcmp(first, "BPM") == 0) {
            char* second = strtok_s(NULL, ":", &next_token);
            music_data->bpm = atof(second);
        }
        else if (strcmp(first, "OFFSET") == 0) {
            char* second = strtok_s(NULL, ":", &next_token);
            music_data->offset = atof(second);
        }
        else if (strcmp(first, "TITLE") == 0) {
            char* second = strtok_s(NULL, "\n", &next_token);
            strcpy_s(music_data->song_name, second);
        }
        else if (strcmp(first, "WAVE") == 0) {
            char* second = strtok_s(NULL, "\n", &next_token);
            strcpy_s(music_data->music_file_path, second);
        }
    }
    return false;
}

void loadHumen(MUGIC_DATA* music_data, FILE* fp) {
    char line[MAX_READ], * next_token = NULL, delim[4] = ", \n";

    for (int col = 0; col < LANE_NUM; col++) {
        music_data->perfect_time_size[col] = 0;
    }

    for (int row = 0; (fgets(line, MAX_READ, fp)) != NULL; row++) {
        char* str = strtok_s(line, delim, &next_token);
        if (str == NULL) continue;

        for (int col = 0; str != NULL; col++) {
            int length = strlen(str);
            for (int i = 0; i < length; i++) {
                if ('1' <= str[i] && str[i] <= '9') {
                    music_data->perfect_times[col][music_data->perfect_time_size[col]++] = 60.0 * 4 * (row + (double)i / length) / music_data->bpm - music_data->offset;
                }
            }
            str = strtok_s(NULL, delim, &next_token);
        }
    }
}

bool loadHumenData(MUGIC_DATA* music_data, const char* file_name) {
    FILE* fp;
    
    if ((fopen_s(&fp, file_name, "r")) != 0 || fp == 0) { // "fpが0である可能性があります"というエラー対策
        return false; // boolはC++
    }

    bool loadable = loadHumenOptions(music_data, fp);

    if (loadable) loadHumen(music_data, fp);

    fclose(fp);
    return true;
}

struct NOTE
{
    bool flag = false;
    float x = 0.f;
    float y = 0.f;
};

void initNotes(int perfect_time_size[LANE_NUM], NOTE notes[LANE_NUM][NOTE_NUM]) {
    for (int col = 0; col < LANE_NUM; col++) {
        int lane_length = perfect_time_size[col];
        for (int row = 0; row < lane_length; row++) {
            notes[col][row].flag = true;
            notes[col][row].x = 200.f + 150.f * col;
        }
    }
}

void updateNotes(double current_time, double perfect_times[LANE_NUM][NOTE_NUM], NOTE notes[LANE_NUM][NOTE_NUM]) {
    // ノーツ座標更新
    for (int col = 0; col < LANE_NUM; col++) {
        for (int row = 0; row < NOTE_NUM; row++) {
            if (notes[col][row].flag)
                notes[col][row].y = JUDGE_Y * (float)(current_time - perfect_times[col][row]) / 2 + JUDGE_Y;
        }
    }

    // 画面外かつ判定範囲外に出たノーツを削除
    for (int col = 0; col < LANE_NUM; col++) {
        for (int row = 0; row < NOTE_NUM; row++) {
            if (notes[col][row].flag
                && WIN_H + NOTE_HEIGHT < notes[col][row].y
                && 0.3 < current_time - perfect_times[col][row])
            {
                notes[col][row].flag = false;
            }
        }
    }
}

void judgeNotes(double current_time, double perfect_times[LANE_NUM][NOTE_NUM], NOTE notes[LANE_NUM][NOTE_NUM], char buf[256]) {
    // レーンに対応するキーが押されていて、かつ判定範囲内であればノーツを削除
    for (int col = 0; col < LANE_NUM; col++) {
        for (int row = 0; row < NOTE_NUM; row++) {
            if (buf[KEYS[col]] == 1
                && notes[col][row].flag
                && -0.3 < current_time - perfect_times[col][row]
                && current_time - perfect_times[col][row] < 0.3)
            {
                notes[col][row].flag = false;
            }
        }
    }
}

void drawNotes(NOTE notes[LANE_NUM][NOTE_NUM]) {
    for (int col = 0; col < LANE_NUM; col++) {
        for (int row = 0; row < NOTE_NUM; row++) {
            if (notes[col][row].flag) {
                DrawBoxAA(notes[col][row].x, notes[col][row].y - NOTE_HEIGHT / 2,
                    notes[col][row].x + NOTE_WIDTH, notes[col][row].y + NOTE_HEIGHT / 2, GetColor(0, 255, 0), TRUE);
            }
        }
    }
}

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) {

    MUGIC_DATA music_data;
    NOTE notes[LANE_NUM][NOTE_NUM];
    char buf[256]; //キー押下状態格納用配列

    SetMainWindowText("OtogeNoYounaNanika"); //ウインドウのタイトルを設定
    ChangeWindowMode(TRUE); //ウィンドウモードで起動
    SetGraphMode(WIN_W, WIN_H, 32); //画面の解像度指定
    SetWindowSizeChangeEnableFlag(FALSE); //画面サイズ変更不可
    if (DxLib_Init() == -1) { return -1; } //DxLib初期化処理
    SetDrawScreen(DX_SCREEN_BACK); //描画先を裏画面に設定

    loadHumenData(&music_data, "test.txt");

    initNotes(music_data.perfect_time_size, notes);

    int bgmHandle = LoadSoundMem(music_data.music_file_path);
    PlaySoundMem(bgmHandle, DX_PLAYTYPE_BACK);

    LONGLONG start_count = GetNowHiPerformanceCount();

    //while(裏画面を表画面に反映, メッセージ処理, 画面クリア)
    while (ScreenFlip() == 0 && ProcessMessage() == 0 && ClearDrawScreen() == 0) {
        
        GetHitKeyStateAll(buf);
        if (buf[KEY_INPUT_ESCAPE] == 1) break; // 終了

        LONGLONG now_count = GetNowHiPerformanceCount();
        double current_time = (now_count - start_count) / 1000000.0;

        updateNotes(current_time, music_data.perfect_times, notes);

        judgeNotes(current_time, music_data.perfect_times, notes, buf);

        DrawLine(0, JUDGE_Y, WIN_W, JUDGE_Y, GetColor(255, 255, 255));

        DrawFormatString(10, 10, GetColor(255, 255, 255), "t:%f\nbpm:%f\noffset:%f\n%s\n%s",
            current_time, music_data.bpm, music_data.offset, music_data.song_name, music_data.music_file_path);
        
        drawNotes(notes);
    }
    DxLib_End(); //DxLibの終了処理
    return 0; //正常終了
}

f:id:iconcreator:20191231204053p:plain

テキストファイルから譜面データを読み込む(C++)

C++のストリームを使うともっとラクになります。
プログラムは後日追記します。

C++に「怖い」や「ヤバそう」といった感情を持っている方も多いと思いますし、私も常に「C++界隈はヤバい」と思っているのですが、C++の方がラクに書けることも多いですし、便利機能もたくさんあります。(ストリームや、STL、cin/coutなど)そのような一部の便利な機能だけ取り入れてみる、「ベターC」な書き方も個人的にはいいんじゃないかなと思っています。

別にC++を書くときはC++17に従わなければならないなんて決まりはないですからね。そんな細かい話よりも、作りたいゲームを作ることの方が大切に決まっています。

おわりに

譜面データ形式について少し考え、譜面データ読み込みを音ゲープログラムに追加しました。

締め切りを過ぎてしまっているので、このあたりでお暇させていただきます。

C言語の文字列の取り扱い、ヤバい。しばらくやりたくない...