[JavaScript] ゲーム開発の設計:これからの開発に活かす教訓

はじめに

JavaScript + canvas でのゲーム開発を始めて 今日で 24 日が経過。

ゲームとしてはショボいんですが、でも、ライブラリを使わず、自分で全部物理演算処理を書いてるのもあって、かなり苦労してる分だけ、数学の勉強や理解を含めて理解力はあがってます。

今日は、今朝の記事でも書いたホーミングショット的な、放物線を意識した弾道計算処理と、普通の物理法則に当てはめた処理では味気ない弾道になるので、その辺を修正・試行錯誤。

進行方向にあわせて画像を回転させるという凝った処理もしてたり、物理判定処理を、円軌道で処理する等、個人的には盛りだくさん内容でした。

ただ、その反面、これまでの実装見て分かる通り、ゲーム内で行う処理が物凄く多機能化・複雑化してしまい、それに合わせて、責務の分散や、状態管理処理をしっかりと行ってきたので、まだまだ拡張はできる反面、もっと初期の頃から設計方法を考えておけば、もっとわかりやすく見やすいコードになったのではないかと、今日実装を終わってリファクタしつつ考えたりしました。

ここまでくると、中・大規模開発レベルに近くなってきてる気はします。

それを踏まえて、ゲームの中で、マナ・ボールの実装を追えて、家の仕事も終わった後、リファクタリングしつつ、この事についてAIと議論した内容を、ざっと記事にしてみます。

例によって、最近の記事の動向として、「未来の自分の為の記事」です。

ゲーム開発の設計:これからの開発に活かす教訓

ゲーム開発において、最初にしっかりとした設計を行うことは非常に重要です。

特に、ゲームが進化する過程で複雑性が増し、機能が多岐にわたる場合、しっかりとした設計がなければ管理が困難になり、コードが膨れ上がってしまいます。今回の開発で得た教訓を基に、次回のゲーム開発を効率的に進めるための設計方法について考えてみましょう。

1. 最初からモジュール化を意識する

ゲームの規模が大きくなるにつれて、コードが複雑になりがちです。そこで重要なのは、モジュール化です。各機能やオブジェクト(例えば、ボール、パドル、アイテム、エフェクト)は、できるだけ独立したモジュールとして管理しましょう。

モジュール化の利点

  • 再利用性: 一度作成した機能は、他の部分でも簡単に再利用できます。
  • 保守性: 問題が発生した際、問題の箇所が明確になるため修正が容易です。
  • 拡張性: 新しい機能を追加する際にも、既存のコードに影響を与えることなく拡張ができます。

実際の例

例えば、ボールを管理するクラスを作成します。Ballクラスは、ボールの位置や速度、衝突判定などを管理します。

class Ball {
  constructor() {
    this.x = 0;
    this.y = 0;
    this.speedX = 5;
    this.speedY = 5;
    this.radius = 10;
  }

  update() {
    this.x += this.speedX;
    this.y += this.speedY;
    // 衝突判定などの処理
  }

  render(ctx) {
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
    ctx.fillStyle = '#ff0000';
    ctx.fill();
    ctx.closePath();
  }
}

2. 状態管理をクラスやオブジェクトで整理

ゲーム開発では、進行状況や各要素(スコア、残機、レベルなど)の状態を管理することが非常に重要です。しかし、ゲームが大きくなるにつれて、グローバル変数での管理は次第に複雑になり、管理が追い付かなくなります。そこで、状態管理をクラスやオブジェクトで整理することが、ゲームの構造を整理し、開発を効率的に進めるために非常に重要です。

状態管理のクラスの重要性

ゲームにおける状態とは、スコア、ライフ、レベル、進行状況など、ゲームの進行に関連する重要なデータを指します。これらのデータがグローバル変数で管理されていると、コードが膨れ上がるだけでなく、どこでどのように変更されたのか追跡するのも難しくなります。

