はじめまして。eiryplusと申します。 この記事は、 Beat Saber Advent Calendar 2020 23日目の記事となります。

譜面を解析してみる

Beat Saberの譜面の難易度については、 NPS (norts per Second、ノーツ数を曲の長さで割った値)と、レベル(AIが自動的に算出した難易度)の2つが標準で利用されていると思います。 しかし、実際は脳トレ譜面が得意だったり、ストリームが得意だったりと、人によって得手不得手があります。(なお、私はストリームが大の苦手です)

解析の目的は、難易度とは別に、事前に譜面の傾向を把握したり、ブラインドノーツのような暗記しなければ対応できない配置が無いかを素早く把握する事となります。

なお、この記事で説明している内容は、複数の譜面データを比較して推測した情報で、仕様として明記されている情報ではありません。 間違いなどありましたら、Twitter: @eiryplus までご連絡をお願いします。

TL;DR

忙しい人のためのまとめです。

譜面の構成

譜面は、凡そ以下のファイルで構成されています。

役割 ファイル名 データ形式
作成者や曲、難易度など譜面全体の情報 info.dat JSON
曲データ song.egg Ogg Vorbis
カバー画像 Cover.jpg jpeg
難易度別のマップデータ。
{難易度}の部分はEasy、Normal、Hard、Export、ExpertPlus
{難易度}.dat JSON

曲データやカバー画像などのファイル名は、一般的に利用されている名前です。実際は info.dat に定義されているので、別名でも問題ないと思います。

解析で利用するデータについて

各ファイルから、利用するデータの取得方法を記載していきます。

info.dat

オリジナルのデータから一部書き換えていますが、以下のようなデータ構造になります。

最重要のデータは _beatsPerMinute というキーで格納されています。後ほど出てくるノーツの解析で利用します。

{
  "_version": "2.0.0",
  "_songName": "曲名",
  "_songSubName": "",
  "_songAuthorName": "アーティスト名",
  "_levelAuthorName": "マッパーさん",
  "_beatsPerMinute": 154,
  "_songTimeOffset": 0,
  "_shuffle": 0,
  "_shufflePeriod": 0.5,
  "_previewStartTime": 12,
  "_previewDuration": 10,
  "_songFilename": "song.egg",
  "_coverImageFilename": "cover.jpg",
  "_environmentName": "BigMirrorEnvironment",
  "_customData": {
    "_contributors": [],
    "_customEnvironment": "",
    "_customEnvironmentHash": ""
  },
  "_difficultyBeatmapSets": [
    {
      "_beatmapCharacteristicName": "Standard",
      "_difficultyBeatmaps": [
        {
          "_difficulty": "Hard",
          "_difficultyRank": 5,
          "_beatmapFilename": "Hard.dat",
          "_noteJumpMovementSpeed": 10,
          "_noteJumpStartBeatOffset": 0,
          "_customData": {
            "_difficultyLabel": "",
            "_editorOffset": -33,
            "_editorOldOffset": -33,
            "_warnings": [],
            "_information": [],
            "_suggestions": [],
            "_requirements": []
          }
        },
        {
          "_difficulty": "Expert",
          "_difficultyRank": 7,
          "_beatmapFilename": "Expert.dat",
          "_noteJumpMovementSpeed": 10,
          "_noteJumpStartBeatOffset": 0,
          "_customData": {
            "_difficultyLabel": "",
            "_editorOffset": -33,
            "_editorOldOffset": -33,
            "_warnings": [],
            "_information": [],
            "_suggestions": [],
            "_requirements": []
          }
        }
      ]
    }
  ]
}

Pythonでこのデータを取得する場合のサンプルコードです。シンプルなJSONなので、標準のライブラリだけで取得できます。

import json

with open("info.dat", "r", encoding="utf-8") as fp:
    info_dat = json.load(fp)
bpm = info_dat["_beatsPerMinute"]

曲データ

info.dat や 譜面データの中に、曲の長さに関する情報が格納されていません。 NPSや解析データグラフに表示する場合など、 曲の長さが必要なときは、曲データから直接取得します。

Pythonでこのデータを取得する場合、(多分)標準のライブラリでは不可能なので、 PyGame というゲーム開発用のライブラリを利用しています。

import pygame

pygame.mixer.init()  # PyGameの初期化
song = pygame.mixer.Sound("song.egg")
song_length = song.get_length()

難易度別のマップデータ

データが多かったため大幅に間引いています。また、ノーツの情報である、 _notes 以外は説明を省略いたします。

