リズムゲームは、音楽に合わせてタイミングよくボタンを押すゲームです。このチュートリアルでは、JavaScriptとCanvasを使ってリズムゲームを作る方法を学びましょう。譜面データの管理、タイミング判定の仕組み、ノーツのアニメーションなど、タイミングベースのゲーム開発に必要な技術を身につけられます。
リズムゲームの画面は複数の「レーン」に分かれていて、ノーツ(音符)が上から下に流れてきます。まず、レーンの数やノーツの速度、判定の範囲といった定数を定義しましょう。
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から逆算して毎フレーム計算します。判定は時間・見た目は座標と役割を分けておくと、あとから落下速度を変えても判定が狂いません。
このチュートリアルでは、レーン構造の設計、BPMベースの譜面生成、タイミング判定とコンボシステムの3つを学びました。発展として、Web Audio APIで実際の音楽を再生したり、長押しノーツ(ロングノート)を追加したり、譜面エディターを作ったりしてみましょう。エフェクトやパーティクルを追加すると、見た目もぐっとかっこよくなりますよ。
A: はい、大丈夫です。このチュートリアルではステップごとにコードを書いていくので、初めての方でも順番に進めれば完成できます。わからないところがあれば、ひなテックの教室で質問もできますよ。
A: 基本部分は約30分〜1時間で作れます。見た目をこだわったり機能を追加すると、さらに楽しく発展させられます。