GAME

ビートステップの実装を解説

4レーンに流れるノート、判定タイミング、ゲームループ、Web Audio API、キーボードとタッチ操作まで、ブラウザ版ビートステップの仕組みを紹介します。

ビートステップは、BGMを聴きながら流れてくるノートを4つのレーンで叩くリズムゲームです。判定ラインにノートが重なる時刻と、プレイヤーがボタンを押した時刻の差を比べ、PERFECT、GOOD、MISSを決めています。

ゲームページはPHPで共通ヘッダーやフッターを読み込み、ゲーム本体はJavaScriptで動かしています。ノートの表示には通常のHTML要素を使い、音声は外部ファイルではなくWeb Audio APIで生成しています。

ゲームの定数と状態を用意する

最初に、レーン数、テンポ、曲の長さ、ノートが画面に現れてから判定ラインへ届くまでの時間を定数にしています。ゲーム中に変化するスコアやコンボ、開始時刻などは変数で管理します。

ゲームの基本設定JAVASCRIPT
const LANES = 4;
const BPM = 112;
const BEAT = 60000 / BPM;
const SONG_LENGTH = 36000;
const APPROACH = 1800;
const HIT_LINE = 398;

const keys = ['d', 'f', 'j', 'k'];
const notes = [];

let score = 0;
let combo = 0;
let hits = 0;
let judged = 0;
let running = false;
let paused = false;
let startAt = 0;

BEATは1拍の長さです。BPMが112なら、1拍は約535.7ミリ秒になります。ノートの時刻、音の再生時刻、画面上の位置を同じ時間軸で扱うため、テンポをミリ秒へ変換して使っています。

ノートを譜面データとして生成する

ノートは、どのレーンを通るかを表すlaneと、開始時点から何ミリ秒後に判定ラインへ到達するかを表すtimeを持つオブジェクトです。譜面ファイルを別に用意せず、一定のパターンを繰り返して約36秒分のノートを作っています。

ノートを生成するJAVASCRIPT
const makeNotes = () => {
    notes.length = 0;
    const pattern = [0, 1, 2, 3, 1, 2, 0, 3, 2, 1, 3, 0];

    for (let beat = 2, i = 0;
         beat * BEAT < SONG_LENGTH - 1000;
         beat++, i++) {
        notes.push({
            lane: pattern[i % pattern.length],
            time: beat * BEAT,
            judged: false
        });

        if (beat % 4 === 3) {
            notes.push({
                lane: (pattern[i % pattern.length] + 2) % LANES,
                time: beat * BEAT + BEAT / 2,
                judged: false
            });
        }
    }
};

通常のノートは1拍ごとに置き、4拍のうち特定の拍には半拍ずれたノートも追加しています。judgedを持たせておくことで、ヒット済みのノートをもう一度判定しないようにしています。実際のゲームでは、この配列を譜面データとして外部JSONへ分離することもできます。

ゲーム内の時刻を基準にする

リズムゲームでは、ノートの画面上の位置よりも時刻が重要です。開始予定時刻をstartAtへ保存し、毎フレームの現在時刻との差からゲーム内の経過時間を求めます。描画が一時的に遅れても、ノートの判定時刻そのものは変わりません。

開始時刻と経過時間JAVASCRIPT
const begin = () => {
    makeNotes();
    score = 0;
    combo = 0;
    hits = 0;
    judged = 0;
    running = true;
    paused = false;

    // 押してから少し待ってゲーム内時刻を開始する
    startAt = performance.now() + 1200;
    animationId = requestAnimationFrame(loop);
};

const loop = now => {
    if (!running) return;
    const elapsed = now - startAt;
    // elapsedをノートのtimeと比較して処理する
};

performance.now()は、ページ表示時刻ではなく高精度な経過時間を返します。ゲーム開始ボタンを押した直後に音声や表示を準備できるよう、実際の開始時刻を1.2秒先に設定しています。

requestAnimationFrameでノートを描画する

