【Cocos Creator 3.8】BoidFlocking の実装:アタッチするだけで「分離・整列・結合」を行う群衆移動AIを実現する汎用スクリプト

このガイドでは、複数の敵キャラクター(または任意のオブジェクト)が、互いに重ならないようにしつつ、群れとしてまとまりを持って移動するための汎用コンポーネント BoidFlocking を実装します。

各ノードにこのスクリプトをアタッチし、パラメータをインスペクタから調整するだけで、分離(Separation)・整列(Alignment)・結合(Cohesion) の 3 つのルールに基づく「Boids」風の群衆移動を実現できます。外部の GameManager やシングルトンには一切依存しない、完全独立型のコンポーネントです。


コンポーネントの設計方針

機能要件の整理

  • シーン上で複数のノードに BoidFlocking をアタッチすると、それらが「群れ」として振る舞う。
  • 各 Boid は以下の 3 つのルールに基づいて移動ベクトルを計算する。
    • 分離 (Separation): 近すぎる仲間から離れる。
    • 整列 (Alignment): 近くの仲間の平均移動方向に合わせる。
    • 結合 (Cohesion): 近くの仲間の中心位置に向かう。
  • 各ルールの影響範囲(半径)重み(どれだけ強く効くか)をインスペクタから調整できる。
  • 群れ全体として暴走しないように、最大速度ステアリング力の制限を設ける。
  • 外部スクリプトに依存せず、このコンポーネントをアタッチしたノード同士だけで完結して動作する。

外部依存をなくすためのアプローチ

  • グローバルな GameManager を使わない代わりに、BoidFlocking 自身が静的配列で「現在シーンに存在する BoidFlocking インスタンスの一覧」を管理します。
    • onEnable で一覧に登録し、onDisable で解除。
    • 各フレームでこの一覧を走査し、「自分以外の Boid」を近傍判定に利用します。
  • 移動は 2D/3D どちらでも使えるように、Node のローカル座標(Vec3)を直接操作します。
    • 2Dゲームでも 3Dゲームでも、そのままポジションを更新するだけで動作。
  • 物理コンポーネント(RigidBody など)には依存せず、Transform ベースのシンプルな移動に限定します。

インスペクタで設定可能なプロパティ設計

以下のプロパティを用意し、すべてインスペクタから調整可能にします。

  • 移動系
    • maxSpeed: number
      – Boid の最大速度。
      – 単位: ユニット/秒。
      – 例: 100〜400 くらいで調整。
    • maxForce: number
      – 1 フレームで向きや速度をどれだけ変えられるか(ステアリング力の上限)。
      – 小さいほど滑らかに、大きいほど急激に方向転換します。
    • initialSpeed: number
      – 初期速度の大きさ。ランダムな方向にこのスピードで動き始めます。
  • 分離(Separation)
    • separationRadius: number
      – どのくらい近づいたら「離れよう」とするかの距離。
    • separationWeight: number
      – 分離行動の強さ。値を大きくすると、仲間同士がより強く離れようとします。
  • 整列(Alignment)
    • alignmentRadius: number
      – どの範囲の仲間の移動方向を参照するか。
    • alignmentWeight: number
      – 整列行動の強さ。値を大きくすると、仲間と同じ方向に進もうとする力が強くなります。
  • 結合(Cohesion)
    • cohesionRadius: number
      – どの範囲の仲間の「中心位置」に向かおうとするか。
    • cohesionWeight: number
      – 結合行動の強さ。値を大きくすると、群れとしてまとまりやすくなります。
  • その他のオプション
    • limitTo2D: boolean
      – 有効にすると、Y 軸(または Z 軸)を固定して 2D 的に動かすためのオプション。
      – ここでは「XZ 平面を 2D と見なす」か「XY 平面を 2D と見なす」など、簡易な制御を入れます。
    • plane: 'XY' | 'XZ'
      – 2D として扱う平面の指定。
      – 2Dゲーム(Canvas)なら XY、3D空間の上から見下ろすゲームなら XZ が一般的。
    • debugDraw: boolean
      – 有効にすると、log など簡易なデバッグ情報を出す(本記事では最小限にします)。

TypeScriptコードの実装

以下が、完成した BoidFlocking.ts の全コードです。