状態管理をクラスやオブジェクトで整理することの利点は以下の通りです。

  • 一元管理: ゲームの進行状況を一か所で管理できるため、状態がどこで変更されたのかを追跡しやすくなります。
  • 可読性: 状態がクラスにまとめられることで、コードが整理され、可読性が向上します。
  • 変更の容易さ: ゲームの状態を更新する場合、GameStateクラスを操作するだけで、複数の場所にある状態を一括で更新できます。

ゲーム全体の状態管理

ゲームの進行状況を管理するクラスとして、GameStateというクラスを作成し、ゲームの状態を一元的に管理しましょう。これにより、ゲームの進行状況やイベントに応じて、状態を簡単に更新することができます。

例: GameState クラス

class GameState {
  constructor() {
    this.score = 0;          // 現在のスコア
    this.lives = 3;          // プレイヤーの残機数
    this.level = 1;          // 現在のレベル
    this.isGameOver = false; // ゲームオーバー状態
    this.isPaused = false;   // ゲーム一時停止状態
  }

  // ゲーム状態をリセットする
  reset() {
    this.score = 0;
    this.lives = 3;
    this.level = 1;
    this.isGameOver = false;
    this.isPaused = false;
  }

  // ゲーム状態の更新
  update() {
    // 例えば、スコアが増加する、ライフが減る、レベルが進行するなど
    if (this.lives <= 0) {
      this.isGameOver = true; // 残機がなくなったらゲームオーバー
    }

    // ゲーム進行に応じてレベルアップなど
    if (this.score >= 1000) {
      this.level++;
      this.score = 0; // スコアをリセットして次のレベルに進む
    }
  }

  // ゲーム状態を描画する
  render(ctx) {
    ctx.font = '20px Arial';
    ctx.fillStyle = '#fff';
    ctx.fillText(`Score: ${this.score}`, 10, 30);
    ctx.fillText(`Lives: ${this.lives}`, canvas.width - 100, 30);
    ctx.fillText(`Level: ${this.level}`, canvas.width / 2 - 50, 30);

    // ゲームオーバー表示
    if (this.isGameOver) {
      ctx.fillText('GAME OVER', canvas.width / 2 - 80, canvas.height / 2);
    }

    // 一時停止中の場合
    if (this.isPaused) {
      ctx.fillText('PAUSED', canvas.width / 2 - 50, canvas.height / 2);
    }
  }
}

GameState クラスの管理ポイント

  • 状態の一元管理: ゲームのスコア、ライフ、レベル、ゲームオーバー状態などを一か所で管理することで、ゲームの進行が把握しやすくなります。
  • リセット機能: ゲームが終了したり、ステージが変わったりしたときに、簡単に初期化(リセット)できるようにします。
  • 状態更新: ゲームの進行に応じて状態を更新するメソッドを用意し、ゲーム内の条件に基づいて状態を変更します。

状態管理を行うことで得られるメリット

  1. コードの整理: ゲームの進行状況や状態変数を一箇所で管理することで、コード全体が整理され、直感的に理解しやすくなります。

  2. バグの特定が容易: 状態を管理する場所が明確になるため、状態の不整合が発生した場合でも、どのクラスのどのメソッドが原因かを特定しやすくなります。

  3. テストの効率化: ゲームの状態が独立したクラスで管理されていれば、状態ごとのテストがしやすく、ユニットテストなども導入しやすくなります。

例: ゲーム状態をリセットする

例えば、ゲームが終了したときやレベルが進行したときに、ゲーム状態をリセットする機能を作成することで、ゲームの進行をよりスムーズに管理できます。

function restartGame() {
  gameState.reset(); // ゲームの状態をリセット
  // ゲームを初期状態から再スタート
  ball.reset();
  paddle.reset();
  // 他の初期化処理
}

3. イベント駆動型アーキテクチャ

