リズムゲームは、音楽に合わせてタイミングよくボタンを押すゲームです。このチュートリアルでは、JavaScriptとCanvasを使ってリズムゲームを作る方法を学びましょう。譜面データの管理、タイミング判定の仕組み、ノーツのアニメーションなど、タイミングベースのゲーム開発に必要な技術を身につけられます。
前半の3ステップで「動く」ところまで、後半(ステップ4以降)で実際に公開しているゲームがどう作られているかを、コードの事実にもとづいて解説します。
リズムゲームの画面は複数の「レーン」に分かれていて、ノーツ(音符)が上から下に流れてきます。まず、レーンの数やノーツの速度、判定の範囲といった定数を定義しましょう。
var LANE_COUNT = 4;
var JUDGE_Y_RATIO = 0.82;
var NOTE_SPEED = 3;
// 判定の幅は「ミリ秒」で決める
var PERFECT_MS = 67;
var GREAT_MS = 144;
var GOOD_MS = 250;
var MISS_MS = 389;
var SCORE_PERFECT = 300;
var SCORE_GREAT = 200;
var SCORE_GOOD = 100;
var LANE_COLORS = ['#E74C3C', '#3498DB', '#2ECC71', '#F1C40F'];
var LANE_KEYS = ['KeyD', 'KeyF', 'KeyJ', 'KeyK'];
4つのレーンにそれぞれ色とキーを割り当てています。JUDGE_Y_RATIOは画面の82%の位置に判定ラインを置くことを意味します。
ここで大事なのが、判定の幅を画面上の距離(ピクセル)ではなく時間(ミリ秒)で決めていることです。ピクセルで決めると、ノーツの落ちる速さを変えたときに許される時間の幅まで一緒に変わってしまいます。速い曲を追加した途端に判定だけ別物になってしまう、というわけです。時間で決めておけば、どんな速さの曲でも「67ミリ秒以内ならPERFECT」を保てます。この数値を小さくするほど、ゲームはシビアになります。
譜面(chart)はノーツの出現タイミングとレーン番号のリストです。BPM(テンポ)から1拍の時間を計算し、パターンベースで譜面を自動生成しましょう。
var BPM = 130;
var BEAT_MS = 60000 / BPM;
function generateChart() {
chart = [];
var totalBeats = 128;
var patterns = [
[[0], [], [2], []], // 基本パターン
[[0], [1], [2], [3]], // 階段
[[0, 2], [], [1, 3], []], // 同時押し
[[3], [2], [1], [0]], // 逆階段
];
var beat = 4; // イントロ休み
while (beat < totalBeats - 4) {
var difficulty = Math.min(beat / totalBeats, 1);
var pat = patterns[Math.floor(Math.random() * patterns.length)];
for (var i = 0; i < pat.length; i++) {
var lanes = pat[i];
for (var j = 0; j < lanes.length; j++) {
chart.push({ time: beat * BEAT_MS, lane: lanes[j] });
}
beat += 1;
}
}
chart.sort(function (a, b) { return a.time - b.time; });
}
BEAT_MSでBPM130なら1拍あたり約461ミリ秒と計算できます。パターン配列の各要素が1拍分に対応し、空配列[]は休符を表します。曲の進行に合わせてdifficultyを上げていくことで、後半ほど複雑なパターンが出やすくなります。
譜面はあくまで「予定表」です。これを画面に流すには、判定ラインに届く少し前にnotes配列へ入れて、毎フレームY座標を計算し直します。このとき、押すべき時刻をtargetTimeとして持たせておくのが大事です。
var now = performance.now() - startTime;
var judgeY = H * JUDGE_Y_RATIO;
// 落ちきるのにかかる時間ぶん手前で登場させる
var travelTime = judgeY / NOTE_SPEED / 60 * 1000;
while (noteIndex < chart.length && chart[noteIndex].time - travelTime <= now) {
var c = chart[noteIndex];
notes.push({ lane: c.lane, targetTime: c.time, y: -20, hit: false });
noteIndex++;
}
// 経過時間からY座標を毎フレーム計算する
for (var i = notes.length - 1; i >= 0; i--) {
var n = notes[i];
var timeDiff = now - n.targetTime;
n.y = judgeY + timeDiff * NOTE_SPEED * 60 / 1000;
if (timeDiff > MISS_MS) { // 通り過ぎたらMISS
combo = 0;
notes.splice(i, 1);
}
}
timeDiffがマイナスならまだ判定ラインの上、0でちょうど判定ライン、プラスなら通り過ぎた状態です。Y座標を「持ち歩く」のではなく毎回時刻から計算し直しているので、フレームが少し飛んでもノーツが音楽からズレません。
プレイヤーがキーを押したとき、そのレーンにあるノーツの中から「本来押すべき時刻」がいまに最も近いものを探して判定します。ズレが小さいほど高評価、大きいとMISS。連続成功でコンボが増え、ボーナスポイントが加算されるしくみです。
ノーツを作るときに、そのノーツを押すべき時刻をtargetTimeとして持たせておくのがポイントです。曲が始まってからの経過時間nowと引き算するだけで、ズレがミリ秒で分かります。
function hitLane(lane) {
if (!running) return;
var now = performance.now() - startTime; // 曲が始まってからの経過時間(ms)
// 押すべき時刻がいまに最も近いノーツを探す
var closest = null;
var closestDiff = Infinity;
for (var i = 0; i < notes.length; i++) {
var n = notes[i];
if (n.lane !== lane || n.hit) continue;
var d = Math.abs(n.targetTime - now); // 早すぎ・遅すぎの両方を見たいので絶対値
if (d < closestDiff) {
closestDiff = d;
closest = n;
}
}
if (!closest) return;
if (closestDiff <= PERFECT_MS) {
points = SCORE_PERFECT; combo++;
} else if (closestDiff <= GREAT_MS) {
points = SCORE_GREAT; combo++;
} else if (closestDiff <= GOOD_MS) {
points = SCORE_GOOD; combo++;
} else if (closestDiff <= MISS_MS) {
points = 0; combo = 0;
} else {
return; // 遠すぎるノーツは「押していない」扱い
}
// コンボボーナス
points += Math.floor(combo * 5);
score += points;
closest.hit = true;
}
Math.abs()で絶対値を取っているので、早く押しすぎても遅く押しすぎても同じように判定されます。コンボが続くほどボーナスが増えるので、連続でPERFECTを出すのが高得点のカギになります。ノーツにhit = trueをセットして二重判定を防いでいるのもポイントです。
なお、画面に表示するノーツのY座標は、このtargetTimeから逆算して毎フレーム計算します。判定は時間・見た目は座標と役割を分けておくと、あとから落下速度を変えても判定が狂いません。
ここまでのnowはperformance.now()で作りましたが、実際のゲームでは次のステップのsongPosition()に置きかわります。音楽と一緒に遊ぶゲームでは、これが決定的に大事です。
音楽を鳴らしながらノーツを流すと、はじめは合っていたのに曲の後半でだんだんズレてくることがあります。原因は「時計が2つある」ことです。performance.now()は画面(描画)の時計、音を鳴らすAudioContextは音声の時計で、この2つは別々に進みます。ズレをなくす方法は1つ、判定も描画も音声の時計に合わせることです。
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// 曲を「いつ鳴らし始めるか」を音声の時計で決める(0.6秒のリードイン)
var audioT0 = audioCtx.currentTime + 0.6;
bgmSource.start(audioT0); // 同じ audioT0 で再生を予約する
// 曲の再生位置(ms)。performance.now() は使わない
function songPosition() {
// Bluetoothイヤホンなどで音が遅れて出る分を差し引く
var latency = audioCtx.outputLatency || audioCtx.baseLatency || 0;
return (audioCtx.currentTime - latency - audioT0) * 1000;
}
ポイントは、bgmSource.start(audioT0)で使ったまったく同じaudioT0を基準にすることです。「曲が始まった瞬間」を音声の時計で記録しておけば、audioCtx.currentTime - audioT0がそのまま曲の再生位置になります。outputLatencyを引いているのは、ワイヤレスイヤホンでは音が数十〜数百ミリ秒遅れて耳に届くためで、これを引かないと「見た目は合っているのに音だけ遅い」状態になります。
実際のゲームでは、ステップ2の「ノーツを登場させる」判断も、ステップ3のhitLane()も、描画のY座標も、すべてsongPosition()の1本の時計で動いています。時計を1本にすると、フレームが落ちてもタブを切り替えても、ノーツは音楽から離れません。
mp3の先頭には無音があり、その長さは曲ごとに違います。そこで曲データにoffset(ms)を持たせ、譜面の時刻に足しています。
// 曲データの例(実際のゲームは9曲)
var SONGS = [
{ file: 'garden.mp3', bpm: 120, offset: 180, noteSpeed: 3.4, intro: 8 },
// offset: 曲頭の無音ぶんの補正。曲ごとに -122〜263ms とバラバラ
// noteSpeed: 落下速度(3〜5.2)。速い曲ほど大きくして読みやすくする
// intro: ノーツが降り始めるまでの休符(拍)
];
// 譜面を作るとき、拍の時刻に offset を足す
chart.push({ time: beat * BEAT_MS + song.offset, lane: lane });
このoffsetは波形を解析しただけでは±35msほど残ります。最後は人間の耳で合わせるしかなく、実際のゲームにはURLに?dev=1を付けると出てくる調整画面(スライダーとメトロノーム)があり、聴きながら数値を決めています。「音ゲーは最後は耳」というのは本当です。
判定の幅(67/144/250/389ms)は難易度で変えていません。変えているのは譜面の密度です。使うパターンの種類数を難易度で絞り(かんたん=5種類・ふつう=9種類・むずかしい以上=全部)、いちばん上の難易度ではstep = 0.5にして半拍刻みでノーツを置きます。6キーモードには6レーン用のパターン表を別に持っています。「判定は同じ、譜面で難しくする」ほうが、プレイヤーには公平に感じられます。
実際のgame.jsにある定数です。ソースページの「書きかえて動かす」で変えると、その場で試せます。
| 定数 | 値 | 変えると何が起きるか |
|---|---|---|
PERFECT_MS〜MISS_MS | 67 / 144 / 250 / 389 | 判定の甘さ。以前は 111/222/361/500 で、厳しくした経緯がコメントに残っています |
NOTE_SPEED・song.noteSpeed | 既定3、曲別3.4〜5.2 | ノーツの落下速度=読みやすさ。判定には影響しない(時間基準だから) |
JUDGE_Y_RATIO | 0.82 | 判定ラインの高さ。低くするとノーツを見ていられる時間が伸びる |
song.offset | -122〜263ms | 音ズレに直結。曲ごとに耳で決める |
song.intro | 8拍 | 曲が始まってからノーツが降り始めるまでの休符 |
| コンボ加点 | Math.floor(combo * 5) | コンボの価値。大きくするとつなぐ人と切る人の差が開く |
| BGMのゲイン | 0.55 | 音量。効果音とのバランス |
requestAnimationFrame、判定はperformance.now()、音はAudioContext……と別の時計を使うと必ずドリフトします。判定も描画もsongPosition()の1本にまとめてくださいjudgeY / noteSpeed / 60 * 1000)を逆算して、その分だけ早く登場させる必要があります。これを忘れると最初のノーツが間に合いませんこのチュートリアルでは、レーン構造の設計、BPMベースの譜面生成、タイミング判定とコンボシステム、そして音声の時計で音楽とノーツを同期する方法を学びました。実際のゲームは9曲のBGMをWeb Audio APIで鳴らし、曲ごとのオフセットを耳で合わせています。発展として、長押しノーツ(ロングノート)を追加したり、譜面を自動生成ではなく手で置ける譜面エディターを作ったりしてみましょう。エフェクトやパーティクルを追加すると、見た目もぐっとかっこよくなりますよ。
A: はい、大丈夫です。このチュートリアルではステップごとにコードを書いていくので、初めての方でも順番に進めれば完成できます。わからないところがあれば、ひなテックの教室で質問もできますよ。
A: 基本部分は約30分〜1時間で作れます。見た目をこだわったり機能を追加すると、さらに楽しく発展させられます。
A: 順に3つです。①判定や描画にperformance.now()を使っていないか(AudioContextの時計に統一する)。②曲のoffsetが合っているか(曲頭の無音は曲ごとに違う)。③BluetoothイヤホンならoutputLatencyを引いているか。ゲームの後半ほどズレが大きくなるなら①、最初からずっと同じだけズレているなら②か③です。
A: 判定をピクセル距離で決めているのが原因です。判定は時間(ミリ秒)で決め、見た目のY座標は時刻から逆算するようにすると、速度を変えても判定の甘さは変わりません。