【Cocos Creator 3.8】FormationMove の実装:アタッチするだけで「リーダーを追いかける陣形移動(横一列・三角形)」を実現する汎用スクリプト

このコンポーネントは、指定した「リーダー」ノードの後ろに、子ノードたちを一定の陣形(横一列・三角形)で追従させるための汎用スクリプトです。
敵編隊や味方部隊のフォーメーション、ペットが隊列を組んで主人公を追いかける演出など、「何体かのオブジェクトをきれいに並べて追従させたい」ときに、ノードにアタッチしてプロパティを設定するだけで使えます。


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

1. 機能要件の整理

  • リーダー(先頭)となるノードを 1 つ指定する。
  • このコンポーネントをアタッチしたノードの「子ノード」たちを隊列メンバーとして扱う。
  • 隊列メンバーは、リーダーの後ろに一定の陣形を保つように移動する。
  • 対応する陣形:
    • 横一列(Line): リーダーの後方に横一列に並ぶ。
    • 三角形(Triangle): リーダーの後方に三角形状に並ぶ(2列目3体、3列目4体…など)。
  • リーダーの向き(左右)に応じて、陣形の向きも左右反転できる。
  • 追従速度や追従のなめらかさ(補間係数)を Inspector から調整できる。
  • 外部のカスタムスクリプトには一切依存せず、この 1 コンポーネントだけで完結する。

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

  • リーダー参照は @property(Node) で Inspector から直接指定。
  • 隊列メンバーは「このコンポーネントがアタッチされたノードの子ノード」を自動取得(this.node.children)。
  • 特別な GameManager やシングルトンなどは一切使用しない。
  • Sprite など特定のレンダラーへの依存は持たず、純粋に Node の位置とスケールのみを操作する。
  • リーダーが未設定・子ノードがいない場合などはエラーログ/警告ログを出して安全に処理をスキップする。

3. Inspector で設定可能なプロパティ設計

以下のようなプロパティを持たせます。

  • leader(Node)
    • 役割: 隊列の先頭となるリーダーノード。
    • 設定: Inspector でシーン上のノードをドラッグ&ドロップ。
  • formationType(enum)
    • 役割: 陣形の種類(横一列 or 三角形)。
    • 値:
      • 0: Line(横一列)
      • 1: Triangle(三角形)
  • spacingX(number)
    • 役割: 隊列メンバー同士の横方向の間隔。
    • 例: 50 にすると、隣のメンバーと 50px 離して配置。
  • spacingY(number)
    • 役割: リーダーからの後方距離や、三角形の列ごとの縦の間隔。
    • 例: 60 にすると、1 列後ろは 60px、2 列後ろは 120px… といった具合。
  • followSpeed(number)
    • 役割: メンバーが目標位置へ移動するスピード(単位: 単純な補間係数)。
    • 例: 3〜10 程度。大きいほど素早く陣形位置に追いつく。
  • smoothFactor(number)
    • 役割: 追従のなめらかさを決める 0〜1 の値。
    • 例: 0.1 ならゆっくり補間、1.0 なら即座に目標位置に張り付く。
  • mirrorByLeaderScaleX(boolean)
    • 役割: リーダーの scale.x が負のとき、陣形を左右反転するかどうか。
    • 例: 横スクロールアクションで、主人公が左を向いたら隊列も左右反転して並び替えたい場合に有効。
  • autoCollectChildren(boolean)
    • 役割: 毎フレーム、子ノードリストを自動更新するかどうか。
    • 例: 途中で隊列メンバーを増減させる可能性がある場合は true。
  • debugDrawGizmos(boolean)
    • 役割: デバッグ用に、陣形のターゲット位置を Gizmo で簡易表示(エディタ上の Scene ビューでの目安)。
    • ※実装は簡易的にしておき、必要に応じて拡張可能とする。

TypeScriptコードの実装

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


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

enum FormationType {
    Line = 0,
    Triangle = 1,
}

Enum(FormationType);

@ccclass('FormationMove')
@executeInEditMode(true)
@menu('Custom/FormationMove')
export class FormationMove extends Component {

    @property({
        type: Node,
        tooltip: '隊列の先頭となるリーダーノード。ここで指定したノードの後ろに陣形を組みます。'
    })
    public leader: Node | null = null;