ゲーム開発において、ユーザー操作やゲーム内での出来事に即座に反応することは非常に重要です。例えば、ユーザーがボタンを押したり、アイテムを取ったりした際に、そのイベントに基づいてゲームの状態を変更する必要があります。ここで強力な手法となるのが、イベント駆動型アーキテクチャです。

イベント駆動型アーキテクチャを採用することで、ゲーム内で発生するさまざまなイベントに対して、即座に適切な反応を返すことができます。これにより、コードがシンプルで直感的になり、管理が容易になります。特にJavaScriptのイベントリスナーは、ユーザー操作やゲーム内の状況を処理する際に非常に便利です。

イベント駆動型アーキテクチャのメリット

  • コードの整理: イベントごとに適切な処理を定義することで、コードが整理され、管理しやすくなります。
  • リアクティブな挙動: ユーザーのアクションやゲーム内のイベントに対して即座に反応できるため、ゲームの動きが自然でスムーズになります。
  • 非同期処理の簡素化: イベント駆動型の仕組みは非同期処理と相性が良く、複雑なゲームロジックを簡単に組み合わせることができます。

イベントリスナーを活用する

JavaScriptのaddEventListenerを活用することで、特定のユーザーアクション(キーボード入力、マウスクリックなど)に反応して、ゲーム内での状態変更や動作を行うことができます。例えば、keydownイベントでキー入力を処理する方法は以下のようになります。

document.addEventListener('keydown', (event) => {
  if (event.key === 'ArrowRight') {
    // 右キーが押されたときの処理
    movePlayerRight();  // プレイヤーを右に動かす
  } else if (event.key === 'ArrowLeft') {
    // 左キーが押されたときの処理
    movePlayerLeft();   // プレイヤーを左に動かす
  } else if (event.key === 'Space') {
    // スペースキーが押されたときの処理
    shootProjectile();  // 弾を発射する
  }
});

このように、keydownイベントをリスンして、ユーザーがキーを押した際に対応する処理を呼び出すことができます。この例では、プレイヤーを左右に移動させたり、弾を発射する処理を行っています。

イベント駆動型のゲームロジック

ゲームにおける重要な要素は、プレイヤーや敵キャラクター、アイテム、エフェクトなど、多くのオブジェクトが相互に作用し合うことです。イベント駆動型アーキテクチャを使用することで、ゲーム内で発生するさまざまなイベントを効率的に処理できます。

例: アイテムを取るイベント

プレイヤーがアイテムを取ったときに、そのアイテムを消失させ、プレイヤーにボーナスを与えるといった処理もイベント駆動で行えます。

// アイテムを取ったときのイベント
document.addEventListener('itemCollected', (event) => {
  const item = event.detail;  // アイテム情報を取得
  increasePlayerScore(item.value);  // アイテムのポイントでスコアを増加
  updateItemDisplay(item);  // アイテムの表示を更新
});

このコードでは、itemCollectedというカスタムイベントを使用し、アイテムを取った際にスコアを増加させたり、アイテムの表示を更新しています。カスタムイベントを使うことで、ゲームの進行に応じたさまざまなアクションを簡単に管理できます。

例: ゲームオーバーイベント

ゲームオーバーのタイミングで、UIにゲームオーバー画面を表示する処理をイベント駆動で行うことができます。

// ゲームオーバーのイベント
document.addEventListener('gameOver', () => {
  displayGameOverScreen();  // ゲームオーバー画面を表示
  stopGameTimer();  // ゲームタイマーを停止
});

このように、イベントが発生したときに、関連する処理をまとめて行うことができます。gameOver イベントが発火すると、ゲームオーバー画面を表示し、ゲームタイマーを停止する処理が実行されます。

イベントの発火

ゲーム内で発生したイベントは、dispatchEventを使って発火することができます。例えば、プレイヤーが特定のアイテムを取ったときに、アイテム取得イベントを発火させます。

