猫をさがせ!の作り方 — JavaScriptでブラウザゲームを作ろう

このゲームで遊ぶ → ソースコード全文 →

このチュートリアルで学べること

猫をさがせ!は、大量の動物絵文字の中から特定の猫を見つけ出すゲームです。このゲームを作ることで、CSSグリッドによる動的レイアウト・クリックイベントの判定・経過時間を使ったスコア計算といった実践的な技術が身につきます。

ステップ1: グリッドの生成とターゲット配置

まずゲームの盤面を作ります。ラウンドが進むにつれてグリッドが大きくなるよう、ラウンドごとに列数・行数の設定を用意します。ターゲットの猫をランダムな位置に1つだけ紛れ込ませるのがポイントです。

// ラウンドごとのグリッドサイズ設定
var ROUND_CONFIGS = [
  { cols: 8,  rows: 8  },  // ラウンド1: 64マス
  { cols: 10, rows: 10 },  // ラウンド2: 100マス
  { cols: 12, rows: 10 },  // ラウンド3: 120マス
  { cols: 12, rows: 12 },  // ラウンド4: 144マス
  { cols: 14, rows: 12 }   // ラウンド5: 168マス
];

function generateRound() {
  var config = ROUND_CONFIGS[currentRound];
  var totalCells = config.cols * config.rows;

  // ターゲットの猫をランダムに選ぶ
  targetCat = CAT_EMOJIS[Math.floor(Math.random() * CAT_EMOJIS.length)];

  // ディストラクター(他の動物)でグリッドを埋める
  var cells = [];
  for (var i = 0; i < totalCells - 1; i++) {
    cells.push(allDistractors[Math.floor(Math.random() * allDistractors.length)]);
  }

  // ターゲットをランダムな位置に挿入
  targetIndex = Math.floor(Math.random() * totalCells);
  cells.splice(targetIndex, 0, targetCat.emoji);

  // CSSグリッドの列数を動的に設定
  gridEl.style.gridTemplateColumns = 'repeat(' + config.cols + ', 1fr)';

  // セルを1つずつ作成してグリッドに追加
  for (var i = 0; i < cells.length; i++) {
    var cell = document.createElement('div');
    cell.className = 'animal-cell';
    cell.textContent = cells[i];
    cell.setAttribute('data-emoji', cells[i]);
    gridEl.appendChild(cell);
  }
}

cells.splice(targetIndex, 0, targetCat.emoji)がポイントです。spliceの第2引数を0にすると「削除せずに挿入」になります。これでターゲットをランダムな位置に差し込めます。gridTemplateColumnsを動的に変えることでラウンドに応じたサイズのグリッドが簡単に作れます。

ステップ2: クリック判定とペナルティ処理

グリッド全体にイベントリスナーを1つだけ設定し、クリックされたセルをe.targetで取得します。絵文字をdata-emoji属性に持たせることで、クリック判定が簡単になります。

var PENALTY_SECONDS = 3; // 間違いクリックのペナルティ秒数

function onCellClick(e) {
  if (!gameActive) return;

  var cell = e.target;
  if (!cell.classList.contains('animal-cell')) return;

  var clickedEmoji = cell.getAttribute('data-emoji');

  if (clickedEmoji === targetCat.emoji) {
    // 正解!タイマーを止めてスコアを計算
    gameActive = false;
    stopTimer();
    cell.classList.add('found');

    var roundScore = calculateRoundScore(elapsedMs, currentRound);
    totalScore += roundScore;
    scoreEl.textContent = totalScore;

    // 次のラウンドへ
    setTimeout(function () {
      if (currentRound < TOTAL_ROUNDS - 1) {
        roundClearOverlay.classList.add('active');
      } else {
        handleGameComplete();
      }
    }, 500);

  } else {
    // 不正解!ペナルティとして経過時間を増やす
    cell.classList.add('wrong');
    elapsedMs += PENALTY_SECONDS * 1000;

    // ペナルティ通知を一時表示
    penaltyNoticeEl.classList.add('show');
    setTimeout(function () {
      penaltyNoticeEl.classList.remove('show');
    }, 1200);
  }
}

