第3章: 数千体の敵大群(スウォーム)出現
第2章で軽快に動くプレイヤーが完成しました。 第3章では、いよいよ本チュートリアルの真骨頂である**「数千体の敵モンスター(スウォーム)が画面外から一斉に押し寄せる」**システムを構築します!
従来のエンジンでは、500体のスプライトを動かすだけでFPSが激減していました。しかし PlutoEngine v1.2.1 なら、5,000体以上の敵がプレイヤー目掛けて殺到しても、WebGPU バックエンドと InstanceBufferArena による一括バッチ描画により、まったく処理落ちしません。
1. データ指向による敵プール (Enemy Pool) の設計
敵モンスターの管理も、ゼロアロケーションの原則(SoA: Structure of Arrays)に従います。Enemy クラスのインスタンスを数千個配列に push するのではなく、事前確保されたフラットな TypedArray (Float32Array / Int32Array) で敵のステータスを管理します。
// 敵管理用の定数
const MAX_ENEMIES = 10000;
export class SwarmSurvivorScene extends Scene {
// ... プレイヤーのプロパティ ...
// 敵管理用 SoA 配列
private enemyCount = 0;
private readonly enemyIds = new Int32Array(MAX_ENEMIES);
private readonly enemyHp = new Float32Array(MAX_ENEMIES);
private readonly enemySpeed = new Float32Array(MAX_ENEMIES);
// スポーンタイマー (ミリ秒ではなく秒で蓄積)
private spawnTimer = 0;
private readonly spawnInterval = 0.1; // 0.1秒ごとにスポーン2. 画面外の円周上からのスポーン処理
プレイヤーを取り囲むように、画面外の一定半径 $R \approx 600\text{px}$ の円周上に敵をランダムに配置します。
/**
* 敵を一定数スポーンさせる
* @param count 一度に生成する敵の数
*/
private spawnEnemies(count: number): void {
const spawnRadius = 600; // 画面外の円周半径
const px = this.player.x;
const py = this.player.y;
for (let i = 0; i < count; i++) {
if (this.enemyCount >= MAX_ENEMIES) break;
// ランダムな角度 (0 〜 2π)
const angle = Math.random() * Math.PI * 2;
const spawnX = px + Math.cos(angle) * spawnRadius;
const spawnY = py + Math.sin(angle) * spawnRadius;
// InstanceBufferArena から新しいスプライトIDを割り当て (サイズ 18px)
const sprite = this.add.sprite(spawnX, spawnY, 'textureKey').setDisplaySize(18, 18);
// 深紅のモンスターカラー (0xef4444)
sprite.setTint(0xef4444);
// 敵プールの末尾に登録
const idx = this.enemyCount++;
this.enemyIds[idx] = sprite.id;
this.enemyHp[idx] = 20; // HP 20
this.enemySpeed[idx] = 90 + Math.random() * 40; // 速度 90〜130 px/s
}
}3. 数千体の敵を一括追跡移動させる (Swarm Update)
毎フレーム、登録されているすべての敵について、プレイヤーへの方向ベクトルを計算して移動させます。
InstanceBufferArena の型付き配列(this.arena.posX, this.arena.posY)をローカル変数に参照させてループを回すことで、JITコンパイラが最速のポインタ走査コードを生成します。ループ内で new は一切使用しません(ゼロアロケーション)。
/**
* 全ての敵をプレイヤーに向かって前進させる
*/
private updateEnemies(dt: number): void {
const px = this.player.x;
const py = this.player.y;
// InstanceBufferArena のバッファを直接参照
const posX = this.arena.posX;
const posY = this.arena.posY;
const facing = this.arena.facing;
const count = this.enemyCount;
for (let i = 0; i < count; i++) {
const id = this.enemyIds[i];
// プレイヤーへの相対ベクトル
const dx = px - posX[id];
const dy = py - posY[id];
const dist = Math.hypot(dx, dy);
if (dist > 1.0) {
// ベクトルを正規化して移動
const step = (this.enemySpeed[i] * dt) / dist;
posX[id] += dx * step;
posY[id] += dy * step;
// プレイヤーのいる方向に向きを切り替え
facing[id] = dx < 0 ? -1.0 : 1.0;
}
}
}4. update(dt) への統合
update メソッド内でタイマーを進め、大量の敵を連続生成します。
update(dt: number): void {
// 1. プレイヤーの移動
this.updatePlayer(dt);
// 2. 敵のスポーンタイマー
this.spawnTimer += dt;
if (this.spawnTimer >= this.spawnInterval) {
this.spawnTimer -= this.spawnInterval;
// 1回のインターバルで 15 体スポーン (秒間 150 体増加!)
this.spawnEnemies(15);
}
// 3. 敵の移動更新
this.updateEnemies(dt);
}5. 動作確認: 驚愕の 5,000 体同時描画!
ブラウザをリロードしてみましょう! 四方八方から、真っ赤な敵の群れが渦を巻きながらプレイヤーに迫ってきます。
ブラウザの DevTools(F12)の Performance タブを開いてみてください。 敵の数が 1,000、2,000、3,000、5,000体と増え続けても、RenderGraph を介した WebGPU バックエンドの最適化により、フレームレートは 60 FPS または 144 FPS の上限に張り付いたまま、ノコギリ状のGCスパイクが一切現れないことに驚くはずです!
しかし、この状態では敵同士が1点に重なってしまったり、当たり判定を愚直に計算すると $O(N^2)$ の計算爆発が起きてしまいます。 続く第4章では、この大群の衝突判定を最適化する XPBDPlugin (Extended Position Based Dynamics) と、空間を高速検索する MortonPlugin (モートンコードベースの空間ハッシュ) を導入します!