ブラウザ版おかし工房タイピングの実装を解説
お菓子の名前をローマ字入力して完成させるタイピングゲームを題材に、問題データ、入力候補、ゲームループ、時間制限、コンボ得点、Canvas描画、音声、ランキング保存までの実装を紹介します。
おかし工房タイピングは、画面に表示されたお菓子の読みをローマ字で入力し、制限時間内にできるだけ多く完成させるゲームです。入力が速いほど残り時間によるボーナスが増え、連続成功するとコンボによる加点も伸びます。
ゲームページのHTMLと設定はPHPで用意し、ゲーム進行はブラウザのJavaScriptで行っています。お菓子の種類やイラスト、成功メッセージはページごとの設定に分け、どんぶり道場と共通のtyping-feast.jsで処理できる構成です。
お菓子の名前と読みをデータで管理する
おかし工房では、ショートケーキ、プリン、マカロンなどのお菓子を、表示名と読みの組として配列に登録しています。ゲームIDとアトラス画像のURLも同じ設定へまとめ、共通スクリプトがゲームの種類を判断できるようにします。
window.typingFeastConfig = {
id: 'sweets',
atlas: '/img/game/sweets-typing-atlas-v2.png',
success: ['できあがり!', 'すてき!'],
items: [
{ name: 'ショートケーキ', kana: 'しょーとけーき' },
{ name: 'プリン', kana: 'ぷりん' },
{ name: 'マカロン', kana: 'まかろん' }
]
};画面表示にはname、ローマ字候補の生成にはkanaを使い分けます。ゲームの処理をお菓子の名前から切り離すことで、問題を追加してもJavaScript本体を変更せずに済みます。
3段階の難易度を共通設定にする
ゆっくり、ふつう、はやうちのボタンは、全体の制限時間、1個あたりの入力時間、得点倍率を切り替えます。難易度ごとに処理を分岐するのではなく、値を配列にして現在の番号から読み出します。
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];
}お菓子1個の入力時間は画面下のゲージにも反映します。全体時間と問題ごとの時間を別に持つことで、入力に手間取った場合は次のお菓子へ進めつつ、ゲーム全体は最後まで続けられます。
問題袋で出題の偏りを抑える
毎回乱数でお菓子を選ぶと、同じお菓子が連続して出る可能性があります。そこで、問題配列をシャッフルして袋を作り、末尾から取り出します。袋が空になったときだけ再シャッフルします。
let bag = [];
let current = null;
function shuffle(values) {
const result = [...values];
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[bag.length - 1] === current) {
[bag[0], bag[bag.length - 1]] = [bag[bag.length - 1], bag[0]];
}
}Fisher-Yates法で配列を後ろから入れ替え、前の問題との連続も避けています。30種類のお菓子を順番に使い切るまで重複しないため、短いプレイでも出題のバリエーションを保てます。
お菓子の読みから入力候補を作る
ローマ字には複数の入力方法があります。「しょ」はsyoやsho、「ん」はnやnnで入力できます。読みを単位へ分解し、それぞれの候補を組み合わせて入力可能な文字列を生成します。
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);
}長音の「ー」、小さい「っ」、撥音の「ん」も共通のローマ字表で処理します。候補を複数持つことで、利用者が覚えている入力方式を無理に一つへ限定せずに済みます。
入力途中の候補を絞り込む
キー入力のたびに、入力済み文字を先頭に持つ候補だけを残します。候補が残る間は入力を受け付け、候補の一つと完全一致したらお菓子を完成させます。
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();
}表示部分では、入力済み、次の1文字、残りの文字にクラスを付けて表示します。入力候補が絞られるため、入力中に次に必要な文字を強調でき、ゲームとしての分かりやすさにもつながります。
ゲームループで時間とゲージを更新する
全体の残り時間と、現在のお菓子の入力時間はrequestAnimationFrameで更新します。前回のフレームから経過した秒数を使うので、画面の更新速度に依存しにくいタイマーになります。
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);
}お菓子ごとの時間が切れた場合はミスを1回増やして次の問題へ進み、全体時間が切れた場合はゲームを終了します。この2つの終了条件を分けることで、1問の失敗がゲーム全体の即終了にならないようにしています。
完成速度とコンボを得点に反映する
お菓子が完成したら、完成数とコンボを更新します。入力文字数、残り時間、難易度倍率を組み合わせ、さらにコンボが続くほど追加倍率が増える計算です。
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);
}速く入力して残り時間が多いほど得点が高くなります。一方、入力ミスや時間切れではコンボが途切れるため、単に速く打つだけでなく正確な入力を続けることが重要です。
Canvasとアトラス画像でお菓子を表示する
お菓子のグラフィックは、複数のイラストをまとめたアトラス画像からCanvasへ切り出します。問題の配列番号を列と行へ変換し、対象のお菓子の範囲だけを描画します。
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 = rowBounds[row][0];
const sourceHeight = rowBounds[row][1] - rowBounds[row][0];
artContext.clearRect(0, 0, 300, 300);
artContext.drawImage(
atlasImage, sourceX, sourceY, cellWidth, sourceHeight,
30, 30, 240, 240
);
}画像を一枚にまとめることで、問題ごとに多数の画像要素を管理せずに済みます。画像の読み込みが終わっていない場合は描画を待ち、読み込み完了時に現在のお菓子を描き直します。
効果音とBGMをWeb Audio APIで生成する
キー入力、完成、ミス、ゲーム終了の音は、音声ファイルではなくWeb Audio APIの発振器とゲインから生成しています。おかし工房では、成功メッセージやBGMの音色を設定とゲームIDに応じて切り替えます。
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);
}AudioContextの初期化は開始ボタンなどのユーザー操作中に行い、ブラウザの自動再生制限に対応します。効果音とBGMは別々にミュートでき、音を消しても入力や得点処理は止まりません。
ゲーム終了とベスト5ランキング
全体時間が尽きるとゲームループとBGMを停止し、得点、完成数、ミス数、最大コンボを結果画面へ表示します。スコアはゲームIDと難易度を含むキーでlocalStorageへ保存し、上位5件だけを残します。
function storageKey() {
return `typing-feast-${config.id}-${level}`;
}
function save(score) {
const values = JSON.parse(
localStorage.getItem(storageKey()) || '[]'
);
values.push(score);
values.sort((a, b) => b - a);
localStorage.setItem(
storageKey(),
JSON.stringify(values.slice(0, 5))
);
}おかし工房とどんぶり道場はゲームIDが異なるため、同じ共通スクリプトを使ってもランキングが混ざりません。保存処理はブラウザの保存容量やプライベートモードの失敗も考慮し、実装では例外を握ってゲームを継続できるようにしています。
共通スクリプトで別テーマへ展開する
おかし工房とどんぶり道場は、見た目や問題データが異なりますが、入力候補の生成、問題袋、時間計測、得点、Canvas、音声、ランキングは同じ流れです。ゲーム固有の違いを設定へ閉じ込め、共通部分をtyping-feast.jsへまとめることで、別テーマのタイピングゲームも少ない追加コードで作れます。
実装を追うときは、まずstart、pick、key、complete、finishの順に読むと、開始から出題、入力、完成、結果表示までの流れをつかめます。実際におかし工房タイピングで遊びながら、入力候補、時間ゲージ、コンボ、ランキングがどの状態から更新されているかを確認してみてください。