// グリッド全体にリスナーを1つ設定(イベント委譲)
gridEl.addEventListener('click', onCellClick);

「イベント委譲(Event Delegation)」というテクニックです。168個のセルそれぞれにリスナーを付けるのではなく、親要素のgridElに1つだけ設定します。e.targetでクリックされた子要素を取得できるので、大量のセルがあっても効率的に処理できます。

スコア計算:速さとラウンド番号でボーナスが変わる

// 素早く見つけるほど高得点。後半ラウンドほど倍率が上がる
function calculateRoundScore(timeMs, round) {
  var seconds = timeMs / 1000;
  var baseScore = Math.max(0, Math.floor(1000 - seconds * 20));
  var multiplier = 1 + round * 0.5;
  return Math.floor(baseScore * multiplier);
}

1秒あたり20点減少するベーススコアに、ラウンド番号に応じた倍率をかけています。ラウンド5では倍率が3倍になるので、後半ほどスコアの稼ぎどころになります。

ステップ3: タイマー計測とゲーム進行管理

このゲームはカウントダウンではなく、経過時間を計測する「ストップウォッチ型」のタイマーです。setIntervalで100ミリ秒ごとに表示を更新します。

var elapsedMs = 0;
var timerInterval = null;

function startTimer() {
  var startTime = Date.now() - elapsedMs;
  timerInterval = setInterval(function () {
    elapsedMs = Date.now() - startTime;
    timerEl.textContent = formatTimeShort(elapsedMs);
  }, 100);
}

function stopTimer() {
  if (timerInterval) {
    clearInterval(timerInterval);
    timerInterval = null;
  }
}

// ミリ秒を「分:秒」形式に変換
function formatTime(ms) {
  var totalSeconds = Math.floor(ms / 1000);
  var m = Math.floor(totalSeconds / 60);
  var s = totalSeconds % 60;
  var cs = Math.floor((ms % 1000) / 10);
  return m + ':' + (s < 10 ? '0' : '') + s + '.' + (cs < 10 ? '0' : '') + cs;
}

Date.now()は現在時刻をミリ秒で返します。startTime = Date.now() - elapsedMsとすることで、ペナルティで経過時間を増やしても正しく計測を続けられます。Math.floorと余りの計算で分・秒・センチ秒に分解しています。

ベストスコアの保存

function saveBestScore(score) {
  var current = getBestScore();
  if (current === null || score > current) {
    localStorage.setItem('bestFindTheCat', String(score));
    return true; // 新記録!
  }
  return false;
}

function getBestScore() {
  var val = localStorage.getItem('bestFindTheCat');
  if (val !== null) {
    var parsed = parseInt(val, 10);
    if (!isNaN(parsed)) return parsed;
  }
  return null; // まだ記録なし
}

localStorage.setItemでキーと値を保存し、getItemで取り出します。数値を保存するとき文字列に変換されるため、読み込み時はparseIntで数値に戻す必要があります。isNaNで変換失敗をチェックするのが安全なパターンです。

まとめ — 次のステップ

おつかれさまでした!ここまでで猫をさがせ!の基本が完成しました。イベント委譲で効率的なクリック判定を実装し、ペナルティで経過時間を操作するユニークな仕組みが作れました。さらに発展させるなら、ラウンドごとに猫の種類を増やしたり、見つけた猫をハイライトするアニメーションをつけたり、モバイル向けタッチ操作に対応させてみましょう。

よくある質問

Q: セルが多いのにパフォーマンスは大丈夫ですか?

A: はい、イベント委譲を使っているので問題ありません。168個のセルに個別のリスナーを付けると重くなりますが、親要素に1つだけ設定する方法なら効率的に動作します。絵文字はテキストデータなので、画像と違って読み込みも高速です。

Q: どのくらい時間がかかりますか?

A: 基本部分は約30分〜1時間で作れます。グリッドサイズや猫の種類を変えるだけでオリジナルのゲームになるので、ぜひいろいろ試してみてください。