import { _decorator, Component, Node, Vec3, math } from 'cc';
const { ccclass, property } = _decorator;

/**
 * BoidFlocking
 * 複数のノードにアタッチすることで、分離・整列・結合のルールに基づく群衆移動を行う汎用コンポーネント。
 * 外部スクリプトに依存せず、このコンポーネントを持つノード同士だけで完結して動作します。
 */
@ccclass('BoidFlocking')
export class BoidFlocking extends Component {

    // === 移動パラメータ ===
    @property({
        tooltip: 'Boid の最大速度(ユニット/秒)。値を大きくすると素早く移動します。'
    })
    public maxSpeed: number = 200;

    @property({
        tooltip: '1フレームあたりの最大ステアリング力。小さいほど滑らかに、大きいほど急激に方向転換します。'
    })
    public maxForce: number = 300;

    @property({
        tooltip: '初期速度の大きさ。ランダムな方向にこの速度で動き始めます。'
    })
    public initialSpeed: number = 100;

    // === 分離(Separation) ===
    @property({
        tooltip: '分離半径。この距離以内に仲間がいると離れようとします。'
    })
    public separationRadius: number = 60;

    @property({
        tooltip: '分離行動の強さ。値を大きくすると仲間同士がより強く離れようとします。'
    })
    public separationWeight: number = 1.5;

    // === 整列(Alignment) ===
    @property({
        tooltip: '整列半径。この距離以内の仲間の移動方向に合わせようとします。'
    })
    public alignmentRadius: number = 120;

    @property({
        tooltip: '整列行動の強さ。値を大きくすると仲間と同じ方向に進もうとする力が強くなります。'
    })
    public alignmentWeight: number = 1.0;

    // === 結合(Cohesion) ===
    @property({
        tooltip: '結合半径。この距離以内の仲間の中心位置に向かおうとします。'
    })
    public cohesionRadius: number = 120;

    @property({
        tooltip: '結合行動の強さ。値を大きくすると群れとしてまとまりやすくなります。'
    })
    public cohesionWeight: number = 1.0;

    // === 2D/3D オプション ===
    @property({
        tooltip: '2D 的な動きに制限するかどうか。true の場合、指定した平面上でのみ移動します。'
    })
    public limitTo2D: boolean = true;

    @property({
        tooltip: '2D として扱う平面。XY: 2Dゲーム(Canvas)向け / XZ: 上から見下ろす3Dゲーム向け。',
        visible: function (this: BoidFlocking) {
            return this.limitTo2D;
        }
    })
    public plane: 'XY' | 'XZ' = 'XY';

    @property({
        tooltip: '簡易デバッグ用フラグ。true にすると一部の内部情報をログ出力します。'
    })
    public debugDraw: boolean = false;

    // === 内部状態 ===

    // 現在シーンに存在する全 BoidFlocking インスタンスの一覧
    private static _instances: BoidFlocking[] = [];

    // 現在の速度ベクトル
    private _velocity: Vec3 = new Vec3();

    // 作業用一時ベクトル(GC削減のため再利用)
    private static _tmpVecA: Vec3 = new Vec3();
    private static _tmpVecB: Vec3 = new Vec3();
    private static _tmpVecC: Vec3 = new Vec3();
    private static _tmpVecD: Vec3 = new Vec3();

    onLoad() {
        // 初期速度をランダムな方向に設定
        const dir = new Vec3(
            math.randomRange(-1, 1),
            this.plane === 'XY' ? math.randomRange(-1, 1) : 0,
            this.plane === 'XZ' ? math.randomRange(-1, 1) : 0
        );
        if (dir.length() === 0) {
            dir.set(1, 0, 0);
        }
        dir.normalize();
        Vec3.multiplyScalar(this._velocity, dir, this.initialSpeed);

        if (this.debugDraw) {
            console.log(`[BoidFlocking] onLoad: node=${this.node.name}, initialVelocity=`, this._velocity);
        }
    }

    onEnable() {
        // インスタンス一覧に登録
        const list = BoidFlocking._instances;
        if (list.indexOf(this) === -1) {
            list.push(this);
        }
    }