毎フレーム、まだ判定されていないノートだけを調べ、判定ラインまでの残り時間から画面上の位置を計算します。APPROACHが1800ミリ秒なので、1800ミリ秒前に画面の上端へ現れ、判定時刻に判定ラインへ到達します。

ノートの位置を計算するJAVASCRIPT
const render = elapsed => {
    notesEl.replaceChildren();

    notes
        .filter(note => !note.judged
            && note.time - elapsed <= APPROACH
            && note.time - elapsed >= -180)
        .forEach(note => {
            const element = document.createElement('span');
            element.className = `beat-note-item lane-${note.lane}`;
            element.style.left = `${note.lane * 25 + 3.5}%`;
            element.style.top = `${HIT_LINE
                - ((note.time - elapsed) / APPROACH) * HIT_LINE}px`;
            notesEl.append(element);
        });
};

残り時間をAPPROACHで割ると、ノートがどれだけ上から下へ進んだかを0から1に近い値で表せます。それをHIT_LINEへ掛け、CSSのtopへ変換しています。ノートごとにDOM要素を作り直すため、判定済みのノートを画面から簡単に取り除けます。

入力時刻との差で判定する

レーンボタンが押されたら、そのレーンにある未判定ノートのうち、入力時刻に最も近いものを探します。差が140ミリ秒以内ならヒットとし、70ミリ秒以内ならPERFECT、それを超える場合はGOODにします。

ノートの判定JAVASCRIPT
const hit = lane => {
    if (!running || paused) return;

    const elapsed = performance.now() - startAt;
    const candidate = notes
        .filter(note => !note.judged && note.lane === lane)
        .sort((a, b) =>
            Math.abs(a.time - elapsed) - Math.abs(b.time - elapsed))[0];

    const difference = candidate
        ? Math.abs(candidate.time - elapsed)
        : Infinity;

    let grade = 'MISS';
    if (candidate && difference <= 140) {
        candidate.judged = true;
        judged++;
        hits++;
        combo++;
        grade = difference <= 70 ? 'PERFECT' : 'GOOD';
    } else {
        judged++;
        combo = 0;
    }
    showJudgement(grade);
};

画面上でノートが判定ラインに重なっているかを画像の位置から判定するのではなく、時刻の差を使っているのがポイントです。これにより、画面の大きさやフレームレートが変わっても、判定基準を同じにできます。

何もしなかったノートをMISSにする

プレイヤーがボタンを押さなかった場合も、ノートが判定時刻を180ミリ秒過ぎたところでMISSとして処理します。これをゲームループ内で行うことで、画面外へ消えたノートが未判定のまま残ることを防ぎます。

通過したノートをMISSにするJAVASCRIPT
notes
    .filter(note => !note.judged
        && elapsed - note.time > 180)
    .forEach(note => {
        note.judged = true;
        judged++;
        combo = 0;
        showJudgement('MISS');
    });

update();
render(elapsed);
animationId = requestAnimationFrame(loop);

ヒット判定とMISS判定の両方でjudgedを増やしているため、正確率は判定済みノートのうち成功した割合として計算できます。コンボはMISSで0へ戻し、ヒット時だけ増やします。

Web Audio APIでBGMと効果音を作る

BGMと効果音は、音声ファイルを読み込まずにWeb Audio APIの発振器から作っています。BGMの各音を開始予定時刻へ予約し、ノートの時間軸と同じ基準で再生します。ブラウザーの自動再生制限があるため、AudioContextはゲーム開始などのユーザー操作の中で作成します。

指定時刻に音を予約するJAVASCRIPT
const toneAt = (frequency, targetMs,
    duration = .08, type = 'sine', gain = .06) => {
    if (!audio || audio.ctx.state !== 'running') return;

    const now = audio.ctx.currentTime
        + Math.max(0, (targetMs - performance.now()) / 1000);
    const oscillator = audio.ctx.createOscillator();
    const volumeNode = audio.ctx.createGain();

    volumeNode.gain.setValueAtTime(.0001, now);
    volumeNode.gain.exponentialRampToValueAtTime(
        gain * volume(), now + .01);
    volumeNode.gain.exponentialRampToValueAtTime(
        .0001, now + duration);
    oscillator.frequency.value = frequency;
    oscillator.connect(volumeNode).connect(audio.ctx.destination);
    oscillator.start(now);
    oscillator.stop(now + duration + .03);
};

