マガジン

JavaScriptのクラスでゲームオブジェクトを整理する

遊びをつくる

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

既存の木製ビー玉コースを保ちながら、一つの青い橋を慎重に足す手元
記事のテーマをもとに制作したAI生成イラスト。

ゲームのコードが数百行を超えたとき、「どこに何が書いてあるかわからない」という状態に陥りやすいです。プレイヤーの座標、敵のHP、弾の速度——これらをバラバラな変数で管理し始めると、機能を追加するたびにバグが増え、修正箇所を探すだけで時間を消費します。 JavaScriptのクラス(class構文)は、こうした散らかりを整理するための仕組みです。「あるオブジェクトが持つデータ」と「そのオブジェクトに対する操作」をひとつの塊にまとめることで、コードの読みやすさと修正のしやすさが大きく変わります。この記事では、クラスをゲーム開発に使う際の考え方と、実際にコードを書く手順を具体的に説明します。

この記事の目次
  1. クラスが解決する「ゲームコードの典型的な散らかり方」
  2. ゲームオブジェクトをクラスに分ける判断基準
  3. 基本構造:`constructor`・`update`・`draw` の三点セット
  4. ゲームループとの組み合わせ方
  5. 継承を使う場面と使いすぎの注意点
  6. 失敗しやすい点と対処
  7. 次の行動
  8. 参考資料

クラスが解決する「ゲームコードの典型的な散らかり方」

クラスを使わない段階では、次のようなコードを書きがちです。

js
let playerX = 100;
let playerY = 200;
let playerHP = 3;
let enemyX = 400;
let enemyY = 200;
let enemyHP = 5;

敵が2体になった瞬間に enemy2X、enemy2Y、enemy2HP が増え、10体になれば管理は破綻します。配列で enemies[i].x のように書くとしても、「敵オブジェクトのかたち」をどこかで定義しておかないと、プロパティの名前がゆれたり、更新処理と描画処理が別々の場所に散らばったりします。

クラスはこの「かたち」を定義する場所です。new Enemy() と書くたびに同じ構造を持つオブジェクトが生まれ、enemy.update() で動かし、enemy.draw(ctx) で描く——という一貫した操作が使えます。コードを読む人(未来の自分を含む)は、クラスの定義を見るだけでそのオブジェクトに何ができるかを把握できます。

ゲームオブジェクトをクラスに分ける判断基準

「何をクラスにするか」に迷ったときは、次の問いで判断できます。

  • 同じ種類のものが複数登場するか(敵、弾、コイン、障害物など)
  • 状態(位置・HP・速度など)と操作(移動・衝突判定・描画)がセットになっているか
  • ゲームループの中で毎フレーム同じ操作が繰り返されるか

三つとも「はい」ならクラスにする価値が高いです。逆に、一度しか使わない設定値や、操作を持たない単純なデータはクラスにしなくても構いません。

典型的なクラス候補を挙げると、プレイヤー・敵・弾・ステージ上のアイテム・エフェクトなどです。スコア表示やBGM管理は「ゲームの状態を持つオブジェクト」として1つだけ存在すれば十分なので、クラスにしてもよいですし、単純なオブジェクトリテラルで事足りる場合もあります。

基本構造:`constructor`・`update`・`draw` の三点セット

ゲームオブジェクトのクラスは、多くの場合この三つのメソッドで構成します。

constructor は初期状態を受け取ります。位置・速度・HPなど、生成時に決まる値をここで this.x = x のように設定します。

update は毎フレーム呼ばれ、状態を変化させます。移動・アニメーションのカウント進行・死亡判定などをここに書きます。

draw は毎フレーム呼ばれ、Canvas への描画を担当します。ctx(CanvasRenderingContext2D)を引数で受け取るのが一般的な形です。

js
class Enemy {
  constructor(x, y, speed) {
    this.x = x;
    this.y = y;
    this.speed = speed;
    this.hp = 3;
    this.alive = true;
  }

  update() {
    this.x -= this.speed;
    if (this.x < -50) {
      this.alive = false;
    }
  }

  draw(ctx) {
    if (!this.alive) return;
    ctx.fillStyle = 'red';
    ctx.fillRect(this.x, this.y, 40, 40);
  }