    onDisable() {
        // インスタンス一覧から削除
        const list = BoidFlocking._instances;
        const idx = list.indexOf(this);
        if (idx !== -1) {
            list.splice(idx, 1);
        }
    }

    update(deltaTime: number) {
        if (deltaTime <= 0) {
            return;
        }

        // 近傍の Boid からステアリングベクトルを計算
        const separation = this.computeSeparation();
        const alignment = this.computeAlignment();
        const cohesion = this.computeCohesion();

        // 各ルールに重みを掛けて合成
        const steering = BoidFlocking._tmpVecA;
        steering.set(0, 0, 0);

        Vec3.scaleAndAdd(steering, steering, separation, this.separationWeight);
        Vec3.scaleAndAdd(steering, steering, alignment, this.alignmentWeight);
        Vec3.scaleAndAdd(steering, steering, cohesion, this.cohesionWeight);

        // ステアリング力を制限
        this.limitVector(steering, this.maxForce);

        // 速度に反映
        Vec3.scaleAndAdd(this._velocity, this._velocity, steering, deltaTime);

        // 最大速度を制限
        this.limitVector(this._velocity, this.maxSpeed);

        // 位置を更新
        const pos = this.node.position;
        const newPos = BoidFlocking._tmpVecB;
        Vec3.scaleAndAdd(newPos, pos, this._velocity, deltaTime);

        // 2D 平面に制限する場合は、不要な軸を固定
        if (this.limitTo2D) {
            if (this.plane === 'XY') {
                newPos.z = pos.z; // Z を固定
                this._velocity.z = 0;
            } else if (this.plane === 'XZ') {
                newPos.y = pos.y; // Y を固定
                this._velocity.y = 0;
            }
        }

        this.node.setPosition(newPos);
    }

    /**
     * 分離(Separation)ベクトルを計算。
     * 近すぎる仲間から離れる方向のステアリングを返す。
     */
    private computeSeparation(): Vec3 {
        const result = BoidFlocking._tmpVecB;
        result.set(0, 0, 0);

        const myPos = this.node.position;
        const instances = BoidFlocking._instances;
        let count = 0;

        for (let i = 0; i < instances.length; i++) {
            const other = instances[i];
            if (other === this) continue;

            const otherPos = other.node.position;
            const offset = BoidFlocking._tmpVecC;
            Vec3.subtract(offset, myPos, otherPos);
            const dist = offset.length();

            if (dist > 0 && dist < this.separationRadius) {
                // 近いほど強く離れるように、距離の逆数で重み付け
                offset.normalize();
                offset.multiplyScalar(1 / dist);
                Vec3.add(result, result, offset);
                count++;
            }
        }

        if (count > 0) {
            result.multiplyScalar(1 / count);
        }

        if (result.length() > 0) {
            // 望ましい速度ベクトルに変換
            result.normalize();
            result.multiplyScalar(this.maxSpeed);

            // 現在の速度との差分(ステアリング)を計算
            Vec3.subtract(result, result, this._velocity);
            this.limitVector(result, this.maxForce);
        }

        return result;
    }

    /**
     * 整列(Alignment)ベクトルを計算。
     * 近くの仲間の平均的な移動方向に合わせるステアリングを返す。
     */
    private computeAlignment(): Vec3 {
        const result = BoidFlocking._tmpVecC;
        result.set(0, 0, 0);

        const myPos = this.node.position;
        const instances = BoidFlocking._instances;
        let count = 0;

        for (let i = 0; i < instances.length; i++) {
            const other = instances[i];
            if (other === this) continue;

            const otherPos = other.node.position;
            const offset = BoidFlocking._tmpVecD;
            Vec3.subtract(offset, otherPos, myPos);
            const dist = offset.length();

            if (dist > 0 && dist < this.alignmentRadius) {
                Vec3.add(result, result, other._velocity);
                count++;
            }
        }

        if (count > 0) {
            result.multiplyScalar(1 / count);
            if (result.length() > 0) {
                result.normalize();
                result.multiplyScalar(this.maxSpeed);
                Vec3.subtract(result, result, this._velocity);
                this.limitVector(result, this.maxForce);
            }
        }

        return result;
    }

