マガジン

ゲームの状態管理入門:タイトル・プレイ中・ゲームオーバーの切り替え

遊びをつくる

執筆にClaudeを使用しています。

週末の明るい机に置かれた、一つの仕組みで遊べる小さな完成済みゲーム。
記事のテーマをもとに制作したAI生成の写真風イメージ。

ブラウザゲームを作り始めると、「タイトル画面からスタートボタンを押したらゲームが始まり、ミスしたらゲームオーバー画面に切り替わる」という流れをどう実装すればいいか迷うことがあります。キャンバスへの描画やキー入力の処理は調べれば出てきますが、「画面の切り替えそのものをどう管理するか」を体系的に解説しているリソースは意外と少ないです。 この記事では、ゲームの状態管理(State Management)という考え方を中心に、タイトル・プレイ中・ゲームオーバーの3画面を切り替える最小限の仕組みを説明します。状態管理とは、「今ゲームはどのフェーズにいるか」を一つの変数で表し、描画・更新・入力処理をすべてそのフェーズに応じて振り分けるアプローチです。シンプルなパズルゲームからアクションゲームまで、規模に関係なく応用できます。

この記事の目次
  1. なぜ「状態」という概念が必要なのか
  2. 3画面を切り替える最小構造
  3. 入力処理を状態に対応させる
  4. 遷移のタイミングと初期化の設計
  5. 画面の表示切り替え:CSSとキャンバスの違い
  6. 状態を増やすときの判断基準
  7. 次の行動

なぜ「状態」という概念が必要なのか

状態管理を使わずに書くと、コードはどうなるでしょうか。たとえばタイトル画面の描画とプレイ中の描画を同じupdate関数に書いてしまい、フラグ変数が増えるにつれて条件分岐が複雑になっていきます。isTitle・isPlaying・isGameOverという3つのboolean変数を使う方法もありますが、どれか一つを変え忘れたまま遷移するバグが起きやすく、フェーズが増えるたびに管理が難しくなります。

状態管理の核心は「今どこにいるかを一つの値で決める」ことです。複数のフラグを同時に管理する必要がなくなり、「タイトルのとき、プレイ中のとき、ゲームオーバーのとき」という分岐がシンプルになります。コードを読む人(将来の自分も含む)が「今どのフェーズか」を即座に把握できるのも大きな利点です。

3画面を切り替える最小構造

状態を表すには文字列か数値の定数を使うのが一般的です。JavaScriptであれば次のように書くと分かりやすくなります。

js
const STATE = {
  TITLE: 'title',
  PLAYING: 'playing',
  GAMEOVER: 'gameover',
};

let currentState = STATE.TITLE;

currentStateという一つの変数が「今どのフェーズか」をすべて表します。遷移させるときはこの変数を書き換えるだけです。

js
// スタートボタンが押されたとき
currentState = STATE.PLAYING;

// ミスが発生したとき
currentState = STATE.GAMEOVER;

// リトライボタンが押されたとき
currentState = STATE.TITLE;

メインループ(requestAnimationFrameで毎フレーム呼ばれる関数)の中では、currentStateを見て処理を振り分けます。

js
function gameLoop() {
  requestAnimationFrame(gameLoop);

  switch (currentState) {
    case STATE.TITLE:
      updateTitle();
      drawTitle();
      break;
    case STATE.PLAYING:
      updateGame();
      drawGame();
      break;
    case STATE.GAMEOVER:
      updateGameOver();
      drawGameOver();
      break;
  }
}

この構造では、updateGame()やdrawGame()はプレイ中にしか呼ばれません。タイトル画面でゲームのロジックが誤作動するという問題が構造的に起きなくなります。

入力処理を状態に対応させる

描画と更新だけでなく、キー入力やマウス・タップの処理も状態ごとに分ける必要があります。よくある失敗は、keydownイベントのリスナーを一つだけ書いて、その中で「今どのフェーズか」を判定しないまま処理を実行してしまうことです。

js
document.addEventListener('keydown', (e) => {
  if (currentState === STATE.TITLE) {
    if (e.key === 'Enter') currentState = STATE.PLAYING;
  } else if (currentState === STATE.PLAYING) {
    handlePlayInput(e);
  } else if (currentState === STATE.GAMEOVER) {
    if (e.key === 'Enter') currentState = STATE.TITLE;
  }
});