    @property({
        type: FormationType,
        tooltip: '陣形タイプを選択します。\nLine: 横一列\nTriangle: 三角形'
    })
    public formationType: FormationType = FormationType.Line;

    @property({
        tooltip: '横方向の間隔(ピクセル)。大きいほど左右に広がります。'
    })
    public spacingX: number = 60;

    @property({
        tooltip: '縦方向(後方)への間隔(ピクセル)。大きいほどリーダーから離れます。'
    })
    public spacingY: number = 60;

    @property({
        tooltip: '追従スピード。大きいほど素早く陣形位置に追いつきます。'
    })
    public followSpeed: number = 5;

    @property({
        tooltip: '追従のなめらかさ(0〜1)。1 に近いほどカクっと追従し、0 に近いほどゆっくり補間します。'
    })
    public smoothFactor: number = 0.2;

    @property({
        tooltip: 'リーダーの scale.x が負の場合に陣形を左右反転するかどうか。'
    })
    public mirrorByLeaderScaleX: boolean = true;

    @property({
        tooltip: '子ノードのリストを毎フレーム自動更新します。隊列メンバーを動的に増減する場合は true。'
    })
    public autoCollectChildren: boolean = false;

    @property({
        tooltip: 'Scene ビュー上で簡易的にターゲット位置を表示するためのフラグ(実装は最小限)。'
    })
    public debugDrawGizmos: boolean = false;

    // 内部用: 隊列メンバー
    private _members: Node[] = [];

    // ワーク用ベクトル(GC削減)
    private _tempVec3: Vec3 = new Vec3();
    private _tempTarget: Vec3 = new Vec3();

    onLoad() {
        this._collectMembers();

        if (!this.leader) {
            console.warn('[FormationMove] leader が設定されていません。Inspector でリーダーノードを指定してください。', this.node);
        }

        if (this._members.length === 0) {
            console.warn('[FormationMove] 子ノードが存在しません。隊列メンバーとして扱う子ノードを追加してください。', this.node);
        }
    }

    start() {
        // エディタ上でのプレビューや実行時の初期整列
        this._updateFormation(0);
    }

    update(deltaTime: number) {
        if (!this.leader) {
            return;
        }

        if (this.autoCollectChildren) {
            this._collectMembers();
        }

        if (this._members.length === 0) {
            return;
        }

        this._updateFormation(deltaTime);
    }

    /**
     * 現在の子ノードから隊列メンバーを収集します。
     * 自分自身(this.node)は含めません。
     */
    private _collectMembers() {
        this._members = this.node.children.slice();
    }

    /**
     * 陣形に基づいて、各メンバーの目標位置を計算し、なめらかに追従させます。
     */
    private _updateFormation(deltaTime: number) {
        const leader = this.leader;
        if (!leader) {
            return;
        }

        // リーダーのワールド座標を取得
        const leaderWorldPos = leader.worldPosition.clone();

        // ミラーリング方向
        let dirSign = 1;
        if (this.mirrorByLeaderScaleX && leader.scale.x < 0) {
            dirSign = -1;
        }

        const memberCount = this._members.length;

        for (let i = 0; i < memberCount; i++) {
            const member = this._members[i];
            if (!member || !member.isValid) {
                continue;
            }

            // メンバー i の陣形上のオフセット位置を計算
            const offset = this._calculateOffsetForIndex(i, dirSign);

            // リーダーの後ろ側に配置するため、Y方向はマイナス(下)方向にずらす
            this._tempTarget.set(
                leaderWorldPos.x + offset.x,
                leaderWorldPos.y + offset.y,
                leaderWorldPos.z
            );

            // メンバーの現在のワールド座標
            const currentWorldPos = member.worldPosition;

            // 目標位置へ向かう補間
            // 簡易的には Lerp(current, target, t) を使用
            const t = math.clamp01(this.smoothFactor * this.followSpeed * deltaTime);

            this._tempVec3.set(
                math.lerp(currentWorldPos.x, this._tempTarget.x, t),
                math.lerp(currentWorldPos.y, this._tempTarget.y, t),
                math.lerp(currentWorldPos.z, this._tempTarget.z, t),
            );

            // ワールド座標をそのまま代入
            member.worldPosition = this._tempVec3;
        }
    }

