マガジン

リズムの判定:押した時刻と拍の時刻の差を測る

遊びをつくる

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

つなぎ目のない銅色のリボンとテープリールで、自然なBGMループを表した写真風イメージ。
記事のテーマをもとに制作したAI生成イラスト。

拍の中心から42ミリ秒遅れてキーを押したプレイヤーを考えてみます。この押下を「良い」とするか「ミス」とするかは、感覚ではなく、ある数値とその数値を区切る境界線で決まります。リズムゲームの判定はこの一点に集約されます。 その数値は「押した時刻」から「拍の時刻」を引いた差です。式は引き算一つですが、実装でつまずくのは引き算の外側です。拍の時刻をどう作るか、どの拍と比べるか、押した時刻を何で測るか、といった部分です。 この記事では、差をミリ秒の図で表し、判定窓(どこまでの差を許すかの幅)の置き方を整理します。そのうえで差を画面に出す最小のコードを示し、公式作品から読み取れることと、自分で確かめる手順をまとめます。結果の数値は環境や人で変わるため、こちらでは書かず、読者が自分で測る形にします。

この記事の目次
  1. 判定の正体は引き算と基準時計です
  2. 判定窓をミリ秒の図で見ます
  3. 最小のコードで差を表示します
  4. 公式作品の設計から読み取れること
  5. 自分で確かめる手順
  6. 次の行動

判定の正体は引き算と基準時計です

判定に使う値は次の一つです。

差(ms) = 押した時刻 − 最も近い拍の時刻

押すのが早ければ差はマイナス、遅ければプラスになります。0に近いほど拍にぴったり合っています。

拍の時刻は、前の拍に間隔を足し続けて作らず、開始時刻から直接計算します。

拍n の時刻 = 開始時刻 + n × 拍の間隔

BPM(1分あたりの拍数)が120なら、拍の間隔は 60000 ÷ 120 = 500ms です。足し算を繰り返す方式では、1回ごとの小さな誤差が積み重なり、曲の後半で拍の位置が流れます。開始時刻から掛け算で出せば、誤差は積み重なりません。

押した時刻から「最も近い拍の番号」を求めるには、次の式で四捨五入します。

n = round((押した時刻 − 開始時刻) ÷ 拍の間隔)

この方式では、差は必ず拍の間隔の半分以内に収まります。BPM120の4分音符なら±250msです。たとえば260ms遅れて押すと、計算上は「次の拍に240ms早く押した」と扱われます。判定窓は、この半分の範囲より狭く取るのが前提です。

16分音符のグリッド、つまり1拍を4分割して並べるステップ式の場合は、間隔が 500 ÷ 4 = 125ms になります。最も近いステップまでの差は最大でも±62.5msです。ステップの粒度が細かいほど、近い隣のステップに吸われやすくなります。

スウィング(裏拍を後ろへずらすノリ)がある場合、拍の時刻は等間隔ではなくなります。偶数番目はそのまま、奇数番目にだけずらし量を足す形で、時刻の表を先に作っておくと、同じ引き算がそのまま使えます。

判定窓をミリ秒の図で見ます

差を横軸に置くと、判定は区間に分けた表になります。次の図は、説明用に仮置きした窓です。公式作品の値ではありません。

-125        -80         -30    0    +30         +80        +125 (ms)
 |-----------|-----------|-----|-----|-----------|-----------|
     ミス        良い       完璧  完璧     良い        ミス
 <- 早い                  拍の時刻                  遅い ->

窓を決めるときは、次の三つの点を見ます。

  • 窓の左右は対称にするとは限りません。入力や表示に遅れがあると、人が合わせたつもりの位置がプラス側へ寄ります。その場合は窓全体を平行移動します。この移動量が「オフセット」です。
  • 窓の幅は、拍の間隔の半分より十分小さくします。BPM120の16分音符で窓を±62msに近づけると、隣のステップとの境目が消えてしまいます。
  • BPMが上がると間隔が縮み、同じ±80msでも相対的な広さが変わります。窓をミリ秒の固定値にするか、拍の間隔に対する割合にするかは、設計の選択です。

「完璧」の幅が狭いほど難しく、広いほど気持ちよく感じる反面、上達の手応えは薄くなります。数値は、遊ぶ人の入力環境のばらつきを見込んで決めます。

最小のコードで差を表示します

次のコードは、HTML一枚で動きます。外部ライブラリは使いません。開始ボタンを押すと2秒後に拍0が来て、ランプが拍に合わせて光ります。スペースキーを拍に合わせて押すと、差と平均がミリ秒で表示されます。

html
<!doctype html>
<meta charset="utf-8">
<button id="s">開始</button>
<div id="lamp" style="width:60px;height:60px;border-radius:50%;background:#ccc;margin:12px 0"></div>
<pre id="log">開始後、スペースキーを拍に合わせて押します。</pre>
<script>
const BPM = 120, beat = 60000 / BPM;
let t0 = null;
const lamp = document.getElementById("lamp");
const log = document.getElementById("log");
const rows = [];

document.getElementById("s").onclick = (e) => {
  e.target.blur();                       // スペースでボタンが再度押されるのを防ぐ
  t0 = performance.now() + 2000;         // 2秒後が拍0
  rows.length = 0;
  requestAnimationFrame(tick);
};

