GAME

ブラウザ版どんぶり道場の実装を解説

どんぶりの名前をローマ字入力するタイピングゲームを題材に、問題データ、入力候補、ゲームループ、制限時間、コンボ得点、Canvas描画、音声、ランキング保存までの実装を順番に紹介します。

どんぶり道場は、画面に表示された料理名の読みをローマ字で入力し、制限時間内に何杯完成させられるかを競うゲームです。正確に入力すると得点が加算され、連続成功によるコンボでさらに得点が伸びます。

ページの外枠はPHPで共通ヘッダーやフッターを読み込み、ゲームの進行はブラウザ上のJavaScriptで処理しています。牛丼や親子丼などの問題データはページから設定として渡し、ゲーム本体の処理は共通のtyping-feast.jsへまとめています。

ゲームの問題データを設定として渡す

ゲームページでは、ゲームID、料理画像のアトラス、成功時のメッセージ、料理名と読みをtypingFeastConfigへまとめています。ゲーム本体はこの設定を読むため、同じJavaScriptを別のタイピングゲームにも利用できます。

ゲーム固有の設定JAVASCRIPT
window.typingFeastConfig = {
    id: 'donburi',
    atlas: '/img/game/donburi-typing-atlas-v2.png',
    success: ['一丁あがり!', 'うまい!'],
    items: [
        { name: '牛丼', kana: 'ぎゅうどん' },
        { name: '親子丼', kana: 'おやこどん' },
        { name: '天丼', kana: 'てんどん' }
    ]
};

料理名と読みを同じ項目に持たせることで、画面には料理名とひらがなを表示し、入力判定には読みを使えます。画像や成功メッセージも設定側に置くため、共通スクリプトへどんぶり専用の条件を増やさずに済みます。

難易度を設定値にまとめる

ゆっくり、ふつう、はやうちの3段階は、制限時間、料理1品あたりの入力時間、得点倍率で表現しています。現在の難易度を番号で持ち、settings()から必要な設定を取り出します。

難易度の設定JAVASCRIPT
let level = 0;

function settings() {
    return [
        { duration: 75, limit: 9,   mul: 1 },
        { duration: 60, limit: 6.5, mul: 1.4 },
        { duration: 50, limit: 4.5, mul: 2 }
    ][level];
}

ゲーム開始時の全体時間はduration、1問の入力猶予はlimit、成功時の倍率はmulです。難易度を追加するときも設定を1行増やせばよく、画面更新や得点処理を複製する必要がありません。

問題を袋から取り出して同じ問題を続けない

料理を毎回ランダムに選ぶだけでは、同じ問題が連続することがあります。そこで、問題一覧をシャッフルした「袋」を作り、末尾から1問ずつ取り出します。袋が空になったら、もう一度全問題をシャッフルします。

問題袋を作るJAVASCRIPT
let bag = [];
let current = null;

function shuffle(items) {
    const result = [...items];
    for (let i = result.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [result[i], result[j]] = [result[j], result[i]];
    }
    return result;
}

function refill() {
    bag = shuffle(config.items);
    if (current && bag.length > 1 && bag.at(-1) === current) {
        [bag[0], bag[bag.length - 1]] = [bag[bag.length - 1], bag[0]];
    }
}

前回の問題が次の問題として選ばれた場合は、袋の先頭と末尾を交換して連続を避けます。全問題を一巡するまでは同じ問題が出ないため、短いゲーム時間でも問題の偏りを抑えられます。

ひらがなから複数のローマ字候補を作る

「し」はsiとshi、「ん」はnやnnなど、同じ読みでも入力方法が複数あります。読みを1つの文字単位に分け、各単位の候補を組み合わせて、入力可能なローマ字候補を作ります。

ローマ字候補を作るJAVASCRIPT
function variants(kana) {
    const units = tokenize(kana);
    const memo = new Map();

    function build(index) {
        if (index === units.length) return [''];
        if (memo.has(index)) return memo.get(index);

        const tails = build(index + 1);
        const heads = map[units[index]] || [units[index]];
        const result = heads.flatMap(head =>
            tails.map(tail => head + tail)
        );
        memo.set(index, result);
        return result;
    }

    return [...new Set(build(0))]
        .sort((a, b) => a.length - b.length);
}

実際の実装では、小さい「ゃ」や促音の「っ」、撥音の「ん」も扱えるように候補を追加しています。候補を配列で持っておけば、入力途中の文字列がどの候補の先頭にも一致するかを判定できます。

入力を候補の先頭一致で判定する

キー入力のたびに、現在までに入力した文字列を候補と比較します。候補が1つでも残っていれば入力を受け付け、候補が完全一致した時点で料理を完成させます。入力できない文字の場合はミスとして扱います。

キーボード入力JAVASCRIPT
function key(event) {
    if (!running || !accepting) return;
    const key = event.key.toLowerCase();
    if (!/^[a-z-]$/.test(key)) return;

    const next = typed + key;
    const filtered = candidates.filter(value =>
        value.startsWith(next)
    );

    if (!filtered.length) {
        miss++;
        combo = 0;
        score = Math.max(0, score - 20);
        return;
    }

    typed = next;
    candidates = filtered;
    if (candidates.includes(typed)) complete();
}

表示側では、入力済みの部分、次に入力する文字、残りの文字へ分けて表示しています。入力候補を絞り込む方式にしたことで、「し」をsiで入力するかshiで入力するかを、入力途中でも自然に受け付けられます。

requestAnimationFrameで時間を進める

