マガジン

2Dシューティングの作り方:弾幕とオブジェクト管理

遊びをつくる

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

一つのボールと短い足場だけで作られた小さなゲーム模型。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

2Dシューティングゲームを作ろうとして最初につまずくのは、「弾が増えるとゲームが重くなる」「弾幕のパターンをどう組めばいいかわからない」という二点です。この記事ではその両方を、実装の考え方と手順に沿って解説します。 シューティングゲームの技術的な核心は、大量のオブジェクト(弾・敵・エフェクト)をいかに効率よく生成・管理・破棄するかにあります。パターンの見栄えは後から調整できますが、管理の仕組みを後から直すのは大きなリファクタリングを伴います。順番として、まず管理の仕組みを固め、次にパターンを組み立てるのが近道です。

この記事の目次
  1. オブジェクトプールで生成コストを抑える
  2. 弾幕パターンの設計:角度と時間の組み合わせ
  3. 当たり判定をシンプルに保つ
  4. 敵の行動とパターンの切り替え
  5. よくある失敗と回避策
  6. 次の行動
  7. 参考資料

オブジェクトプールで生成コストを抑える

弾を撃つたびに new でオブジェクトを生成し、画面外に出たら削除する実装は、弾数が少ないうちは問題になりません。しかし弾幕系のゲームでは画面上に数百発が同時に存在することも珍しくなく、生成・破棄の繰り返しがガベージコレクションの負荷を生み、処理落ちの原因になります。

これを避けるのがオブジェクトプールという手法です。考え方は単純で、「使い終わったオブジェクトを捨てずに非アクティブ状態で保管し、次に必要になったときに再利用する」というものです。

JavaScript(Canvas)で最小限のプールを書くと次のような構造になります。

class BulletPool {
  constructor(size) {
    this.pool = Array.from({ length: size }, () => ({
      active: false, x: 0, y: 0, vx: 0, vy: 0
    }));
  }
  acquire() {
    return this.pool.find(b => !b.active) ?? null;
  }
  release(bullet) {
    bullet.active = false;
  }
}

acquire() で非アクティブな弾を取り出し、座標と速度をセットして active: true にします。画面外や当たり判定が発生したら release() で戻します。更新ループでは active なオブジェクトだけを処理します。

プールのサイズは「同時に画面上に存在しうる最大数」を想定して設定します。足りなくなった場合に null を返す実装にしておくと、プール不足による誤動作を防ぎやすくなります。プールが常に枯渇するようなら、設計として弾数が多すぎる可能性を疑うのも大切です。

弾幕パターンの設計:角度と時間の組み合わせ

弾幕は複雑に見えますが、ほとんどのパターンは「発射角度」「弾数」「発射間隔」「速度」の四パラメータで記述できます。これらを組み合わせることで、見栄えの異なるパターンが生まれます。

基本の三パターン

全方位(n-way):360度をn等分した角度に同時に発射します。弾数8で45度刻み、弾数12で30度刻みが定番です。コードでは for (let i = 0; i < n; i++) のループで angle = (2 * Math.PI / n) * i を計算します。

扇形(扇状spread):中心角を基準に±指定度の範囲を等分して発射します。プレイヤーを向く方向を中心角にすると、敵の攻撃として自然に機能します。

らせん(spiral):毎フレームまたは一定間隔ごとに発射角度を少しずつ回転させます。angle += rotationSpeed を更新ループ内で加算するだけで実装できます。回転速度と発射間隔の比率を変えると密度と見た目が大きく変わります。

パターンを組み合わせるとき

実際の弾幕は上記の組み合わせです。「らせんで発射しながら、各弾が自分の進行方向にさらにn-wayを展開する」といった二段構えも、acquire() を入れ子で呼ぶことで実現できます。ただし弾が弾を生む設計はプールの枯渇と処理負荷に直結するため、総弾数の上限を決めてから組むのが安全です。

当たり判定をシンプルに保つ

弾とプレイヤーの当たり判定は、ピクセル単位の精度より「小さく感じる」ヒットボックスのほうがプレイ感がよくなります。シューティングゲームではプレイヤーの当たり判定をスプライトより一回り小さい円(半径3〜6px程度)にするのが一般的なアプローチです。

毎フレーム全弾とプレイヤーの距離を計算する総当たりは、弾が多いと重くなります。軽量化の優先度は次の順で検討します。

  1. 非アクティブな弾を処理から除外する(プールを使っていれば自然に達成できます)
  2. 空間分割を導入する:画面を格子状に分割し、プレイヤーと同じセルにある弾だけ判定する方法です。弾幕ゲームの規模なら4×4〜8×8程度の粗い分割でも効果があります。
  3. 判定頻度を下げる:60fpsで動いているなら、毎フレームではなく2フレームに1回判定するだけで半分の負荷になります。高速な弾でも2フレーム分の移動距離はヒットボックスより小さいことが多く、見た目上の問題はほぼ出ません。

敵の行動とパターンの切り替え

弾幕ゲームの敵は、単にHPがなくなるまで同じパターンを繰り返すよりも、HPに応じてフェーズが変わる設計のほうが緊張感を作りやすいです。

フェーズ管理は状態機械(ステートマシン)として実装するのが定番です。phase という変数を持ち、switch 文や辞書でその状態に対応する更新関数を呼び分けます。フェーズ遷移の条件はHPの閾値だけでなく、「出現からの経過時間」や「特定の行動を完了した」なども使いやすい条件です。

フェーズが切り替わるタイミングで既存の弾を全クリアするか残すかは、演出上の選択です。全クリアは難易度を下げますが、緊張の切れ目が生まれます。残す場合はプレイヤーが避ける余裕を確保するため、フェーズ変化前に少し間を置くのが親切な設計です。

パターン自体は「パターンデータ」として分離しておくと、調整が楽になります。角度・弾速・発射数をコードに直書きするのではなく、オブジェクトや配列として持っておくと、後から数値だけ変えてバランスを調整できます。

よくある失敗と回避策

弾の座標がずれる:フレームレートが変動すると x += speed のような固定値更新では動きが不安定になります。x += speed * deltaTime のようにデルタタイム(前フレームからの経過ミリ秒)を掛けることで、フレームレートに依存しない動きになります。

プールが足りなくなる:acquire() が null を返したときに弾を発射しないだけでは、パターンが崩れて見えます。デバッグ時にプール枯渇をログで記録しておくと、適切なプールサイズの判断がしやすくなります。

判定が重すぎる:弾の更新とレンダリングを同じループに書いているケースで、まず更新とレンダリングを分離してどちらが重いか確認します。ブラウザの開発者ツールのパフォーマンスタブで計測するのが最短の診断方法です。

パターンが単調に感じる:弾速の変化(発射直後は遅く、時間差で加速する)や、プレイヤーへのホーミング成分を少量混ぜるだけで印象が変わります。ホーミングは弾の進行方向をプレイヤー方向に毎フレーム少しずつ補正することで実装でき、補正量を小さくすれば「若干追ってくる」程度のクセになります。

次の行動

まずオブジェクトプールを実装し、弾100発を同時に動かしても処理が安定することをブラウザの開発者ツールで確認してください。その上でn-wayとらせんの二パターンを組み、フェーズ遷移で切り替えるところまで動かせれば、シューティングゲームとして動く最小構成が手に入ります。完成したゲームはHTMLファイルやZIPの形式でアソバースに投稿して公開できます。

参考資料

  • MDN Web Docs(Canvas API・requestAnimationFrame)
  • HTML5 Game Devs Forum(オブジェクトプールの実装事例)