    /**
     * 結合(Cohesion)ベクトルを計算。
     * 近くの仲間の中心位置に向かうステアリングを返す。
     */
    private computeCohesion(): Vec3 {
        const result = BoidFlocking._tmpVecD;
        result.set(0, 0, 0);

        const myPos = this.node.position;
        const instances = BoidFlocking._instances;
        let count = 0;

        for (let i = 0; i < instances.length; i++) {
            const other = instances[i];
            if (other === this) continue;

            const otherPos = other.node.position;
            const offset = BoidFlocking._tmpVecA;
            Vec3.subtract(offset, otherPos, myPos);
            const dist = offset.length();

            if (dist > 0 && dist < this.cohesionRadius) {
                Vec3.add(result, result, otherPos);
                count++;
            }
        }

        if (count > 0) {
            // 近傍の平均位置(重心)を求める
            result.multiplyScalar(1 / count);

            // その位置へ向かうベクトルを計算
            Vec3.subtract(result, result, myPos);

            if (result.length() > 0) {
                result.normalize();
                result.multiplyScalar(this.maxSpeed);
                Vec3.subtract(result, result, this._velocity);
                this.limitVector(result, this.maxForce);
            }
        }

        return result;
    }

    /**
     * ベクトルの長さを maxLength 以下に制限するユーティリティ。
     */
    private limitVector(v: Vec3, maxLength: number) {
        const len = v.length();
        if (len > maxLength && len > 0) {
            v.multiplyScalar(maxLength / len);
        }
    }
}

コードのポイント解説

  • 静的配列 _instances
    • BoidFlocking._instances に、現在有効な全インスタンスを保管しています。
    • onEnable で登録、onDisable で削除することで、シーン上の Boid を自動的に検出できます。
    • これにより、外部のマネージャスクリプトに依存せず「Boid 同士の関係」を構築できます。
  • onLoad
    • ランダムな方向に初期速度を付与します。
    • plane 設定に応じて、「XY 平面」または「XZ 平面」でランダムベクトルを生成します。
  • update
    • 毎フレーム、computeSeparation / computeAlignment / computeCohesion を呼び出してステアリングベクトルを計算します。
    • それぞれに重みを掛けて合成し、maxForce で制限したうえで現在の速度に加算します。
    • 速度を maxSpeed で制限し、deltaTime を掛けて位置を更新します。
    • limitTo2D が有効な場合は、不要な軸を固定して 2D 的な動きに制限します。
  • compute* メソッド
    • computeSeparation
      separationRadius 内にいる仲間に対して、距離の逆数で反発ベクトルを加算し、平均化してステアリングベクトルに変換します。
    • computeAlignment
      alignmentRadius 内の仲間の速度を平均し、「その速度に近づく」ようにステアリングを計算します。
    • computeCohesion
      cohesionRadius 内の仲間の位置の平均(重心)を求め、そこに向かうようにステアリングを計算します。
  • GC 削減のための一時ベクトル再利用
    • _tmpVecA_tmpVecD を static で使い回し、new Vec3() を毎フレーム連発しないようにしています。

使用手順と動作確認

ここからは、実際に Cocos Creator 3.8.7 のエディタで BoidFlocking を使ってみる手順を説明します。

1. スクリプトファイルの作成

  1. Assets パネルで任意のフォルダ(例: assets/scripts)を右クリックします。
  2. Create → TypeScript を選択します。
  3. 作成されたファイル名を BoidFlocking.ts に変更します。
  4. BoidFlocking.ts をダブルクリックしてエディタで開き、前述の TypeScript コードを丸ごと貼り付けて保存します。

2. テスト用ノードの作成

2D ゲームか 3D ゲームかで若干手順が変わりますが、基本は「複数のノードを作って同じスクリプトを付ける」だけです。

2D(Canvas)で試す場合(XY 平面)

  1. Hierarchy パネルで右クリックして Create → 2D Object → Canvas を作成します(既にある場合は流用)。
  2. Canvas の子として、右クリック → Create → 2D Object → Sprite を選択し、敵1体目となる Sprite ノードを作成します。
  3. 同様にして、Sprite ノードを複数(例: 10〜30 個)複製しておきます。
    • Sprite を選択し、Ctrl+D / Cmd+D で複製すると簡単です。
    • 位置が完全に重なっていても構いませんが、少し散らしておくと挙動が分かりやすくなります。