    /**
     * 指定インデックスのメンバーに対する陣形オフセット(リーダーからの相対位置)を計算します。
     * dirSign は左右方向の符号(1: 右基準, -1: 左基準)。
     */
    private _calculateOffsetForIndex(index: number, dirSign: number): Vec3 {
        const offset = new Vec3();

        switch (this.formationType) {
            case FormationType.Line:
                // 横一列: index が増えるごとに横方向に並べる
                // 中心をリーダー後方中央にするため、(-n/2 + i) * spacingX で左右に振り分ける
                const count = this._members.length;
                const centerIndex = (count - 1) * 0.5;
                const x = (index - centerIndex) * this.spacingX * dirSign;
                const y = -this.spacingY; // リーダーの一列後ろ
                offset.set(x, y, 0);
                break;

            case FormationType.Triangle:
                // 三角形: 1,2,3,... と列を増やしながら三角形状に並べる
                // 例: 
                //   列0: 1体
                //   列1: 2体
                //   列2: 3体 ... のように増やしていく
                this._calculateTriangleOffset(index, dirSign, offset);
                break;
        }

        return offset;
    }

    /**
     * 三角形陣形のオフセット計算。
     * index: メンバーのインデックス
     * dirSign: 左右反転用の符号
     * outOffset: 結果を書き込む Vec3
     */
    private _calculateTriangleOffset(index: number, dirSign: number, outOffset: Vec3) {
        // 何列目か、列内の何番目かを計算する
        // 列0に1体、列1に2体、列2に3体... と増える三角数列を利用
        let remaining = index;
        let row = 0;
        while (true) {
            const rowCount = row + 1; // 0列目=1体, 1列目=2体, ...
            if (remaining < rowCount) {
                // この列に属する
                break;
            }
            remaining -= rowCount;
            row++;
            if (row > 1000) {
                // 念のため無限ループ防止
                break;
            }
        }

        const rowCount = row + 1; // この列の人数
        const positionInRow = remaining; // 0〜rowCount-1

        // この列の横方向の中心を 0 にする
        const centerIndex = (rowCount - 1) * 0.5;
        const x = (positionInRow - centerIndex) * this.spacingX * dirSign;

        // 縦方向は列番号に応じて後ろに下げる
        const y = -(row + 1) * this.spacingY;

        outOffset.set(x, y, 0);
    }

}

コードのポイント解説

  • enum FormationTypeEnum(FormationType)
    Cocos Creator の Inspector に enum を表示させるために Enum() 呼び出しを行っています。これにより formationType をドロップダウンで選択可能になります。
  • @executeInEditMode(true)
    エディタ上でプレビューしやすいように、編集モードでも update が呼ばれます。Scene ビューでリーダーを動かすと、ある程度陣形が追従するのが確認できます。
  • onLoad
    • _collectMembers() で自ノードの子ノードを隊列メンバーとして収集。
    • leader が未設定、または子ノードが存在しない場合は console.warn で警告を出し、エディタ上で気づきやすくしています。
  • update
    • leader が設定されていない場合は即リターン(防御的実装)。
    • autoCollectChildren が true のときは毎フレーム隊列メンバーを再収集し、動的に増減させたノードにも対応。
    • _updateFormation(deltaTime) で、各メンバーの目標位置を計算し、なめらかに追従させています。
  • _updateFormation
    • リーダーの worldPosition を基準に、各メンバーのオフセット位置を求めます。
    • 左右反転は mirrorByLeaderScaleX とリーダーの scale.x の符号を見て決定。
    • math.lerpsmoothFactor * followSpeed * deltaTime を使って、現在位置から目標位置へ線形補間しています。
  • _calculateOffsetForIndex_calculateTriangleOffset
    • Line: メンバー数に応じて中心を 0 にし、左右に広がるように配置。
    • Triangle: 三角数列(1, 1+2, 1+2+3, …)を利用して「何列目か」「列内の何番目か」を求め、三角形状に配置。

使用手順と動作確認

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

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. 作成されたスクリプトの名前を FormationMove.ts に変更します。
  3. ダブルクリックしてエディタで開き、先ほどの TypeScript コードを丸ごと貼り付けて保存します。

