#まえがき
こちらの記事は、CCC Advent Calendar 2018の21日目の記事です。
#前振り
大半の人はタイトルを読んだとき、「ん?」って思ったかもしれません。
一部の人にとっては、タイトルを読んだとき「は?」って思ったかもしれません。
つまりは、誰からしても変なことしているなと、思ったことでしょう。
まさにその通りで、この記事は誰得極まりないものですw
だけど自分にとっては需要があったということなだけですw
それは置いといて。
##ksh?
kshとは何か。
一言で言えば「音楽ゲームにおける譜面データ」ということです。
「 K-Shoot Mania (以降KSM)」 という某K○NAMIさんから出ている音楽ゲーム「SD○X」を模したPCゲームがあるんです。
自分はS○VXが好きだったこともあって、遊ぶことが多々ありました。
KSMには譜面作成機能というのがありまして、作成した譜面を保存するときのファイル形式が「.ksh」なのです。
##なんでkshをjson化したの?
細かい経緯は端折るんですが、
あるときですね、自分は音楽ゲームの譜面データがjson形式で必要になってしまいました。
jsonには譜面を提示するタイミング(ms)とどのボタンに割り当てるか。
この二つの情報が必要になってしまいました。
提示するタイミングがmsで必要なので、いちいち計算するの面倒くさい・・・。
さてどうするか。
「json形式で必要な音楽ゲームがパネル4枚。」
「KSMの白鍵の数は4つ。」
「kshで作ったものをjson化できるなら、GUIで楽に譜面生成できそう」
そんな変なことに気が付いた私は、「kshをjson化しよう」そんな変なことを思いついてしまいました。
#本題
さて本題。
音楽ゲームにおける譜面の要素で必要になってくるのが
BPMとどのタイミングでどこに譜面を配置するか。
##BPM2ms
BPMというのは「Beat Per Minute」の略です。
日本語に訳すと、「1分間に何回4音符を鳴らすか」ということです。
これはもう計算式が決まっています。
一応、しっかり導出してみましょう。
BPM160を例にとりましょう。
これは1分間に160回4分音符が鳴ることを意味します。(はやっ)
つまり、
$ \dfrac{60(s)}{160(回)}=0.375(s)=375(ms)\quad -①$
ということです。
つまり、BPM160は375ms
間隔でというのは4分音符
が鳴ることを示しています。
それっぽく言うならば、
BPM160で、4分音符間隔でなっている音が時刻 $t[ms]$ で$n$番目に鳴ったとする(これを $S_n$ とする)。
$S_{n+1}$ で鳴る音は、$S_n$ の375[ms]
後ということです。
(要らん説明でしたかね)
もし、八分音符ならばこの値の半分になり、二分音符の場合は倍になるのは自明だと思います。
例えば、BPM100の8分音符とBPM200の4分音符は同じこと言っていることが分かれば、すぐ気づくと思います。
では、先ほどの式①を少し変えましょう。
$\text{音符の長さ}[ms] = 60\times 1000[ms]\times A_{p}\div \text{BPM}$
ただし、$A_p$ は $p$ 分音符の時の定数をさします。
補足
これで、音符の長さが分かります。
例えば、BPM200の12分音符の長さはどれくらいでしょうか?
こたえ
ではこれをコードに起こします。
今回は自分がよく使っているprocessingを使用します。
正直なところ、書くまでもないんだけど、一応書いておきます。
float bpm2ms (float bpm, int n) {
float ms = 60.0 / bpm * 4.0 / n * 1000;
return ms;
}
BPM170の8分音符を計算してみましょう。
176.4706
はい。出ましたね。
ネット上には自動で計算してくれるWebサイトがあるので確認してみます。
↑のサイトはディレイタイム計算機で確認できます。
四捨五入すれば、一致することが確認できました。問題なさそうです。
では次です。
##kshの構造に迫る
では早速kshの構造について迫っていきましょう。
一例をぶん投げます。長いので、たたんでおきます。
example.ksh
title=example
artist=dj example
effect=
jacket=.jpg
illustrator=
difficulty=extended
level=1
t=150
m=example.mp3
mvol=75
o=0
bg=desert
layer=arrow
po=0
plength=15000
pfiltergain=50
filtertype=peak
chokkakuautovol=0
chokkakuvol=50
ver=160
--
beat=4/4
0000|00|--
--
0001|00|--
--
0001|00|--
1000|00|--
--
0001|00|--
0000|00|--
0000|00|--
1000|00|--
--
0001|00|--
0000|00|--
0000|00|--
0000|00|--
0010|00|--
0100|00|--
1000|00|--
0000|00|--
--
0001|00|--
0010|00|--
0100|00|--
1000|00|--
0001|00|--
0000|00|--
0000|00|--
0000|00|--
1000|00|--
0000|00|--
0000|00|--
0000|00|--
0001|00|--
0000|00|--
0000|00|--
0000|00|--
--
0001|00|--
0010|00|--
0100|00|--
0010|00|--
0100|00|--
1000|00|--
0001|00|--
0000|00|--
0000|00|--
0000|00|--
0100|00|--
0000|00|--
--
0001|00|--
0000|00|--
1000|00|--
0100|00|--
0010|00|--
0000|00|--
0100|00|--
0000|00|--
--
kshを詳しく見ていきましょう。
最初の18行くらいまではスルーして、(楽曲情報など今回には関係ないため)その先をよく見てましょう。
--
だけの行は小節の区切りをさしていて、--
~--
間が1小節であることを意味します。
少しずつ掘り起こしていきましょう。
※ここでは0000|00|--
の|
や--
は無視します。
beat=4/4のため、以降4分の4拍子として説明していきます。
4分の4拍子とは、1小節が4分が4個で構成されていることを意味しています。
(これは音楽の授業で習ったよね?)
--
0001|00|--
--
これは一番右の白い鍵盤が小節の冒頭(1拍目)に割り当てられていることが書かれています。
それ以外に情報はないので、記述はそれのみとなっています。
--
0001|00|--
0000|00|--
0000|00|--
1000|00|--
--
これは、1小節に4つの行があります。つまり、4分で1拍目と4拍目にそれぞれ譜面が割り当てられていることが分かります。
しかし、2拍目、3拍目には譜面は割り当てられてないので0のみの記述になっています。
--
0001|00|--
0010|00|--
0100|00|--
1000|00|--
0001|00|--
0000|00|--
0000|00|--
0000|00|--
1000|00|--
0000|00|--
0000|00|--
0000|00|--
0001|00|--
0000|00|--
0000|00|--
0000|00|--
--
これは16行で小節が構成されています。つまり、16分で表現されていることを意味します。4分の4拍子は16分の16拍子の相当するので、1~5個目までは16分でできていますが、5個目以降は4分で3拍分なので間に16分の3拍分は空欄になっています。
つまり、小節の表現の構成は表現できる最小の単位(n分)まで記述する
ということになっていそうです。
kshの大まかな構造理解はこれで良いとしましょう。
##json化する
言葉で説明するのは正直なところ難しいのですが、頑張って説明していきます
kshの構造はこんな感じでした。
曲や譜面などの基本情報(~20行目)
BPMは8行目の"t="以降
offsetは11行目の"o="以降、単位は(ms)
--
1小節目
--
2小節目
--
3小節目
--
:
:
--
n小節目
--
offsetは音源と譜面の軽微な調整に使います。これは譜面生成タイミングに影響するので、抽出する必要があります。
パースのポイントは0000|00|--
のある行がいくつあるかで、表現されている刻みが分かるということ。譜面情報はその中に含まれているということ。
つまり、--
間に、幾つの0000|00|--の行があるかを調べ、1000(左から1番目)や0010(左から3番目)のようにどこに割り当てられているかを取得できればいいですね。
それをコードに起こします。
かなり頭の悪い実装の仕方をしてますので、簡易版で。
String ksh2json(String str[]){
//どこからどこまでがn小節目かを取得する。
//ここから
int getSectionTop = -1;//n小節目の先頭の行を参照する
int getSectionBottom = -1;//n小節目の最後の行を参照する
int top = 0;//さいごに参照した行
int bottom = str.length;//kshファイル自体の行の大きさ
int bpm = ~;//BPM保存用
int offset = ~;//ある行にoffsetがあるので取得する
int nowMs += offset;//現在の何msかを計算する。
//ここまでは全部 global変数
while (/*topがbottomに到達するまで*/){
getRange(top, bottom, str);
jsn += picNotes(str);//jsonを更新する
}
return jsn;
}
void getRange(int above, int under, String str[]){
//範囲を取得して、picNotesに役立てる
for (int i=above; i<under; i++) {
if (getSectionTop == -1) {
if (str[i].equals("--")) {
getSectionTop = i;
}
} else if (getSectionBottom == -1) {
if (str[i].equals("--")) {
getSectionBottom = i;
}
}
}
String picNotes(String str[]){
int barRange;//何分かどうか
boolean noNote[] = new boolean [barRange];//ノートがあるかどうかの判定
int tiles[] = new int[barRange];//譜面データ
for (int i=getSectionTop+1; i<getSectionBottom; i++){
//ノーツ情報の有無を取得->noNoteに保存
}
for (int i=0; i<tiles.length; i++){
//ノート情報を記録する
}
ms = bpm2ms(~);//分(ぶ)数ごとのmsを計算し、json化するときにタイミングを合わせられるようにする。
for (int i=0; i<tiles.length; i++){
//ノート情報をjson化する
//msを逐一計算
//ノート情報がなければすっ飛ばして、jsonを作る
}
//次の小節を取得するために更新。
top = getSectionBottom+1;
getSectionTop = getSectionBottom;
getSectionBottom = -1;
return json;
}
B4U.ksh
title=B4U
artist=
effect=
jacket=.jpg
illustrator=
difficulty=extended
level=1
t=170
m=b4u cut.mp3
mvol=75
o=40
bg=desert
layer=arrow
po=0
plength=15000
pfiltergain=50
filtertype=peak
chokkakuautovol=0
chokkakuvol=50
ver=160
--
beat=4/4
0000|00|--
--
0000|00|--
0100|00|--
--
0000|00|--
0000|00|--
0010|00|--
1000|00|--
--
0001|00|--
0100|00|--
0010|00|--
0000|00|--
--
0000|00|--
0000|00|--
1000|00|--
0100|00|--
--
0010|00|--
0001|00|--
1000|00|--
0000|00|--
--
0000|00|--
0000|00|--
0010|00|--
0100|00|--
--
0010|00|--
0100|00|--
0010|00|--
0000|00|--
--
0000|00|--
1000|00|--
--
0000|00|--
0001|00|--
0010|00|--
0000|00|--
--
0100|00|--
0000|00|--
0001|00|--
0010|00|--
--
0100|00|--
0000|00|--
1000|00|--
0000|00|--
0100|00|--
0000|00|--
0000|00|--
0010|00|--
--
0000|00|--
0100|00|--
0010|00|--
0000|00|--
--
0100|00|--
0000|00|--
0000|00|--
0010|00|--
0000|00|--
0000|00|--
0100|00|--
0000|00|--
--
0000|00|--
0000|00|--
0001|00|--
0100|00|--
--
0010|00|--
1000|00|--
0001|00|--
0000|00|--
--
1000|00|--
0010|00|--
--
0100|00|--
0010|00|--
0001|00|--
0000|00|--
--
0000|00|--
0100|00|--
--
0000|00|--
0010|00|--
--
0000|00|--
0100|00|--
--
0000|00|--
1000|00|--
--
0000|00|--
0001|00|--
--
0000|00|--
0010|00|--
--
0100|00|--
0010|00|--
--
0000|00|--
1000|00|--
0001|00|--
0000|00|--
--
0000|00|--
0010|00|--
--
0000|00|--
--
0000|00|--
1000|00|--
--
0000|00|--
--
0000|00|--
0001|00|--
--
0000|00|--
--
0000|00|--
0100|00|--
--
0010|00|--
0000|00|--
0000|00|--
0000|00|--
0100|00|--
0000|00|--
0000|00|--
0010|00|--
--
0000|00|--
0000|00|--
0001|00|--
0000|00|--
0010|00|--
0000|00|--
0000|00|--
0100|00|--
--
0000|00|--
1000|00|--
0100|00|--
0000|00|--
--
B4U.json
[
{
"start": "2711",
"tileNum": "1"
},
{
"start": "5885",
"tileNum": "2"
},
{
"start": "6842",
"tileNum": "0"
},
{
"start": "7799",
"tileNum": "3"
},
{
"start": "8756",
"tileNum": "1"
},
{
"start": "9713",
"tileNum": "2"
},
{
"start": "13541",
"tileNum": "0"
},
{
"start": "14498",
"tileNum": "1"
},
{
"start": "15455",
"tileNum": "2"
},
{
"start": "16412",
"tileNum": "3"
},
{
"start": "17369",
"tileNum": "0"
},
{
"start": "21197",
"tileNum": "2"
},
{
"start": "22154",
"tileNum": "1"
},
{
"start": "23111",
"tileNum": "2"
},
{
"start": "24068",
"tileNum": "1"
},
{
"start": "25025",
"tileNum": "2"
},
{
"start": "28199",
"tileNum": "0"
},
{
"start": "30416",
"tileNum": "3"
},
{
"start": "31373",
"tileNum": "2"
},
{
"start": "33287",
"tileNum": "1"
},
{
"start": "35201",
"tileNum": "3"
},
{
"start": "36158",
"tileNum": "2"
},
{
"start": "37115",
"tileNum": "1"
},
{
"start": "37819",
"tileNum": "0"
},
{
"start": "38523",
"tileNum": "1"
},
{
"start": "39579",
"tileNum": "2"
},
{
"start": "40888",
"tileNum": "1"
},
{
"start": "41845",
"tileNum": "2"
},
{
"start": "43759",
"tileNum": "1"
},
{
"start": "44815",
"tileNum": "2"
},
{
"start": "45871",
"tileNum": "1"
},
{
"start": "48489",
"tileNum": "3"
},
{
"start": "49446",
"tileNum": "1"
},
{
"start": "50403",
"tileNum": "2"
},
{
"start": "51360",
"tileNum": "0"
},
{
"start": "52317",
"tileNum": "3"
},
{
"start": "54231",
"tileNum": "0"
},
{
"start": "55491",
"tileNum": "2"
},
{
"start": "56751",
"tileNum": "1"
},
{
"start": "57708",
"tileNum": "2"
},
{
"start": "58665",
"tileNum": "3"
},
{
"start": "61839",
"tileNum": "1"
},
{
"start": "64359",
"tileNum": "2"
},
{
"start": "66879",
"tileNum": "1"
},
{
"start": "69399",
"tileNum": "0"
},
{
"start": "71919",
"tileNum": "3"
},
{
"start": "74439",
"tileNum": "2"
},
{
"start": "75699",
"tileNum": "1"
},
{
"start": "76959",
"tileNum": "2"
},
{
"start": "79176",
"tileNum": "0"
},
{
"start": "80133",
"tileNum": "3"
},
{
"start": "83307",
"tileNum": "2"
},
{
"start": "87238",
"tileNum": "0"
},
{
"start": "91169",
"tileNum": "3"
},
{
"start": "95100",
"tileNum": "1"
},
{
"start": "96360",
"tileNum": "2"
},
{
"start": "97768",
"tileNum": "1"
},
{
"start": "98824",
"tileNum": "2"
},
{
"start": "99880",
"tileNum": "3"
},
{
"start": "100584",
"tileNum": "2"
},
{
"start": "101640",
"tileNum": "1"
},
{
"start": "102949",
"tileNum": "0"
},
{
"start": "103906",
"tileNum": "1"
}
]
こんな感じになります。
ソフランや変拍子に対応してないなど、色々と不足部分があるのですが、ひとまず、骨格となる部分は完成です。
さいごに
はい。
誰得な話でしたが、今回の記事は終わりです。
唐突な終わり方ですし、解説とかも皆無。
これ大丈夫なん?って思うかもしれませんが、こんな感じでいいでしょ?(だってロクなことしてないし)
というわけで、大切な時間を無駄にしていただきありがとうございました。