マガジン

リズムゲームの作り方:譜面データと判定幅

遊びをつくる

執筆にClaudeを使用しています。

十六個のパッドとキック、スネア、ハイハットを並べたドラムループの立体イメージ。
記事のテーマをもとに制作したAI生成イラスト。

リズムゲームを作ろうとしたとき、多くの人が最初につまずくのは「音楽に合わせてノーツを落とす」仕組みそのものではなく、どのタイミングでノーツが判定されるべきかという設計の部分です。音を鳴らすことはできた、ノーツも動いた、でも実際に叩いてみると「なんかズレてる」「タイミングが合っている気がしない」という感覚が残る。この記事では、その違和感の原因と解消手順を、譜面データの構造と判定幅の設計という二つの軸で整理します。 リズムゲームの核心は「音楽の時間軸と入力の時間軸をいかに正確に比べるか」です。グラフィックスや演出は後から足せますが、この二本の時間軸の設計を後回しにすると、全体を作り直す羽目になります。最初に構造を決め切ることが、完成への最短経路になります。

この記事の目次
  1. 譜面データの構造を決める
  2. 判定幅の設計
  3. 再生位置の取得と同期
  4. 判定処理の実装手順
  5. 譜面を作る実際の流れ
  6. 次の行動
  7. 参考資料

譜面データの構造を決める

譜面データとは、「何秒後にどのレーンにノーツを出すか」を記録したデータです。一番シンプルな形は、時刻とレーン番号だけをもつ配列です。

[
  { time: 1.240, lane: 0 },
  { time: 1.480, lane: 2 },
  { time: 1.720, lane: 1 },
  ...
]

この time の単位は秒にしておくのが最も扱いやすいです。BPM(テンポ)を基準にした「拍数」で記録する方法もありますが、変拍子や途中でBPMが変わる曲に対応しようとした瞬間に変換処理が複雑になります。最初から秒数で管理すれば、再生位置との比較が直接できます。

時刻の基準点をどこに置くか

曲の先頭を 0 秒とすることが多いですが、実際には音声の再生開始から最初のノーツまでに余裕時間(オフセット)を設けるのが標準的な作りです。プレイヤーが構える時間をとるために、最初の数秒はノーツを出さないのが普通です。この余裕はデータ側ではなく再生側のオフセット変数で吸収すると、譜面データ自体はシンプルに保てます。

ロングノーツと複合ノーツの追加

最初は単純なタップノーツだけで始め、慣れてからロングノーツを追加する順序を勧めます。ロングノーツは timeStart と timeEnd の二値が必要になり、判定も「押し続けている間は何点」という区間評価になるため、設計の複雑さが一段上がります。最初の完成品はタップのみで十分です。

判定幅の設計

判定幅(ジャッジメントウィンドウ)とは、「ノーツの定刻より何ミリ秒早くても・遅くても正解とみなすか」の範囲です。この設計が体感の「気持ちよさ」にほぼ直結します。

一般的な判定階層

多くのリズムゲームは3〜4段階の判定を持ちます。参考として典型的な構造を示します。

  • PERFECT、範囲(定刻からのズレ):±40ms 以内
  • GREAT、範囲(定刻からのズレ):±80ms 以内
  • GOOD、範囲(定刻からのズレ):±120ms 以内
  • MISS、範囲(定刻からのズレ):それ以外

数値はゲームによって大きく異なります。±40ms は上級者向けの厳しめ設定で、カジュアルなゲームなら PERFECT を ±80ms に広げることも多いです。「どの判定まで出したいか」と「ターゲットのプレイヤー層」をセットで考えて決めます。

ズレを測定して調整する

作ったあとに「実際にどれくらいズレているか」を測れるようにしておくことが重要です。具体的には、入力時刻と直近ノーツの定刻の差分をデバッグ表示する仕組みを最初から入れておきます。自分でプレイしてみて、「ちゃんと叩いているのに GREAT ばかり出る」なら PERFECT の幅を広げる。「GOOD が出にくすぎる」なら GOOD の幅を削る。この数値は論理で決めるより、実際に触って調整するほうが最終的に速く決まります。