function tick(now) {
  const phase = (now - t0) / beat;
  const frac = phase - Math.floor(phase);
  lamp.style.background = (phase >= 0 && frac < 0.15) ? "#f90" : "#ccc";
  requestAnimationFrame(tick);
}

addEventListener("keydown", (e) => {
  if (e.code !== "Space" || e.repeat || t0 === null) return;
  e.preventDefault();
  const n = Math.round((e.timeStamp - t0) / beat); // 最も近い拍の番号
  if (n < 0) return;
  const diff = e.timeStamp - (t0 + n * beat);      // 押した時刻 − 拍の時刻
  rows.push(diff);
  const mean = rows.reduce((a, b) => a + b, 0) / rows.length;
  log.textContent = `拍${n}: ${diff.toFixed(1)} ms\n回数: ${rows.length}\n平均: ${mean.toFixed(1)} ms`;
});
</script>

読み取りの要点は三つです。

  • 押した時刻には、performance.now() と同じ時間軸の e.timeStamp を使っています。押した「あと」に performance.now() を呼ぶと、処理の待ち時間が差に混ざります。
  • ランプの点灯は画面の更新単位でしか変わりません。更新が1秒に60回の画面なら約16.7msごとです。ランプ自体にも最大でこの程度の粗さがあり、画面の種類によっても変わります。
  • 音を鳴らして拍を知らせる場合は、描画ではなく音の再生側で時刻を予約するのが定石です。音と画面で遅れ方が違うため、どちらに合わせて押したかを分けて記録します。

公式作品の設計から読み取れること

アソバースの公式作品のうち、時刻の扱いを考える材料になるのは次の二つです。ここでは確認済みの仕様だけを根拠にします。

WAVE / WAVEは、4つの音を16ステップに並べてループ再生するリズムスタジオです。テンポ、スウィング、トラックごとのミュート、3つのお手本、取り消し、パッド演奏の録音があり、保存にも対応しています。設計から読み取れるのは、次のことです。

  • 曲が「16ステップ × テンポ」という時刻の表に落とせる構造であること。ステップの間隔はテンポから決まります(BPM120の16分音符なら125msという前節の計算と同じ種類の話です)。
  • スウィングがあるため、ステップの時刻が均等とは限らないこと。時刻の表を作ってから比べる方式と相性のよい題材です。
  • パッド演奏の録音が、押した時刻をそのまま残すのか、最も近いステップに寄せるのか。この部分は確認済み事項にありません。作品を開いて確かめます。パッドを拍から少しずらして録音し、ステップ表示にどう現れるかを見れば、手がかりが得られます。

雨音のピアノには、30秒・128音までの録音と、その場での再生があります。録音は画面を開いている間だけ残ります。ここから読み取れるのは、「押した時刻の列」を一定の上限まで保持して再生する構造が成り立つ、ということです。内部の持ち方や拍への補正の有無は、作品を開いて確かめます。

作者として自作に判定を入れる場合は、次の点も押さえておきます。アソバースの作品は外部通信なしのHTMLとして動かせます。オフセットのような小さな数値は、作品ごとにJSONで128KiBまでのクラウドセーブに入る大きさです。ただしアップロード作品内のlocalStorageは一時メモリー扱いです。調整値を端末をまたいで残したいときは、SDKを使うクラウドセーブの案内を現在の案内で確認します。

自分で確かめる手順

前節のコードで、自分の環境のずれを測る設計です。数値の結果はここでは書きません。

  1. コードを保存してブラウザで開きます。BPMは120のまま始めます。
  2. 1セットにつき20回、拍に合わせてスペースを押します。これを3セット行います。セットごとに開始ボタンから押し直し、画面に出る「平均」と各回の差を書き留めます。
  3. 記録は「各回の差(ms)」を単位にします。セットごとの平均と、最も大きく外れた値も書いておきます。
  4. 次に、キーボード、ブラウザ、出力機器を一つずつ変えて同じ手順を繰り返します。変えるのは一度に一つだけにします。

結果は次のように読みます。

  • 3セットの平均がどれも近いプラス(またはマイナス)の値なら、入力や表示の遅れによる系統的なずれです。判定窓を平行移動する「オフセット」で補正できる候補です。
  • 平均は0に近いのに、差の大きさが回ごとにばらつくなら、人の揺れが主因です。窓を広げるか、難易度として受け入れます。
  • セットごとの平均が大きく違うなら、慣れや集中の影響です。環境の補正値にはできません。
  • 機器を変えると平均が変わるなら、オフセットを固定値にせず、プレイヤーが調整できる設定にします。

起こりやすい誤りもあります。

  • キーを押し続けたときの連続入力(e.repeat)を除いていないと、差が混ざります。
  • 拍の間隔の半分を超えて遅れると、次の拍との差として記録されます。大きな外れ値が出たら、この割り当てを疑います。
  • タブが裏に回ると描画が止まることがあります。測定中は画面を前面のままにします。
  • ランプの見た目に合わせた場合と、音に合わせた場合の差を、同じ記録に混ぜないようにします。

次の行動

まずコードを動かし、20回押して平均を一つ出してみてください。次に、WAVE / WAVE のパッド録音を、拍からわざとずらして試し、ステップ表示にどう寄るかを見ます。その結果をもとに、自作の判定窓の幅とオフセットの置き方を決めます。