リズムの判定:押した時刻と拍の時刻の差を測る
遊びをつくる執筆にClaudeを使用しています。

拍の中心から42ミリ秒遅れてキーを押したプレイヤーを考えてみます。この押下を「良い」とするか「ミス」とするかは、感覚ではなく、ある数値とその数値を区切る境界線で決まります。リズムゲームの判定はこの一点に集約されます。 その数値は「押した時刻」から「拍の時刻」を引いた差です。式は引き算一つですが、実装でつまずくのは引き算の外側です。拍の時刻をどう作るか、どの拍と比べるか、押した時刻を何で測るか、といった部分です。 この記事では、差をミリ秒の図で表し、判定窓(どこまでの差を許すかの幅)の置き方を整理します。そのうえで差を画面に出す最小のコードを示し、公式作品から読み取れることと、自分で確かめる手順をまとめます。結果の数値は環境や人で変わるため、こちらでは書かず、読者が自分で測る形にします。
判定の正体は引き算と基準時計です
判定に使う値は次の一つです。
差(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が来て、ランプが拍に合わせて光ります。スペースキーを拍に合わせて押すと、差と平均がミリ秒で表示されます。
<!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を使うクラウドセーブの案内を現在の案内で確認します。
自分で確かめる手順
前節のコードで、自分の環境のずれを測る設計です。数値の結果はここでは書きません。
- コードを保存してブラウザで開きます。BPMは120のまま始めます。
- 1セットにつき20回、拍に合わせてスペースを押します。これを3セット行います。セットごとに開始ボタンから押し直し、画面に出る「平均」と各回の差を書き留めます。
- 記録は「各回の差(ms)」を単位にします。セットごとの平均と、最も大きく外れた値も書いておきます。
- 次に、キーボード、ブラウザ、出力機器を一つずつ変えて同じ手順を繰り返します。変えるのは一度に一つだけにします。
結果は次のように読みます。
- 3セットの平均がどれも近いプラス(またはマイナス)の値なら、入力や表示の遅れによる系統的なずれです。判定窓を平行移動する「オフセット」で補正できる候補です。
- 平均は0に近いのに、差の大きさが回ごとにばらつくなら、人の揺れが主因です。窓を広げるか、難易度として受け入れます。
- セットごとの平均が大きく違うなら、慣れや集中の影響です。環境の補正値にはできません。
- 機器を変えると平均が変わるなら、オフセットを固定値にせず、プレイヤーが調整できる設定にします。
起こりやすい誤りもあります。
- キーを押し続けたときの連続入力(e.repeat)を除いていないと、差が混ざります。
- 拍の間隔の半分を超えて遅れると、次の拍との差として記録されます。大きな外れ値が出たら、この割り当てを疑います。
- タブが裏に回ると描画が止まることがあります。測定中は画面を前面のままにします。
- ランプの見た目に合わせた場合と、音に合わせた場合の差を、同じ記録に混ぜないようにします。
次の行動
まずコードを動かし、20回押して平均を一つ出してみてください。次に、WAVE / WAVE のパッド録音を、拍からわざとずらして試し、ステップ表示にどう寄るかを見ます。その結果をもとに、自作の判定窓の幅とオフセットの置き方を決めます。