全体の残り時間と、現在の料理に使える時間は、requestAnimationFrameで呼ばれるゲームループから更新します。前回の時刻との差を秒に変換するため、端末のフレームレートが違っても時間の進み方が大きく変わりません。

ゲームループJAVASCRIPT
function loop(now) {
    if (!running) return;

    const delta = Math.min((now - last) / 1000, 0.05);
    last = now;
    time -= delta;
    itemTime -= delta;

    limitElement.style.transform =
        `scaleX(${Math.max(0, itemTime / itemMax)})`;

    if (time <= 0) return finish();
    if (itemTime <= 0) timeout();

    updateHud();
    raf = requestAnimationFrame(loop);
}

全体時間が0になればゲーム終了、1問の時間だけが0になればミスとして次の料理へ進みます。入力処理と時間処理を分け、どちらからでも共通の状態変更関数を呼ぶ構成にしています。

完成時にコンボと得点を更新する

料理を完成させると、完成数とコンボを増やし、入力文字数、残り時間、難易度倍率を使って得点を計算します。コンボが続くほど追加倍率も少しずつ増えます。

完成処理と得点JAVASCRIPT
function complete() {
    accepting = false;
    served++;
    combo++;
    maxCombo = Math.max(maxCombo, combo);

    score += Math.round(
        (100 + typed.length * 18 + itemTime * 12)
        * settings().mul
        * (1 + Math.min(combo, 20) * 0.04)
    );

    updateHud();
    setTimeout(() => {
        if (running) pick();
    }, 90);
}

短時間で入力するほどitemTimeが多く残るため得点が高くなります。ミスや時間切れではコンボを0に戻し、得点を伸ばすには速さだけでなく正確さも必要になるようにしています。

Canvasで料理画像をアトラスから切り出す

料理のイラストは、複数の画像を1枚にまとめたアトラス画像からCanvasへ切り出して描画します。問題の番号を列と行に変換し、該当する範囲だけをdrawImageで表示します。

アトラス画像を描画するJAVASCRIPT
function drawArt(item) {
    const index = config.items.indexOf(item);
    const column = index % 5;
    const row = Math.floor(index / 5);
    const cellWidth = atlasImage.naturalWidth / 5;
    const sourceX = column * cellWidth;
    const sourceY = row * rowHeight;

    artContext.clearRect(0, 0, 300, 300);
    artContext.drawImage(
        atlasImage,
        sourceX, sourceY, cellWidth, rowHeight,
        30, 30, 240, 240
    );
}

Canvasを使うと、料理ごとに別々の画像要素を作らずに済みます。アトラス画像の読み込み前は描画を待ち、読み込み完了後に現在の問題を描き直します。画面上の料理名や読みは通常のHTMLで表示し、画像だけをCanvasへ分担させています。

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

効果音とBGMは音声ファイルを読み込まず、Web Audio APIの発振器とゲインを使って生成しています。キー入力、完成、ミス、ゲーム終了で音を変え、BGMはゲーム中だけ一定間隔で再生します。

短い音を生成するJAVASCRIPT
function tone(frequency, duration = 0.06) {
    if (!sound) return;
    initAudio();

    const oscillator = audio.createOscillator();
    const gain = audio.createGain();
    const now = audio.currentTime;

    oscillator.frequency.value = frequency;
    gain.gain.setValueAtTime(0.0001, now);
    gain.gain.exponentialRampToValueAtTime(0.08, now + 0.01);
    gain.gain.exponentialRampToValueAtTime(0.0001, now + duration);
    oscillator.connect(gain).connect(audio.destination);
    oscillator.start();
    oscillator.stop(now + duration + 0.02);
}

音声の初期化はユーザーの開始ボタン操作の中で行います。ブラウザの自動再生制限に対応するためです。効果音とBGMのミュート状態は別々に持ち、音を切ってもゲーム操作は続けられるようにしています。

ゲーム終了とランキングをlocalStorageで管理する

制限時間が終わると、ゲームループを停止し、BGMを止め、スコア・完成数・ミス数・最大コンボをモーダルへ表示します。スコアは難易度ごとのキーでlocalStorageへ保存し、上位5件だけをランキングに残します。

ベスト5を保存するJAVASCRIPT
function storageKey() {
    return `typing-feast-${config.id}-${level}`;
}

function save(score) {
    const oldScores = JSON.parse(
        localStorage.getItem(storageKey()) || '[]'
    );
    oldScores.push(score);
    oldScores.sort((a, b) => b - a);
    localStorage.setItem(
        storageKey(),
        JSON.stringify(oldScores.slice(0, 5))
    );
}

ゲームIDと難易度をキーに含めることで、どんぶり道場の記録が別ゲームや別難易度と混ざりません。保存に失敗する環境も考え、実際の共通スクリプトでは読み書きをtry...catchで囲んでいます。

レスポンシブ対応と状態管理

ゲーム画面は、ゲーム本体とランキング・設定のサイドパネルで構成しています。画面幅が狭い場合は縦並びへ切り替え、キーボード入力の説明や設定ボタンがゲーム画面に重ならないようにします。

実装を追うときは、まずstart、pick、key、complete、finishの順に読むと、開始から問題出題、入力、完成、終了までの流れをつかめます。表示だけを直接変更するのではなく、JavaScriptの状態を更新してからHUDや画面を描画する構成が、難易度・音声・ランキングを追加しても保守しやすくするポイントです。

実際に遊ぶ場合はどんぶり道場のゲームページへ進んでください。ローマ字入力の候補、制限時間、コンボによる得点の変化を試しながらコードを読むと、各処理の役割を確認しやすくなります。