マガジン

リズムゲームの譜面と音をずらさない実装

遊びをつくる

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

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

リズムゲームを作るとき、最初に直面する壁は「見た目と音がずれる」問題です。譜面のタイミングをBPMで計算し、setTimeoutやsetIntervalで音を鳴らしても、プレイするたびにズレ幅が変わり、判定が安定しません。原因はブラウザのタイマーAPIと音声再生の仕組みが根本的に異なることにあります。 この記事では、Web Audio APIのAudioContext.currentTimeを基準にして譜面と音を一本のタイムラインに乗せる方法を説明します。「なぜずれるか」の仕組みを理解したうえで、実際に使える実装の骨格を示します。ゲームエンジンや外部ライブラリを使う場合でも、考え方は共通して役立ちます。

この記事の目次
  1. なぜ`setTimeout`では音と譜面がずれるのか
  2. 実装の基本構造:一本のタイムラインに統一する
  3. BPMと譜面時刻の変換
  4. 落とし穴と対処法
  5. 譜面データの持ち方
  6. 次の行動
  7. 参考資料

なぜ`setTimeout`では音と譜面がずれるのか

setTimeoutとsetIntervalは「指定ミリ秒後にコールバックを呼ぶ」と定義されていますが、正確な時刻を保証しません。ブラウザはメインスレッドで多くの処理を並行して行っているため、コールバックの実行は数ミリ秒から数十ミリ秒遅れることがあります。60fpsのゲームループ内でも、一フレームあたり16.7msのサイクルのどこで呼ばれるかは確定しません。

一方、Web Audio APIが持つAudioContext.currentTimeはサンプル精度で進む高解像度のクロックです。音声バッファのスケジューリングをこのクロックで行うと、OSのオーディオドライバに渡った瞬間から再生タイミングが固定されます。つまり、「音を鳴らす命令を出すタイミング」と「実際に音が出るタイミング」が分離されており、未来の時刻を指定して先行スケジュールできます。

この二つのタイムラインを混在させると必ずズレが生じます。解決策はシンプルで、「譜面の進行もAudioContext.currentTimeで計算する」ことです。

実装の基本構造:一本のタイムラインに統一する

曲の開始時刻を記録する

曲の再生を開始した瞬間のAudioContext.currentTimeを変数に保存します。これが譜面全体の「時刻ゼロ」になります。

js
const audioCtx = new AudioContext();
let songStartTime = 0;

function startSong(audioBuffer) {
  const source = audioCtx.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(audioCtx.destination);
  songStartTime = audioCtx.currentTime;
  source.start(songStartTime);
}

現在の曲内時刻を求める

毎フレームの描画ループでは、audioCtx.currentTime - songStartTimeを「曲内経過時間」として使います。この値でノーツの位置を計算すれば、描画と音声は同じ基準に乗ります。

js
function gameLoop() {
  const songTime = audioCtx.currentTime - songStartTime; // 秒単位
  updateNotes(songTime);
  requestAnimationFrame(gameLoop);
}

requestAnimationFrameのコールバックは描画タイミングに合わせて呼ばれますが、ここでは時刻の計算にcurrentTimeを使うだけなので、フレームレートの揺れが判定精度に直接影響しません。

効果音を先行スケジュールする

ノーツが判定ラインに到達したとき効果音を鳴らす場合も、audioCtx.currentTimeで指定します。「今すぐ鳴らす」ならsource.start(audioCtx.currentTime)、わずかに未来を指定したい場合はsource.start(audioCtx.currentTime + 0.001)のように余裕を持たせると、スケジュール遅れによる無音を避けられます。

BPMと譜面時刻の変換

譜面データはBPMと拍数で管理することが多いです。BPMから秒単位の時刻へ変換する式は次のとおりです。

  • 1拍の長さ(秒)= 60 ÷ BPM
  • N拍目の時刻(秒)= N × (60 ÷ BPM)

BPMが途中で変化する曲の場合は、セクションごとに「このセクション開始時の秒数」を累積して管理します。可変BPMをすべて一つの配列で扱おうとすると計算が複雑になるため、まずは固定BPMで完成させてから可変BPMへ拡張する順序が失敗しにくいです。

判定窓(何秒以内なら「PERFECT」か)は、プレイヤーの入力時刻と各ノーツの理想時刻の差をMath.absで取って比較するだけです。入力時刻にもaudioCtx.currentTimeを使うことで、タイマーAPIと混在する問題を防げます。

js
document.addEventListener('keydown', () => {
  const inputTime = audioCtx.currentTime - songStartTime;
  judgeNote(inputTime);
});

落とし穴と対処法

AudioContextの自動再生制限:多くのブラウザは、ユーザー操作なしにAudioContextを開始することを制限しています。「スタートボタンを押したとき」など明示的な操作のコールバック内でaudioCtx.resume()を呼ぶのが標準的な対処法です。ゲームロード直後に音声が無音になる場合はこれが原因のほぼすべてです。

AudioContextの生成タイミング:ページロード時にnew AudioContext()を呼ぶだけでは不十分で、ブラウザによってはstateがsuspendedのままです。操作イベント内で生成するか、操作イベント内でresume()を呼ぶかのいずれかを確認してください。

モバイルでの遅延:モバイルブラウザは特にオーディオの初回再生に遅延が出やすいです。無音の短いバッファを最初のユーザー操作で再生してAudioContextを「温める」プラクティスが有効です。これはアンロック用の無音再生と呼ばれる手法で、モバイル対応のリズムゲームでよく使われます。

描画ずれと音声ずれは別問題:AudioContext.currentTimeで音と判定を合わせても、画面に映るノーツの動きが視覚的にずれて見えることがあります。これはモニターのリフレッシュレートやrequestAnimationFrameの遅延によるもので、「オフセット設定」としてプレイヤーに調整させる機能を用意するのが一般的な解決策です。視覚オフセットと音声オフセットを独立して持つ設計にすると調整しやすくなります。

譜面データの持ち方

実装が進んだとき保守しやすいのは、譜面データを「曲内秒数・レーン番号・ノーツ種別」の配列として持つ方法です。BPMや拍数での管理はエディタ側の都合であり、ゲームロジックには秒数に変換済みのデータを渡すと判定ロジックがシンプルになります。

js
// 譜面データの例(秒単位)
const notes = [
  { time: 1.000, lane: 0, type: 'tap' },
  { time: 1.500, lane: 1, type: 'tap' },
  { time: 2.000, lane: 0, type: 'hold', duration: 0.5 },
];

ノーツを時刻順にソートしておき、インデックスポインタで「次に判定すべきノーツ」だけを見ると、毎フレーム全ノーツを走査するコストを避けられます。ノーツ数が増えてもパフォーマンスが落ちにくい構造です。

次の行動

Web Audio APIのAudioContext.currentTimeを曲開始時刻として保存し、ノーツ位置の計算・判定・効果音のスケジュールをすべてこの値から求めるように実装を組み直してみてください。まず固定BPMの単純な譜面で動作を確認してから、可変BPMや視覚オフセット機能へ段階的に拡張するのが最短の完成経路です。完成したリズムゲームはHTMLファイルやZIPとしてアソバースに投稿して公開できます。

参考資料

  • MDN Web Docs「Web Audio API」
  • MDN Web Docs「AudioContext.currentTime」
  • MDN Web Docs「AudioBufferSourceNode.start()」