{
  "_version": "2.0.0",
  "_BPMChanges": [],
  "_events": [],
  "_notes": [
    {
      "_time": 11.915300369262695,
      "_lineIndex": 0,
      "_lineLayer": 0,
      "_type": 0,
      "_cutDirection": 6
    },
    {
      "_time": 12.915300369262695,
      "_lineIndex": 1,
      "_lineLayer": 0,
      "_type": 1,
      "_cutDirection": 6
    }
  ],
  "_obstacles": [
    {
      "_time": 195.91529846191406,
      "_lineIndex": 0,
      "_type": 0,
      "_duration": 39,
      "_width": 1
    },
    {
      "_time": 195.91529846191406,
      "_lineIndex": 3,
      "_type": 0,
      "_duration": 39,
      "_width": 1
    }
  ],
  "_bookmarks": []
}

_notes

ノーツの位置や向き、タイミングなどの設定値が格納されています。 { } で囲まれたブロック1つが1ノーツの情報になります。

Pythonでこのデータを取得する場合のサンプルコードです。info.datと同じく、シンプルなJSONなので、標準のライブラリだけで取得できます。

import json

with open("Hard.dat", "r", encoding="utf-8") as fp:
    difficulty_map = json.load(fp)
notes = difficulty_map["_notes"]  # ノーツ全てを取得
note = notes[0]  # 先頭のノーツを1つだけ取得

ノーツ1つは以下のようなデータで構成されています。

key 説明
_time ノーツを切るタイミング。BPMの値になっているので、以下の計算で秒に変換します。
_timeの値 × 60 ÷ bpm(info.datから取得したbpm)
_lineIndex 左右の位置。0~3で設定されており、0が一番左、3が一番右になります
_lineLayer 上下の位置。0~2で設定されており、0が一番下、2が一番上になります
_type どちらの手で切るか。0,1 で設定されており、0が左手、1が右手になります
_cutDirection ノーツ種類です。設定値とノーツの組み合わせは以下の表をご確認ください

_cutDirectionの設定値とノーツ

向き
0 上
1 下
2 左
3 右
4 左上
5 右上
6 左下
7 右下
8 ドット

これらの値を使うことで、壁やボムを除いた譜面の情報が取得できます。 なお、MODで特殊な動きをするノーツは、おそらくこの方法では解析出来ないと思います。引き続き試して行きますので、気長にお待ち下さい。

解析の例

おなじみのNPS

NPSはノーツの数を曲の秒数で割って算出される値になります。

Pythonでこのデータを取得する場合のサンプルコードです。曲データから曲の長さを、難易度別のマップデータからノーツの数を取得して計算します。

import json
import pygame


pygame.mixer.init()
song = pygame.mixer.Sound("song.egg")
song_length = song.get_length()  # 

with open("Hard.dat", "r", encoding="utf-8") as fp:
    difficulty_map = json.load(fp)
notes = difficulty_map["_notes"]  # ノーツ全てを取得

nps = len(notes) / song_length
# 今回サンプルで利用しているMAPだと、2.41 となってます。

この値の欠点ですが、全ての平均なので、ノーツが密集している部分があっても気がつけません。なので、1秒毎のノーツ数を数えて、ノーツが密集している場所を探してみようと思います。

1秒ごとのノーツ数を数えてグラフにする

難易度の一つとして、やはりノーツの密度は見逃せません。 毎秒のノーツ数をカウントして、突出して密度が高い時間帯がないか確認します。

※ ここからはコードが長くなるので、記事では省略させていただきます。別途サンプルコードを上げていますので、そちらをご確認ください。

わけわかんないやつ

直に全部出力した結果、わけが分からなくなってますね。ただ、200秒過ぎのところに秒間6ノーツのポイントがあるようですが、まんべんなくバラけている印象です。

少しデータを間引いて、5秒ごとに一番密度が高かった場所を参考値として利用するようにしてみます。

ましなやつ

ちょっとマシになったとおもいます。今回サンプルとして使っているMAPは、NPS:2.41 の譜面ですが、最も密度が高い所だとNPS:6あることがわかります。

今後の展望について

手元にある譜面のチェック

今後は、Webサービスとして作ろうと思っているものが1つあります。MAPデータをアップロードすると、ノーツの分布をグラフで表示させたり、ウインドノーツやブラインドノーツなど、一般的に好まれない配置があるかチェックするようなサービスを考えてます。

DLしてきたMAPの内容のチェックだけでなく、マッパーさんが作成中の譜面のチェックでも使えるかなーと思っています。

特殊譜面の解析について

三妖精を代表とする?特殊譜面については、密度や使われているノーツの種類の分布を見ても、それほど変な値になりませんでした。

別アプローチとして、ノーツをまっすぐ切るために剣先を移動させる行為を「予備動作」とし、予備動作でどれだけ大きく剣先が動くか?といったアプローチで解析を頑張ってます。。。が、間に合いませんでした。ごめんなさい。お詫びの975をやっておきます。

長々とありがとうございました。それでは、引き続きビートセイバーを楽しんでいきましょう。