  takeDamage(amount) {
    this.hp -= amount;
    if (this.hp <= 0) {
      this.alive = false;
    }
  }
}

alive フラグを持たせておくと、ゲームループ側で enemies = enemies.filter(e => e.alive) と書くだけで死亡した敵を除去できます。これが「クラスに状態と操作を閉じ込める」ことの実用的な恩恵です。

ゲームループとの組み合わせ方

クラスを定義しただけでは動きません。ゲームループ(requestAnimationFrame で繰り返される処理)の中で配列を使い、全オブジェクトをまとめて更新・描画します。

js
const enemies = [];

// 敵を生成する例
enemies.push(new Enemy(600, 150, 2));
enemies.push(new Enemy(600, 300, 3));

function gameLoop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  for (const enemy of enemies) {
    enemy.update();
    enemy.draw(ctx);
  }

  // 死亡した敵を除去
  enemies = enemies.filter(e => e.alive);

  requestAnimationFrame(gameLoop);
}

このパターンの利点は、敵が何体になっても gameLoop の中身を変えなくていい点です。敵を追加したいときは enemies.push(new Enemy(...)) するだけで済みます。

衝突判定も同様に整理できます。プレイヤーのクラスに collidesWith(other) メソッドを持たせ、if (player.collidesWith(enemy)) のように呼び出す形にすると、判定ロジックがどこに書いてあるかが明確になります。

継承を使う場面と使いすぎの注意点

JavaScriptのクラスは extends で継承ができます。「動く敵」と「止まって撃ってくる砲台」が共通のプロパティ(位置・HP・描画の枠組み)を持つなら、GameObject のような基底クラスを作り、それを継承するのが有効な選択肢です。

js
class GameObject {
  constructor(x, y) {
    this.x = x;
    this.y = y;
    this.alive = true;
  }
  update() {}
  draw(ctx) {}
}

class Walker extends GameObject {
  constructor(x, y, speed) {
    super(x, y);
    this.speed = speed;
  }
  update() {
    this.x -= this.speed;
    if (this.x < -50) this.alive = false;
  }
  draw(ctx) {
    ctx.fillStyle = 'orange';
    ctx.fillRect(this.x, this.y, 40, 40);
  }
}

ただし、継承は深くなるほど「このメソッドはどのクラスで定義されているか」を追うコストが増します。2段階(基底クラス → 具体クラス)で済む規模なら継承は有効ですが、それ以上複雑になるようなら、継承より「コンポジション(別のオブジェクトを this.mover = new Mover() のように内包する)」を選ぶ方が見通しが良くなることが多いです。小〜中規模のブラウザゲームでは継承1段階で十分なケースがほとんどです。

失敗しやすい点と対処

this の文脈を失う問題:setTimeout や addEventListener のコールバックの中で this を使うと、意図したオブジェクトを指さないことがあります。クラスのメソッドをコールバックに渡す際は this.method.bind(this) か、アロー関数でラップするのが確実です。

クラスが大きくなりすぎる問題:一つのクラスに400行書いてしまうと、クラスを使う前と同じ問題が再現します。「プレイヤーの移動」「プレイヤーのアニメーション」「プレイヤーの衝突判定」がすべて一つのクラスに入っているなら、それぞれを別のクラスや関数に切り出し、Player クラスから呼び出す形に整理できます。

const で宣言した配列への再代入:上の例では enemies = enemies.filter(...) と書きましたが、const enemies と宣言していると再代入でエラーになります。let で宣言するか、enemies.length = 0; enemies.push(...filtered) のようにインプレースで更新する書き方を使ってください。

次の行動

まず手元にある最も長いゲームファイルを開き、同じプロパティ群(例:enemyX, enemyY, enemyHP)が繰り返し登場する箇所を探してみてください。そこが最初にクラス化すべき候補です。クラスに切り出したら、ゲームループを for...of でまとめて update → draw を呼ぶ形に書き直すと、コード全体の見通しが大きく改善します。整理したゲームはHTMLファイルまたはZIPファイルとしてアソバースへ投稿できます。

参考資料

  • MDN Web Docs「Classes — JavaScript」
  • MDN Web Docs「requestAnimationFrame」