// アイテム取得時にイベントを発火
function collectItem(item) {
  const event = new CustomEvent('itemCollected', { detail: item });
  document.dispatchEvent(event);  // アイテム取得イベントを発火
}

イベント駆動型アーキテクチャの利点

  1. シンプルな構造: イベントが発生したタイミングでその都度適切な処理を行うため、コードが整理され、直感的に理解しやすくなります。
  2. 非同期的な反応: ユーザーのアクションやゲーム内のイベントに対して即時に反応できるため、ゲームの流れをスムーズに管理できます。
  3. 状態管理の分離: イベントごとに処理を分けることで、状態管理が分散され、モジュール化が進みます。例えば、キー入力の処理、アイテム取得の処理、ゲームオーバーの処理などがそれぞれ独立して管理されます。

4. 初期化処理の整理

ゲーム開発において、初期化処理は非常に重要な部分ですが、処理が増えてくると次第に複雑になり、管理が大変になります。特にゲームの規模が大きくなると、初期化すべき項目が増えてくるため、これを効率的に整理する必要があります。リファクタリングを行い、初期化処理をモジュールごとに分けることで、コードの可読性や保守性が向上します。

初期化処理の重要性

ゲームがスタートする前に、ゲームの状態やオブジェクトの初期化を適切に行うことが非常に重要です。ゲームの進行に必要なオブジェクト(プレイヤー、ボール、アイテム、エフェクトなど)を全て初期化し、状態を整えることで、ゲームの流れがスムーズに進行します。

しかし、初期化処理が増えてくると、どの部分が何を初期化しているのかが不明確になり、バグが発生しやすくなります。そのため、初期化処理をモジュールごとに分け、最終的に一元的に初期化できるメソッドを作成することが効果的です。

初期化処理をモジュールごとに分ける

ゲーム全体の初期化処理を1つのメソッドで管理すると、コードが長くなり、管理が難しくなります。そこで、各オブジェクト(ボール、パドル、ゲーム状態など)の初期化処理をそれぞれのクラス内に分け、最終的に全体を初期化するメソッドを作成する方法を採用します。

例: Game クラスにおける初期化処理

以下のコード例では、Gameクラスの中で、各オブジェクト(ゲームの状態、パドル、ボール、アイテムなど)を初期化し、それをまとめて初期化するinitializeメソッドを作成しています。

class GameState {
  constructor() {
    this.score = 0;
    this.lives = 3;
    this.level = 1;
    this.isGameOver = false;
  }

  reset() {
    this.score = 0;
    this.lives = 3;
    this.level = 1;
    this.isGameOver = false;
  }

  update() {
    // ゲーム状態の更新(スコアやライフの管理など)
  }

  render() {
    // ゲーム状態を描画
  }
}

class Paddle {
  constructor() {
    this.width = 120;
    this.height = 20;
    this.x = 0;
    this.y = 0;
  }

  reset() {
    this.width = 120;
    this.height = 20;
    this.x = 0;
    this.y = canvas.height - this.height;
  }

  update() {
    // パドルの更新(移動や当たり判定など)
  }

  render(ctx) {
    ctx.fillStyle = '#00f';
    ctx.fillRect(this.x, this.y, this.width, this.height);
  }
}

class Ball {
  constructor() {
    this.x = 0;
    this.y = 0;
    this.speedX = 5;
    this.speedY = 5;
    this.radius = 10;
  }

  reset() {
    this.x = canvas.width / 2;
    this.y = canvas.height / 2;
    this.speedX = 5;
    this.speedY = 5;
  }

  update() {
    this.x += this.speedX;
    this.y += this.speedY;
    // 衝突判定や画面端判定など
  }

  render(ctx) {
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
    ctx.fillStyle = '#ff0000';
    ctx.fill();
    ctx.closePath();
  }
}

class Items {
  constructor() {
    this.list = [];
  }

  reset() {
    this.list = [];
  }