イベントリスナーは最初に一度だけ登録し、その中でcurrentStateを参照する形にすると管理がしやすくなります。フェーズが変わるたびにリスナーを付け替える方法もありますが、付け忘れや二重登録のリスクがあるため、慣れないうちは一つのリスナーで振り分ける方が安全です。

タッチ操作(スマートフォン対応)の場合も同じ考え方が使えます。touchstartやclickイベントのコールバック内でcurrentStateを見て処理を切り分けるだけです。

遷移のタイミングと初期化の設計

状態を切り替えるときに「前の状態をリセットする処理」が必要なことがあります。たとえばゲームオーバーからタイトルに戻るとき、スコアや自機の位置・残機数を初期値に戻さないと、次のプレイに前回の結果が残ったままになります。

この初期化処理をどこに書くかは二つの考え方があります。

方法A:遷移先の状態に入ったタイミングで初期化する

js
function enterState(newState) {
  currentState = newState;
  if (newState === STATE.PLAYING) {
    initGame(); // スコアや位置をリセット
  }
}

currentState = STATE.PLAYINGと直接書く代わりにenterState(STATE.PLAYING)を経由させると、遷移のたびに必ず初期化が走ります。

方法B:遷移元を離れるときに後始末する

js
function leaveState(oldState) {
  if (oldState === STATE.PLAYING) {
    clearGameTimers(); // タイマーやアニメーションを止める
  }
}

どちらが正解という話ではなく、「スコアのリセット」のように次の状態に関係するものは方法A、「タイマーの停止」のように今の状態の後始末は方法Bが自然に対応します。規模が大きくなるにつれてenterStateとleaveStateを両方持つ設計が使われることが増えます。

画面の表示切り替え:CSSとキャンバスの違い

キャンバス(<canvas>)一枚で全画面を描く場合は、drawTitle()・drawGame()がそれぞれ毎フレームキャンバス全体を塗り直すため、特別な操作なく画面が切り替わります。

一方、タイトル画面をHTMLのDOM要素(<div>)で作り、ゲーム本体をキャンバスで描くという混在構成はよく見かけます。この場合は状態の切り替えと同時にDOM要素の表示・非表示を操作する必要があります。

js
function enterState(newState) {
  // まず全セクションを隠す
  document.getElementById('title-screen').style.display = 'none';
  document.getElementById('gameover-screen').style.display = 'none';
  document.getElementById('game-canvas').style.display = 'none';

  // 新しい状態に対応するものだけ表示
  if (newState === STATE.TITLE) {
    document.getElementById('title-screen').style.display = 'flex';
  } else if (newState === STATE.PLAYING) {
    document.getElementById('game-canvas').style.display = 'block';
    initGame();
  } else if (newState === STATE.GAMEOVER) {
    document.getElementById('gameover-screen').style.display = 'flex';
  }

  currentState = newState;
}

この構成の利点は、タイトル画面やゲームオーバー画面をCSSで柔軟にデザインできることです。ただしキャンバスとDOMが混在すると重なり順(z-index)やスケーリングに注意が必要になります。どちらを選ぶかはプロジェクト全体のHTML構成に依存するため、最初に方針を決めておくと途中の書き直しが減ります。

状態を増やすときの判断基準

ゲームが育ってくると「一時停止(ポーズ)」「ステージクリア」「設定画面」などの状態を追加したくなります。このとき判断の目安にしたいのは、「その状態のとき、更新・描画・入力処理が他の状態と明確に異なるか」という点です。

たとえばポーズ画面は、updateGame()を止めてdrawGame()は維持しつつポーズUI を重ねて表示するという特殊な挙動をします。これは他の状態と異なるため、STATE.PAUSEDとして独立させる価値があります。一方、「残機が0になる直前の短いアニメーション」程度であれば、STATE.PLAYINGの中でフラグ一つで制御する方がシンプルなことが多いです。

状態の数は少ない方が管理しやすいですが、「一つの状態に複数の振る舞いを詰め込みすぎてフラグが増える」ときは分割のサインです。

次の行動

この記事の構造を手元のコードに当てはめるには、まずcurrentState変数とSTATEオブジェクトを追加し、既存のループをswitch文で振り分けるところから始めるのが最短です。タイトル・プレイ・ゲームオーバーの3状態が動くようになったら、遷移のたびに初期化が走るenterState関数を導入して安定させてください。完成したゲームはHTMLファイルまたはZIPにまとめてアソバースに投稿できます。