3D(見下ろし)で試す場合(XZ 平面)

  1. Hierarchy パネルで右クリックして Create → 3D Object → Node を作成し、名前を Boid_01 などに変更します。
  2. 必要であれば MeshRenderer や Model などを追加して見た目を付けます(任意)。
  3. このノードを複製して、同様に 10〜30 個程度のノードを用意します。

3. BoidFlocking コンポーネントのアタッチ

  1. Boid にしたいノード(Sprite や 3D ノード)を 1 つ選択します。
  2. Inspector パネルの下部にある Add Component ボタンをクリックします。
  3. Custom → BoidFlocking を選択して追加します。
    • Custom カテゴリに BoidFlocking が見つからない場合は、スクリプトの保存状態やビルドエラーがないか確認してください。
  4. 他の Boid ノードにも同じコンポーネントを付けます。
    • 1 つのノードに付けたあと、そのノードを複製すると、コンポーネントごとコピーされるので楽です。

4. プロパティの設定例

Inspector で BoidFlocking を選択すると、以下のようなプロパティが表示されます。まずは次のような値で試してみてください。

maxSpeed         = 200
maxForce         = 300
initialSpeed     = 100

separationRadius = 60
separationWeight = 1.5

alignmentRadius  = 120
alignmentWeight  = 1.0

cohesionRadius   = 120
cohesionWeight   = 1.0

limitTo2D        = true
plane            = XY   (2Dゲームの場合) / XZ (3D見下ろしの場合)
debugDraw        = false

2D Canvas の場合は plane = XY、見下ろし 3D の場合は plane = XZ に設定してください。

5. 再生して動作を確認

  1. エディタ右上の Play ボタンを押してゲームを再生します。
  2. BoidFlocking を付けたノードたちが、ランダムな方向に動き始め、徐々に群れとしてまとまりつつ、互いにぶつからないように動く様子が確認できるはずです。
  3. 挙動が激しすぎる場合は:
    • maxSpeed を小さくする(例: 120〜160)。
    • maxForce を小さくする(例: 150〜200)。
    • separationWeight を 1.0 前後に下げる。
  4. 群れがまとまりにくい場合は:
    • cohesionWeight を 1.5〜2.0 に上げる。
    • alignmentWeight を 1.5 前後に上げる。

6. よくある調整パターン

  • 敵がバラバラに散ってしまう
    • cohesionWeight を上げる(群れのまとまり強化)。
    • alignmentWeight を上げる(進行方向の揃いを強化)。
  • 敵同士が近づきすぎて重なって見える
    • separationRadius を少し大きくする。
    • separationWeight を上げる(1.5〜2.5 など)。
  • 動きがカクカク・不安定
    • maxForce を下げることでステアリングの変化を緩やかにする。
    • maxSpeed を下げて全体の速度を落とす。

まとめ

この BoidFlocking コンポーネントは、

  • 外部の GameManager やシングルトンに一切依存せず、
  • BoidFlocking をアタッチしたノード同士だけで「分離・整列・結合」に基づく群衆移動を実現し、
  • すべての調整パラメータをインスペクタから変更できる

という点で、プロトタイプから本番ゲームまで幅広く使える汎用スクリプトになっています。

応用例としては、

  • シューティングゲームの敵編隊(敵が自然に群れながらプレイヤーを囲む)。
  • RPG のモンスター群(同種モンスターが群れとして行動する)。
  • 群衆シミュレーションや群れ行動のデモシーン。

など、「たくさんのオブジェクトを自然に動かしたい」場面でそのまま活用できます。

ノードを増やしても、基本的には BoidFlocking をアタッチするだけで同じルールに従って動作するため、敵のバリエーションを増やすときもスクリプトをほとんど触らずに済むのが大きなメリットです。

必要に応じて、

  • 「特定ターゲット(プレイヤー)への追従ベクトル」を加算する。
  • 「エリア外に出たら戻る」境界処理を追加する。

といった拡張も、同じ設計方針(外部依存なし・インスペクタで調整)で簡単に実装できます。ここで紹介した基本形をベースに、あなたのゲームに合わせた群衆AIへ発展させてみてください。