  update() {
    this.list.forEach(item => item.update());
  }

  render(ctx) {
    this.list.forEach(item => item.render(ctx));
  }
}

class Game {
  constructor() {
    this.gameState = new GameState();
    this.paddle = new Paddle();
    this.ball = new Ball();
    this.items = new Items();
  }

  // ゲームの初期化
  initialize() {
    this.gameState.reset();    // ゲーム状態のリセット
    this.paddle.reset();       // パドルのリセット
    this.ball.reset();         // ボールのリセット
    this.items.reset();        // アイテムのリセット
  }

  // ゲームの更新
  update() {
    this.gameState.update();
    this.paddle.update();
    this.ball.update();
    this.items.update();
  }

  // ゲームの描画
  render(ctx) {
    this.gameState.render(ctx);
    this.paddle.render(ctx);
    this.ball.render(ctx);
    this.items.render(ctx);
  }
}

このように整理することで得られるメリット

  1. クラスごとに責務が分かれる: 各クラスが自分の担当する処理(状態管理、パドルの動き、ボールの動き、アイテム管理など)を担当するため、コードが整理され、何がどこで管理されているかが明確になります。

  2. 初期化処理がシンプル: 各クラスで初期化の処理を行った後、Gameクラスでそれらを一元的に初期化するため、ゲーム全体の初期化が簡単に行えます。initializeメソッドを呼び出すだけで、ゲームの初期化処理が完了します。

  3. 拡張性が向上: 新しい機能やオブジェクトを追加する際、既存のコードに影響を与えることなく新しいクラスを追加し、initializeメソッドで管理することができます。これにより、ゲームが進化してもコードが汚れず、管理がしやすくなります。

  4. テストの効率化: 各クラスが独立して管理されているため、単体テスト(ユニットテスト)を行いやすくなります。特定の機能やオブジェクトの動作が独立しているため、問題が発生した際に、原因を特定しやすくなります。

5. エラー処理とデバッグ

ゲーム開発において、デバッグやエラー処理は非常に重要です。ゲームが進行する中で、予期しないエラーやバグが発生するのは避けられませんが、それを効率的に発見し解決するための体制を整えることは、ゲームの品質を高く保つためには欠かせません。初期設計段階からエラーハンドリングやロギングを考慮することで、開発中に発生した問題を迅速に解決できるようになります。

エラー処理とデバッグの重要性

ゲームが複雑になると、エラーや不具合の発見が難しくなります。特に、ゲームの進行中に問題が発生すると、どの部分でエラーが起きているのか特定するのが難しくなることがあります。そのため、エラーハンドリングやデバッグの仕組みを最初から設計に組み込むことが重要です。これにより、エラーが発生した際に迅速に問題を特定し、修正することが可能になります。

エラーハンドリングの基本

エラーハンドリングを行うには、JavaScriptのtry-catch文を活用する方法があります。これにより、エラーが発生した場合に適切に処理し、ユーザーにエラーメッセージを表示したり、エラーログを残すことができます。

例: 基本的なエラーハンドリング

try {
  // エラーが発生する可能性のあるコード
  let result = someFunctionThatMayFail();
} catch (error) {
  // エラーが発生した場合に呼ばれる
  console.error("エラーが発生しました:", error.message);
}

上記のコードでは、someFunctionThatMayFailが失敗した場合、catchブロックが実行され、エラーメッセージがコンソールに表示されます。このように、エラーハンドリングを行うことで、ゲームがクラッシュすることなくエラーに対処できます。

ロギングとデバッグ

エラーが発生した際、ログを残すことも非常に重要です。特に、開発中には詳細なログを出力しておくことで、どの部分で問題が発生しているのかを追跡するのが容易になります。ログの出力をconsole.logconsole.errorを使って行い、エラーの発生源や詳細な情報を表示させることができます。

例: ロギング用のクラス