再生位置の取得と同期

判定の計算式は単純です。

差分 = 入力時刻 - ノーツの定刻

問題は「入力時刻」と「ノーツの定刻」の両方を同一の時間軸で取得できているかです。

ブラウザの JavaScript では AudioContext.currentTime が高精度の再生時刻を返します。Date.now() や performance.now() との混用は避けます。AudioContext.currentTime は音声バッファの再生開始から経過した秒数を返すため、これを曲の時間軸の基準として使い、入力イベントのタイムスタンプも同じ基準に揃えることで、ズレの混入を防げます。

キーボード入力の遅延

keydown イベントのタイムスタンプはブラウザによって数ミリ秒の遅延が乗ることがあります。これを補正するために、入力オフセット(audioOffset)を設定画面でユーザーが調整できるようにするのが丁寧な設計です。プレイヤーごとに環境が違うため、固定値で全員に合わせるのは不可能です。「早め補正・遅め補正」を設定できるようにするだけで、プレイアビリティが大きく上がります。

判定処理の実装手順

実装の順序を整理します。

  1. 音声の再生を AudioContext で開始し、再生開始時刻を変数に保存する。
  2. 毎フレーム(requestAnimationFrame)で AudioContext.currentTime を読み、現在の再生位置を計算する。
  3. 譜面データをループし、currentTime が各ノーツの time - 表示開始余裕 を超えたらノーツをスポーンする。
  4. キー入力イベントで押されたレーンを取得し、そのレーンの「判定待ちノーツ」の中から定刻が最も近いものを選ぶ。
  5. 差分(ms換算)を判定幅の表と照合し、結果を表示してノーツを消す。
  6. ノーツの定刻を大幅に過ぎても入力がなければ MISS として処理する。

この6ステップが最小構成です。スコア計算やコンボ表示はこの土台の上に後から積めます。

よくある失敗:「最前列ノーツ」を間違える

複数のノーツが同レーンに並んでいるとき、入力に対してどのノーツを判定対象にするかのロジックを間違えると、1回の入力で2個先のノーツが消えるバグが起きます。判定対象は「そのレーンで最も時刻が早い(=最も古い)ノーツ1個だけ」にするのが原則です。入力時刻より大幅に未来のノーツは、差分が判定幅を超えるため自動的に外れます。

譜面を作る実際の流れ

仕組みができたら、次は譜面データをどう用意するかです。手打ちで配列を書いてもよいですが、曲が長くなると現実的ではありません。

最も手軽なのは、音楽プレイヤーを再生しながらキーを叩いてタイムスタンプを記録する「タップ入力ツール」をゲームとは別に作る方法です。押した瞬間の AudioContext.currentTime を配列に追加していくだけの簡単なスクリプトで、粗い譜面が数分で用意できます。その後、配列を眺めながら手で微調整する流れが効率的です。

既存のBMSやSusフォーマットを読み込む方法もありますが、独自ゲームで使うなら自前のシンプルなJSON形式から始めるほうが、デバッグのしやすさで勝ります。フォーマットは自分が読んで理解できる構造を優先します。

次の行動

まず AudioContext.currentTime を使った再生時刻の取得と、差分のデバッグ表示だけを実装し、自分の環境でどれくらいのズレが出るかを確認してみてください。判定幅の数値はその結果を見てから決めるのが最も確実です。完成したゲームはHTMLファイルまたはZIPにまとめてアソバース(https://aso-verse.com)に投稿できます。

参考資料

  • Web Audio API 仕様(W3C)
  • MDN Web Docs「AudioContext.currentTime」
  • MDN Web Docs「KeyboardEvent: timeStamp プロパティ」