もぐらバスターの実装を解説
3×3の穴から出てくるもぐらをたたくゲームを題材に、画面の生成、出現タイミング、得点計算、入力、音声、ベストスコア保存までの仕組みを順番に紹介します。
もぐらバスターは、穴から顔を出すもぐらを30秒間でできるだけ多くたたくゲームです。マインスイーパーが盤面のマスの状態を調べるゲームだとすれば、もぐらバスターは時間の経過によって変わる一つのターゲットを正しく表示し、入力された場所と照合するゲームです。
ゲーム本体は、PHPで共通のヘッダーやフッターを読み込み、HTMLで操作パネルを用意し、JavaScriptでゲームの状態を管理しています。見た目はCSSのクラス切り替えで表現し、音声は外部ファイルではなくWeb Audio APIで生成しています。
ゲーム画面の要素を取得する
最初に、スコア、残り時間、ボタン、盤面、結果ダイアログなど、JavaScriptから更新する要素を取得します。ゲームの状態を変えたら、対応する要素のテキストやクラスを更新するという役割分担です。
const board = document.getElementById('board');
const scoreEl = document.getElementById('score');
const timeEl = document.getElementById('time');
const messageEl = document.getElementById('message');
const startButton = document.getElementById('start');
const pauseButton = document.getElementById('pause');
const result = document.getElementById('result');HTML側には、ゲーム開始ボタンや盤面の入れ物だけを置いています。9個の穴はHTMLに手書きせず、JavaScriptの初期化処理で生成します。そのため、穴の数やラベルを変更するときも同じ処理を直せば済みます。
ゲームの状態を変数で管理する
ゲーム中に変化する値は、処理の外側に状態としてまとめています。runningはゲーム中か、pausedは一時停止中かを表し、activeHoleには現在もぐらが出ている穴の番号を保存します。
let running = false;
let paused = false;
let score = 0;
let time = 30;
let activeHole = -1;
let popTimer = 0;
let clockTimer = 0;
const holes = [];activeHoleを一つだけ持つことで、「同時に出るもぐらは一匹」というルールを簡単に表現できます。もぐらが出ていない状態は-1にし、命中後や時間切れには表示中のクラスを外して戻します。
3×3の穴をJavaScriptで生成する
盤面には9個のボタンを作り、各ボタンに0から8までの番号をdata-indexとして持たせます。ボタンなのでキーボードのフォーカスやクリックイベントを標準機能として利用でき、画面をタップする操作にも対応できます。
for (let i = 0; i < 9; i += 1) {
const hole = document.createElement('button');
hole.type = 'button';
hole.className = 'mh-hole';
hole.dataset.index = String(i);
hole.setAttribute('role', 'gridcell');
hole.setAttribute('aria-label', `穴 ${i + 1}`);
hole.innerHTML = '<span class="mh-mole" aria-hidden="true"></span>';
hole.addEventListener('click', () => hit(i));
board.append(hole);
holes.push(hole);
}見た目のもぐらはaria-hiddenで読み上げ対象から外し、ボタンには「穴1」のようなラベルを付けています。ゲームの装飾と操作対象を分けることで、画面を見なくても各ボタンを識別しやすくなります。
CSSクラスで、出現と命中を表現する
もぐらを表示するために、JavaScriptから画像を毎回作り直すことはしていません。穴の中にあるもぐら要素へis-activeを付けたり外したりし、CSSのtransformとアニメーションで出入りを表します。
.mh-mole {
transform: translateY(75%);
transition: transform .13s ease-out;
}
.mh-hole.is-active .mh-mole {
transform: translateY(8%);
animation: mh-pop .18s ease-out;
}
.mh-hole.is-hit .mh-mole {
transform: translateY(8%) scale(.82);
}ゲームのルールはJavaScriptに置き、動きや色、穴の形はCSSに置いています。命中時に一瞬だけis-hitを付ければ、拡大縮小や「+10」の表示をCSSへ任せられます。アニメーションが終わったら、JavaScriptがクラスを削除して次の表示に備えます。
一度に一匹だけもぐらを出す
もぐらを出すときは、まず現在の穴からis-activeを外し、9個の中から乱数で新しい穴を選びます。前の出現を消してから次を表示する順番にすることで、複数の穴が同時にアクティブになるのを防げます。
function clearMole() {
if (activeHole < 0) return;
holes[activeHole].classList.remove('is-active', 'is-hit');
activeHole = -1;
}
function showMole() {
clearMole();
activeHole = Math.floor(Math.random() * holes.length);
holes[activeHole].classList.add('is-active');
clearTimeout(popTimer);
popTimer = window.setTimeout(() => {
clearMole();
if (running && !paused) showMole();
}, 700);
}Math.random()は0以上1未満の値を返すため、穴の数を掛けてMath.floorで整数にします。表示時間が終わったら自動的に次のもぐらを出しますが、一時停止中やゲーム終了後は再表示しません。
残り時間に応じて出現を速くする
実際のゲームでは、30秒の終わりに近づくほど出現時間が短くなります。ゲーム開始からの経過時間を30 - timeで求め、700ミリ秒から少しずつ減らします。ただし速くなりすぎないように、Math.maxで下限を設定しています。
const elapsed = 30 - time;
const visibleTime = Math.max(390, 700 - elapsed * 8);
popTimer = window.setTimeout(() => {
clearMole();
if (running && !paused) showMole();
}, visibleTime);上の計算は、画面のアニメーション時間ではなく、ゲームルール上の表示時間を決める処理です。CSSの動きとJavaScriptのタイマーを分けることで、見た目を変更してもゲームの難易度を調整しやすくなります。
クリックした穴と出現中の穴を照合する
クリック処理では、ゲーム中か、一時停止中でないかを確認してから、クリックされた番号とactiveHoleを比較します。一致すれば命中、違えば空振りです。空振りは減点せず、メッセージと短い音だけを変えています。
function hit(index) {
if (!running || paused) return;
if (index !== activeHole) {
holes[index].classList.add('is-miss');
messageEl.textContent = 'そこにはいません。次のもぐらを探そう';
tone(120, .06, 'sine', .05);
return;
}
score += 10;
holes[index].classList.add('is-hit');
messageEl.textContent = '命中! +10点';
clearMole();
showMole();
updateHud();
}命中時に先に得点を加え、表示を更新してから次のもぐらを出します。正解した穴をそのままにせず次の出現へ進めるため、連続して素早くたたく操作が可能になります。
30秒のゲームループを作る
このゲームは毎フレーム画面を書き換える必要がないため、時間の管理にsetIntervalを使っています。1秒ごとに残り時間を1減らし、0になったらendGameを呼びます。もぐらの表示は別のsetTimeoutなので、表示時間とカウントダウンが互いに邪魔をしません。
async function startGame() {
await initAudio();
score = 0;
time = 30;
running = true;
paused = false;
result.hidden = true;
pauseButton.disabled = false;
messageEl.textContent = 'もぐらを探そう!';
updateHud();
showMole();
startMusic();
clearInterval(clockTimer);
clockTimer = window.setInterval(() => {
if (!running || paused) return;
time -= 1;
updateHud();
if (time <= 0) endGame();
}, 1000);
}やり直しに対応するため、開始時にはスコア、時間、表示、タイマーを初期状態へ戻します。clearIntervalを先に実行しておけば、開始ボタンを何度押しても古いカウントダウンが残りません。
一時停止時にタイマーともぐらを止める
一時停止は表示だけを止めるのではなく、時間の減少、もぐらの再出現、BGMをまとめて止める必要があります。pausedを条件に入れ、表示中のタイマーを解除しておきます。再開時には新しいもぐらを出して、残り時間の計測を続けます。
function togglePause() {
if (!running) return;
paused = !paused;
pauseButton.textContent = paused ? '再開' : '一時停止';
messageEl.textContent = paused ? '一時停止中' : 'もぐらを探そう!';
if (paused) {
clearTimeout(popTimer);
clearMole();
stopMusic();
} else {
showMole();
startMusic();
}
}ブラウザのタブを切り替えたときも、visibilitychangeイベントで一時停止します。画面を見ていない間に時間だけが進み、復帰したらいきなり終了している、という状態を避けるためです。
キーボードとスマートフォンに対応する
穴はボタンなので、マウスやタッチではクリックイベントが使えます。さらにPCでは数字キー1~9を穴1~9へ対応させています。キー入力を番号へ変換して、最終的にはマウスと同じhit関数へ渡すことで、入力方法ごとに得点処理を複製せずに済みます。
document.addEventListener('keydown', event => {
if (/^[1-9]$/.test(event.key)) {
event.preventDefault();
hit(Number(event.key) - 1);
} else if (event.code === 'Space') {
event.preventDefault();
if (running) togglePause();
else startGame();
} else if (event.code === 'KeyP') {
togglePause();
}
});文字列のキーを正規表現で確認し、Number(event.key) - 1で配列の添字へ変換しています。スペースキーとPキーは、画面上の一時停止ボタンと同じ処理を呼ぶため、操作方法が違ってもゲームの状態遷移は一つに保てます。
Web Audio APIでBGMと効果音を作る
音声ファイルを読み込む代わりに、Web Audio APIの発振器とゲインを使って短い音を作っています。ブラウザの自動再生制限があるため、AudioContextはゲーム開始やサウンドボタンの操作をきっかけに初期化します。
function tone(frequency, duration = .08, type = 'square', gain = .1) {
if (!audio || audio.state !== 'running' || !sfxOn) return;
const now = audio.currentTime;
const oscillator = audio.createOscillator();
const envelope = audio.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, now);
envelope.gain.setValueAtTime(.0001, now);
envelope.gain.exponentialRampToValueAtTime(gain, now + .01);
envelope.gain.exponentialRampToValueAtTime(.0001, now + duration);
oscillator.connect(envelope).connect(master);
oscillator.start(now);
oscillator.stop(now + duration + .02);
}命中、空振り、時間切れで周波数や波形を変えると、処理の結果を音でも伝えられます。BGMも音符の配列を一定間隔で読み取り、toneへ渡すだけです。BGMと効果音で同じ音生成処理を共有し、チャンネルごとのON・OFFを引数で分けています。
ベストスコアをlocalStorageへ保存する
ベストスコアはサーバーへ送信せず、ブラウザのlocalStorageへ保存しています。ゲーム終了時に現在のスコアと比較し、上回っていた場合だけ保存します。ユーザー登録なしでも同じブラウザで記録が残る仕組みです。
if (score > best) {
best = score;
localStorage.setItem('mole-hunt-best', String(best));
}
best = Number(localStorage.getItem('mole-hunt-best') || 0);
bestEl.textContent = String(best).padStart(4, '0');保存値は文字列として返るため、読み出すときはNumberで数値に戻します。値がまだない場合は0を使い、画面上では0000のように4桁へ揃えています。
時間切れでゲームを終了する
終了処理は、ゲーム中フラグの変更、タイマーの停止、もぐらの非表示、音楽の停止、ベストスコア更新、結果ダイアログ表示を一か所にまとめています。途中で終了処理が複数箇所に分かれると、タイマーだけ残るなどの停止漏れが起きやすいためです。
function endGame() {
running = false;
paused = false;
clearInterval(clockTimer);
clearTimeout(popTimer);
stopMusic();
clearMole();
pauseButton.disabled = true;
if (score > best) {
best = score;
localStorage.setItem('mole-hunt-best', String(best));
}
updateHud();
resultText.textContent = `スコア ${score}点。もう一度挑戦しよう。`;
result.hidden = false;
}結果ダイアログはhidden属性で表示・非表示を切り替えています。ゲーム終了後に「もう一度遊ぶ」を押すと、通常の開始処理を呼び直すため、リセット用のコードを別に用意せずに済みます。
実装して分かったこと
もぐらバスターでは、複雑な物理演算よりも、表示中の対象と入力された対象を正しく対応させることが重要です。特に、出現タイマーを複数残さないこと、停止中に時間が進まないこと、ゲーム終了後の入力を無視することが安定した動作につながります。
また、ゲームの状態と画面の表現を分けると、マウス、タッチ、数字キーのように入力方法を増やしても、得点判定はhitへ集約できます。CSSのクラス切り替え、Web Audio API、localStorageを組み合わせることで、外部ライブラリなしでも短時間で遊べるブラウザゲームを作れます。
実際に遊ぶときはもぐらバスターのページを開き、ゲーム開始後に穴をクリックしてみてください。コードを読むときは、まずstartGame、showMole、hit、endGameの4つを追うと、ゲーム全体の流れをつかみやすくなります。