開発中に頻繁に使用するデバッグログやエラーメッセージの管理をするために、Loggerクラスを作成することも一つの手です。これにより、ログレベルを指定して情報を分けて出力することができます。

class Logger {
  static log(type, message) {
    if (type === 'debug') {
      console.debug(message); // デバッグメッセージ
    } else if (type === 'error') {
      console.error(message); // エラーメッセージ
    } else {
      console.log(message); // 通常メッセージ
    }
  }
}

// 使用例
Logger.log('debug', 'デバッグ情報: ボールの位置を更新しました');
Logger.log('error', 'エラー: 位置の計算に失敗しました');
Logger.log('info', 'ゲームの状態が更新されました');

ログの分類とログレベル

Loggerクラスでは、ログの種類をデバッグ、エラー、情報などで分類することができます。これにより、開発中にどのログがどのレベルで出力されているのかが一目でわかります。特に、デバッグログは開発中に詳細な情報を提供してくれるため、バグを発見した際には非常に役立ちます。

  • debug: 開発中に必要な詳細な情報。処理の流れや状態など、バグの発見をサポートします。
  • error: エラーが発生したときに出力されるメッセージ。エラーメッセージとスタックトレースを出力して問題の発生源を特定します。
  • info: ゲームの状態や処理の進行状況を表示します。ゲームがどの状態にあるのかを簡単に確認できます。

ゲームのバグや不具合のトラブルシューティング

バグが発生したとき、まずはログを見て、どの処理で問題が発生しているのかを特定します。次に、スタックトレース(エラーメッセージに含まれるエラー発生時の関数呼び出し履歴)を確認し、問題の箇所を特定します。さらに、try-catch文を使ってエラー発生箇所を囲み、詳細なエラーメッセージや変数の値をログに出力することで、デバッグを効率化できます。

例: try-catchによる詳細なデバッグ

function updateBallPosition() {
  try {
    // ボールの位置を更新
    ball.x += ball.speedX;
    ball.y += ball.speedY;

    // 例外処理: ボールが画面外に出た場合
    if (ball.x < 0 || ball.x > canvas.width || ball.y < 0 || ball.y > canvas.height) {
      throw new Error('ボールが画面外に出ました');
    }
  } catch (error) {
    Logger.log('error', error.message); // エラーメッセージをログに出力
  }
}

このように、エラーが発生した場合でもゲームがクラッシュせずに、エラーメッセージをログに残して次の処理に進むことができます。

まとめ

ゲーム開発において、初期設計の段階でしっかりとした基盤を作ることは、後々の開発の効率やコードの保守性、拡張性に大きく影響します。これからのゲーム開発を進めるにあたり、モジュール化、状態管理の明確化、イベント駆動型アーキテクチャなどの設計思想を取り入れることで、ゲームの構造をシンプルに保ちつつ、複雑なロジックを効率的に管理することができます。

今回のポイント

  • モジュール化: ゲームの各要素を独立したモジュールとして管理することで、再利用性や保守性が向上し、拡張が容易になります。
  • 状態管理: ゲーム全体の状態を一元的に管理することで、状態更新やイベントの処理をシンプルに保ち、コードの可読性が向上します。
  • イベント駆動型アーキテクチャ: ユーザー操作やゲーム内のイベントに即座に反応できるシンプルで直感的なコードを作ることができます。
  • 初期化処理の整理: ゲームの初期化処理をモジュールごとに分け、最終的に全体を一元的に初期化することで、コードの可読性と管理のしやすさが向上します。
  • エラー処理とデバッグ: 開発初期からエラー処理やログ機能を組み込んでおくことで、問題発生時に迅速に対処でき、ゲームの品質が向上します。

これまでの開発で得た経験を活かして、次回のプロジェクトでは、さらに効率的で柔軟な開発を進めていきましょう。ゲームの設計と実装は常に進化し続ける分野ですので、これらの設計思想を応用することで、より良いゲームを作成するための土台を築くことができます。