2. シーンにリーダーと隊列ノードを用意する

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite(または任意の Node)を選択し、リーダーとなるノードを作成します。
    • 名前を分かりやすく Leader などにしておきます。
  2. 同様に、隊列全体をまとめるルートノードを作成します。
    • Hierarchy で右クリック → Create → Empty Node を選択し、名前を FormationRoot などにします。
  3. FormationRoot の子として、隊列メンバーとなるノードを複数作成します。
    • 例: Member1, Member2, Member3, Member4, Member5 など。
    • Sprite を使う場合は、各ノードに Sprite コンポーネントを追加しておくと見やすいです。

3. FormationMove コンポーネントをアタッチする

  1. Hierarchy で FormationRoot を選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. CustomFormationMove を選択してアタッチします。

4. Inspector でプロパティを設定する

  1. Leader プロパティに、Hierarchy から Leader ノードをドラッグ&ドロップして設定します。
  2. Formation Type(formationType)
    • ドロップダウンから Line または Triangle を選択します。
    • まずは動作確認として Line を選ぶのがおすすめです。
  3. Spacing X(spacingX)
    • 例: 80 と設定すると、メンバー同士が 80px 間隔で横に並びます。
  4. Spacing Y(spacingY)
    • 例: 60 と設定すると、リーダーの 60px 後ろに 1 列目が並びます。
  5. Follow Speed(followSpeed)
    • 例: 5〜10 の範囲で調整してみてください。大きいほどすぐに陣形位置に追いつきます。
  6. Smooth Factor(smoothFactor)
    • 例: 0.20.5 程度が扱いやすいです。
    • 0.1 以下だとかなりゆったりとした追従になり、1.0 だとほぼ即座に張り付く挙動になります。
  7. Mirror By Leader Scale X(mirrorByLeaderScaleX)
    • 左右反転を使いたい場合は チェックを入れたまま にします。
    • リーダーの scale.x を -1 にすると、陣形が左右反転して並びます。
  8. Auto Collect Children(autoCollectChildren)
    • 動的にメンバーを増減させない場合は OFF のままで OK です。
    • ゲーム中に FormationRoot の子ノードを追加・削除する場合は ON にしてください。

5. 実行して動作を確認する

  1. Scene ビューで Leader ノードを少し右上あたりに配置し、FormationRoot は近くに置いておきます。
  2. 上部の再生ボタン(Play)を押してゲームを実行します。
  3. 実行中に Leader を動かすスクリプトが無い場合は、簡単にテストするために以下のいずれかを行います。
    • ① 一時的に Leader にシンプルな移動スクリプトを付ける(左右に往復など)。
    • ② Editor の「Remote」機能で、実行中に Leader の Position をいじってみる。
  4. Leader が移動すると、FormationRoot の子ノード(Member1〜)が、設定した陣形を保ちながら後ろを追従してくることを確認します。

6. 陣形タイプを変えてみる

  1. 実行を停止し、Inspector で Formation TypeTriangle に変更します。
  2. Spacing X / Spacing Y を調整しながら再度実行してみてください。
    • 例: spacingX=80, spacingY=60 など。
  3. メンバー数を増やすと、1体 → 2体 → 3体 → 4体… と列が増えていき、三角形のような隊列になるのを確認できます。

7. 左右反転の確認(任意)

  1. Leader ノードを選択し、Inspector で Scale X-1 に設定します。
  2. Mirror By Leader Scale X が ON の場合、陣形が左右反転して並ぶことが確認できます。

まとめ

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

  • リーダーとなるノードを 1 つ指定するだけで、
  • 自分の子ノードを自動的に「隊列メンバー」として扱い、
  • 横一列・三角形といった陣形を保ちながら、なめらかに追従させる

という汎用的な陣形移動を実現します。

外部の GameManager やシングルトンに依存せず、このスクリプト 1 本をアタッチするだけで使えるため、

  • 敵編隊のパターンを増やしたいとき
  • 味方ユニットが主人公の後ろに整列してついてくる表現
  • シューティングゲームのオプション(サブ機)配置

など、さまざまなシーンで再利用できます。

また、_calculateOffsetForIndex のロジックを差し替えることで、円形・V字・縦一列など、他の陣形への拡張も容易です。まずはこの基本形をベースに、自分のゲームに合ったフォーメーションパターンを増やしてみてください。