targetMsと現在のperformance.now()の差をAudioContextの時刻へ加え、将来の再生時刻を予約しています。音量は最初に小さくし、短時間で指定音量へ上げたあと減衰させています。これにより、音の始まりと終わりが急に切れにくくなります。

BGMを先に予約するJAVASCRIPT
const scheduleMusic = elapsed => {
    while (musicStep * BEAT <= elapsed + 500) {
        scheduleMusicBeat(musicStep, startAt + musicStep * BEAT);
        musicStep++;
    }
};

毎フレーム、現在時刻から500ミリ秒先までの音を予約します。先の音を少しずつ予約しておくことで、JavaScriptの処理が一瞬遅れても音が途切れにくくなります。BGMのON/OFFと効果音のON/OFFはlocalStorageへ保存し、次回も同じ設定で遊べるようにしています。

キーボードと画面ボタンを同じ処理へ集約する

キーボードのD・F・J・Kと矢印キー、画面上の4つのボタンは、最終的にレーン番号をhitへ渡します。入力方法ごとに判定処理を複製しないため、キーボードとタッチ操作で結果がずれません。

キーボード入力JAVASCRIPT
document.addEventListener('keydown', event => {
    const lane = keys.indexOf(event.key.toLowerCase());
    const arrowLane = {
        ArrowLeft: 0,
        ArrowDown: 1,
        ArrowUp: 2,
        ArrowRight: 3
    }[event.key];
    const selected = lane >= 0 ? lane : arrowLane;

    if (selected === undefined) return;
    event.preventDefault();
    laneButtons[selected].classList.add('pressed');
    hit(selected);
});

キー名をレーン番号へ変換してから、画面ボタンの押下表示も更新しています。画面ボタン側のpointerdownでも同じhitを呼ぶため、マウス、タッチ、キーボードを同じ判定にできます。

一時停止で時刻のずれを補正する

一時停止中に経過時間が進むと、再開したときにノートとBGMだけが先へ進んでしまいます。そこで再開時に、一時停止していた時間をstartAtへ加えています。開始時刻を後ろへずらすことで、ゲーム内の経過時間を停止前の続きへ戻します。

一時停止時間を除外するJAVASCRIPT
if (paused) {
    pausedAt = performance.now();
    audio.ctx.suspend();
} else {
    startAt += performance.now() - pausedAt;
    audio.ctx.resume();
}

音声コンテキストも停止・再開し、画面の時間軸と音声の時間軸をそろえています。ページを別のタブへ切り替えた場合も、visibilitychangeで音声を停止し、戻ったときに再開する構成です。

レスポンシブ表示とアクセシビリティ

ゲーム画面は4レーンを横並びにし、スマートフォンでは画面上のレーンボタンを押して遊べるようにしています。ボタンにはレーンのキー名を表示し、ゲームの説明、判定結果、スコアにはaria-labelやaria-liveを設定しています。

ゲームの見た目はCSSで作り、ノートのレーン位置や判定ラインの表示もDOMとCSSで制御しています。矩形のパネルやボタンの角丸はサイト全体のデザインに合わせて8pxに統一し、ゲーム画面の情報が操作部分に重ならないようにしています。

実装して分かったこと

リズムゲームでは、ノートを動かして見せることよりも、時刻の基準を一つにすることが重要です。ノート、入力判定、MISS、BGMをstartAtとBEATから計算することで、描画と音声を同じリズムへ合わせられます。

また、入力方法を増やすときも、レーン番号をhitへ渡す形にそろえておくと、判定処理を一か所で管理できます。ゲームを遊ぶときは、実際のビートステップでBGMを聴きながら、PERFECTのタイミングを試してみてください。