【Cocos Creator】アタッチするだけ!TargetGroup (複数追尾)の実装方法【TypeScript】

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

【Cocos Creator 3.8】TargetGroup(複数追尾)の実装:アタッチするだけで「複数ターゲットの中心へカメラを移動し、全員が画面内に入るよう自動ズーム」する汎用スクリプト

このコンポーネントは、複数のキャラクターやオブジェクトを同時に追尾したいときに便利な「カメラ用」スクリプトです。
カメラノードにアタッチし、追尾対象をインスペクタで登録するだけで、ターゲット群の中間点へカメラを移動し、全員が画面内に収まるようにズームを自動調整します。


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

1. 機能要件の整理

  • 複数のターゲット(Node)を配列で受け取り、その「バウンディングボックス(最小矩形)」を算出する。
  • その矩形の中心にカメラを移動させる。
  • 矩形のサイズに応じて、全ターゲットが画面内に入るようにカメラのズーム(Camera.orthoHeight または Camera.fov / Node.scale)を調整する。
  • 2Dゲーム(正投影カメラ)を主ターゲットとし、orthographic カメラに最適化した設計にする。
  • ターゲットが1体または0体の場合の挙動も破綻しないようにする。
  • 毎フレームいきなり位置・ズームを変更せず、補間(スムーズ追従)を行えるようにする。
  • 外部スクリプトへの依存なし(完全独立)。すべてインスペクタ設定で完結する。

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

  • カメラ参照は this.getComponent(Camera) で自身のノードから取得する。
  • 取得できなかった場合は error ログを出し、処理をスキップする(防御的実装)。
  • ターゲットリストも、@property(Node) の配列としてインスペクタから直接設定可能にする。
  • ゲーム全体の状態を管理する GameManager 等は一切使わない。

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

以下のようなプロパティを設計します。

  • targets: Node[]
    追尾対象となるノードの配列。プレイヤー、敵、NPCなど、画面に収めたいものを登録します。
  • minTargets: number
    追尾を開始するために必要なターゲット数。
    例: 1 なら1体から追尾開始、2 なら2体以上いないとカメラは動かない。
  • padding: number
    画面の端とターゲット群との余白(マージン)を ワールド座標単位(例: メートル) で指定。
    例: 2 なら、上下左右に2ユニット分の余白を確保します。
  • minOrthoHeight: number
    カメラの最小 orthoHeight。これより小さくズームインしないようにする下限値。
  • maxOrthoHeight: number
    カメラの最大 orthoHeight。これより大きくズームアウトしないようにする上限値。
  • positionLerp: number
    カメラ位置の追従速度(0〜1)。
    0に近いほどゆっくり追従、1に近いほど即座にターゲット中心へ移動。
  • zoomLerp: number
    ズーム(orthoHeight)の追従速度(0〜1)。
    0に近いほどゆっくりズーム、1に近いほど即座に目標ズーム値に変化。
  • useLocalSpace: boolean
    ターゲットの座標を このカメラノードの親座標系に変換してから 計算するかどうか。
    親を共有しているノードを追尾する場合に true にすると扱いやすくなります。
  • debugDrawBounds: boolean
    ターゲット群のバウンディングボックスをデバッグ用にログ出力するかどうか。
    実際のゲームでは false 推奨。

この設計により、2Dゲームであれば「カメラノードにアタッチ → ターゲットを登録 → パラメータを微調整」だけで使える、完全独立の汎用コンポーネントになります。


TypeScriptコードの実装

以下が完成した TargetGroup.ts の実装例です。


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

/**
 * 複数ターゲットの中間点へカメラを移動し、
 * 全員が収まるように orthoHeight を自動調整するコンポーネント。
 *
 * 想定用途: 2Dゲーム / Orthographic カメラ
 * このスクリプトはカメラノードに直接アタッチして使用します。
 */
@ccclass('TargetGroup')
export class TargetGroup extends Component {

    @property({
        type: [Node],
        tooltip: '画面内に収めたいターゲットノードの配列。\nプレイヤーや敵、NPCなどをドラッグ&ドロップで登録してください。'
    })
    public targets: Node[] = [];

    @property({
        tooltip: '追尾を開始するために必要なターゲット数。\n例: 1なら1体から追尾開始、2なら2体以上いないとカメラは動きません。'
    })
    public minTargets: number = 1;

    @property({
        tooltip: 'ターゲット群の周囲に確保する余白(ワールド座標単位)。\n値を大きくすると、画面の端とターゲットとの距離が広がります。'
    })
    public padding: number = 2;

    @property({
        tooltip: 'カメラの最小 orthoHeight。これより小さくズームインしません。'
    })
    public minOrthoHeight: number = 5;

    @property({
        tooltip: 'カメラの最大 orthoHeight。これより大きくズームアウトしません。'
    })
    public maxOrthoHeight: number = 50;

    @property({
        tooltip: 'カメラ位置の追従速度(0〜1)。\n0に近いほどゆっくり、1に近いほど即座に目標位置へ移動します。'
    })
    public positionLerp: number = 0.15;

    @property({
        tooltip: 'ズーム(orthoHeight)の追従速度(0〜1)。\n0に近いほどゆっくり、1に近いほど即座に目標ズーム値に変化します。'
    })
    public zoomLerp: number = 0.15;

    @property({
        tooltip: 'ターゲット座標をこのノードの親座標系に変換してから計算するかどうか。\n同じ親を持つノードを追尾する場合は true 推奨。'
    })
    public useLocalSpace: boolean = true;

    @property({
        tooltip: 'ターゲット群のバウンディングボックス情報をログ出力するデバッグ用フラグ。'
    })
    public debugDrawBounds: boolean = false;

    private _camera: Camera | null = null;
    private _tmpCenter: Vec3 = new Vec3();
    private _tmpMin: Vec3 = new Vec3();
    private _tmpMax: Vec3 = new Vec3();

    onLoad() {
        // カメラコンポーネントを取得
        this._camera = this.getComponent(Camera);
        if (!this._camera) {
            error('[TargetGroup] Camera コンポーネントが見つかりません。' +
                  'このスクリプトは Camera を持つノードにアタッチしてください。');
        }

        // Lerp パラメータのクランプ
        this.positionLerp = math.clamp01(this.positionLerp);
        this.zoomLerp = math.clamp01(this.zoomLerp);

        // min/max orthoHeight の整合性チェック
        if (this.maxOrthoHeight < this.minOrthoHeight) {
            warn('[TargetGroup] maxOrthoHeight が minOrthoHeight より小さくなっています。値を入れ替えます。');
            const tmp = this.maxOrthoHeight;
            this.maxOrthoHeight = this.minOrthoHeight;
            this.minOrthoHeight = tmp;
        }
    }

    start() {
        if (!this._camera) {
            return;
        }

        // 初期フレームで一度だけ即座に位置・ズームを合わせておくと、
        // ゲーム開始時のカメラジャンプが軽減されます。
        this.updateCameraImmediate();
    }

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

    /**
     * ターゲット群のバウンディングボックスと中心点を計算する。
     * 有効なターゲットが minTargets 未満の場合は false を返す。
     */
    private computeTargetsBounds(): boolean {
        const parent = this.node.parent;

        let validCount = 0;
        let first = true;

        for (const target of this.targets) {
            if (!target || !target.activeInHierarchy) {
                continue;
            }

            // 座標系の選択
            let pos = target.worldPosition;
            if (this.useLocalSpace && parent) {
                // カメラの親ノード座標系に変換
                parent.inverseTransformPoint(this._tmpCenter, pos);
                pos = this._tmpCenter;
            }

            if (first) {
                this._tmpMin.set(pos);
                this._tmpMax.set(pos);
                first = false;
            } else {
                this._tmpMin.x = Math.min(this._tmpMin.x, pos.x);
                this._tmpMin.y = Math.min(this._tmpMin.y, pos.y);
                this._tmpMin.z = Math.min(this._tmpMin.z, pos.z);

                this._tmpMax.x = Math.max(this._tmpMax.x, pos.x);
                this._tmpMax.y = Math.max(this._tmpMax.y, pos.y);
                this._tmpMax.z = Math.max(this._tmpMax.z, pos.z);
            }

            validCount++;
        }

        if (validCount < this.minTargets || first) {
            return false;
        }

        // 中心点を計算
        Vec3.add(this._tmpCenter, this._tmpMin, this._tmpMax);
        this._tmpCenter.multiplyScalar(0.5);

        if (this.debugDrawBounds) {
            // デバッグログ(必要に応じてコメントアウト)
            // eslint-disable-next-line no-console
            console.log('[TargetGroup] bounds:',
                'min=', this._tmpMin.toString(),
                'max=', this._tmpMax.toString(),
                'center=', this._tmpCenter.toString()
            );
        }

        return true;
    }

    /**
     * ターゲット群に全員が収まるために必要な orthoHeight を計算する。
     * カメラのアスペクト比を考慮して求める。
     */
    private computeRequiredOrthoHeight(): number {
        if (!this._camera) {
            return this.minOrthoHeight;
        }

        // ターゲット群の幅・高さ
        const width = this._tmpMax.x - this._tmpMin.x + this.padding * 2;
        const height = this._tmpMax.y - this._tmpMin.y + this.padding * 2;

        const aspect = this._camera.camera ? this._camera.camera.aspect : 1;

        // 幅が画面内に収まるために必要な orthoHeight
        const heightByWidth = width / (2 * aspect); // orthographic: width = orthoHeight * 2 * aspect

        // 実際に必要な高さは「高さそのもの」と「幅から換算した高さ」の大きい方
        const required = Math.max(height / 2, heightByWidth);

        // 最小・最大の範囲にクランプ
        return math.clamp(required, this.minOrthoHeight, this.maxOrthoHeight);
    }

    /**
     * 初期化・リセット時など、補間なしで即座にカメラを目標位置&ズームへ移動。
     */
    private updateCameraImmediate() {
        if (!this.computeTargetsBounds()) {
            return;
        }

        const parent = this.node.parent;

        // カメラ位置をターゲット中心へ(Zはそのまま)
        const currentPos = this.node.position;
        const newPos = new Vec3(
            this._tmpCenter.x,
            this._tmpCenter.y,
            currentPos.z
        );

        if (parent && !this.useLocalSpace) {
            // ターゲットをワールドで計算している場合、中心点もワールド座標。
            // カメラノードのローカル座標に変換して設定する。
            parent.inverseTransformPoint(newPos, this._tmpCenter);
            newPos.z = currentPos.z;
        }

        this.node.setPosition(newPos);

        // ズーム(orthoHeight)を即座に合わせる
        const requiredHeight = this.computeRequiredOrthoHeight();
        this._camera!.orthoHeight = requiredHeight;
    }

    /**
     * 毎フレーム呼ばれ、補間しながらカメラを追従させる。
     */
    private updateCameraSmooth(deltaTime: number) {
        if (!this.computeTargetsBounds()) {
            return;
        }

        const parent = this.node.parent;

        // 目標位置
        const currentPos = this.node.position;
        const targetPos = new Vec3(
            this._tmpCenter.x,
            this._tmpCenter.y,
            currentPos.z
        );

        if (parent && !this.useLocalSpace) {
            parent.inverseTransformPoint(targetPos, this._tmpCenter);
            targetPos.z = currentPos.z;
        }

        // 位置の補間
        const lerpT = this.positionLerp;
        const newPos = new Vec3(
            math.lerp(currentPos.x, targetPos.x, lerpT),
            math.lerp(currentPos.y, targetPos.y, lerpT),
            currentPos.z
        );
        this.node.setPosition(newPos);

        // ズーム(orthoHeight)の補間
        const requiredHeight = this.computeRequiredOrthoHeight();
        const currentHeight = this._camera!.orthoHeight;
        const newHeight = math.lerp(currentHeight, requiredHeight, this.zoomLerp);
        this._camera!.orthoHeight = newHeight;
    }
}

コードの要点解説

  • onLoad
    • this.getComponent(Camera) で同じノード上の Camera を取得。
    • 見つからない場合は error ログを出して以降の処理をスキップする(防御的実装)。
    • positionLerpzoomLerpmath.clamp01 で 0〜1 に制限。
    • minOrthoHeightmaxOrthoHeight の関係が逆転していたら警告を出しつつ入れ替え。
  • start
    • ゲーム開始時に updateCameraImmediate() を一度呼び出し、初期位置・ズームを即座に合わせる。
    • これにより、ゲーム開始直後のカメラジャンプを軽減。
  • update
    • 毎フレーム updateCameraSmooth() を呼び、補間しながら追従とズーム調整を行う。
  • computeTargetsBounds()
    • 有効なターゲット(Node が存在し activeInHierarchytrue)のみを対象とする。
    • useLocalSpacetrue の場合は、カメラノードの親座標系に変換してから計算。
    • 最小座標 _tmpMin と最大座標 _tmpMax を走査しながら更新。
    • ターゲット数が minTargets 未満なら false を返して処理をスキップ。
  • computeRequiredOrthoHeight()
    • ターゲット群の幅と高さに padding を足した矩形サイズを求める。
    • Camera のアスペクト比(camera.aspect)から、幅が収まるために必要な orthoHeight を算出。
    • 「高さそのもの」と「幅から換算した高さ」の大きい方を採用し、minOrthoHeightmaxOrthoHeight にクランプ。
  • updateCameraImmediate() / updateCameraSmooth()
    • どちらもまず computeTargetsBounds() で中心点・バウンディングボックスを更新。
    • 中心点の Z はカメラの現在の Z を維持しつつ、X/Y のみターゲット中心へ移動。
    • updateCameraSmooth() では math.lerp で位置とズームを補間し、スムーズな追従を実現。

使用手順と動作確認

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

  1. エディタ左下の Assets パネルで、任意のフォルダ(例: assets/scripts)を右クリックします。
  2. Create → TypeScript を選択し、ファイル名を TargetGroup.ts にします。
  3. 作成された TargetGroup.ts をダブルクリックして開き、内容をすべて削除して、上記の TypeScript コードを貼り付けて保存します。

2. カメラノードの準備

  1. Hierarchy パネルで右クリック → Create → 2D Object → Camera を選択し、2D用のカメラを作成します。
    すでにメインカメラがある場合はそれを使って構いません。
  2. カメラを選択し、Inspector パネルで ProjectionOrthographic になっていることを確認します。
    もし Perspective になっている場合は、ドロップダウンから Orthographic に変更してください。

3. TargetGroup コンポーネントをアタッチ

  1. Hierarchy で先ほどのカメラノードを選択します。
  2. Inspector パネルの一番下にある Add Component ボタンをクリックします。
  3. Custom → TargetGroup を選択し、カメラにこのコンポーネントを追加します。

4. 追尾ターゲット用ノードの作成

テスト用として、単純なスプライトを2つ作ってみます。

  1. Hierarchy で右クリック → Create → 2D Object → Sprite を選択し、Player1 ノードを作成します。
  2. 同様にもう一つ Player2 ノードを作成します。
  3. それぞれのスプライトに適当な画像(Texture)を設定します。
  4. Player1 の Position を (-5, 0, 0)Player2 の Position を (5, 0, 0) など、少し離れた位置に設定します。

5. TargetGroup のプロパティ設定

カメラノードを選択し、Inspector で TargetGroup コンポーネントの各プロパティを設定します。

  • Targets
    1. 配列の右側にある ボタンを2回押して、要素数を 2 にします。
    2. 要素0に Player1 ノード、要素1に Player2 ノードをドラッグ&ドロップします。
  • Min Targets: 1
    1体から追尾を開始するようにします(2体にしたい場合は 2 に変更)。
  • Padding: 2
    ターゲット群の周囲に2ユニット分の余白を確保します。
  • Min Ortho Height: 5
    これより小さくはズームインしません。
  • Max Ortho Height: 50
    これより大きくはズームアウトしません。
  • Position Lerp: 0.15
    カメラ位置がなめらかに追従する程度の値です。もっとキビキビ動かしたい場合は 0.30.5 に上げてみてください。
  • Zoom Lerp: 0.15
    ズーム変化もなめらかにします。違和感があれば 0.10.3 の範囲で調整。
  • Use Local Space: true
    カメラとターゲットが同じ親を持つ前提なら true のままで問題ありません。
  • Debug Draw Bounds: false
    動作確認中にバウンディングボックスのログが欲しい場合のみ true にします。

6. 再生して動作確認

  1. エディタ上部の Play ボタンを押してゲームを実行します。
  2. Player1Player2 の位置が離れていても、カメラが2体の中間点へ移動し、両方が画面内に収まるようにズームアウトされていることを確認します。
  3. 実行中に Player1Player2 をスクリプトなどで動かすと、カメラがそれに追従して常に2体が画面内に入るように調整されます。
  4. ターゲットを1体だけにしたり、Min Targets を変えて挙動の違いを試してみてください。

まとめ

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

  • カメラノードにアタッチするだけで、複数ターゲットの中心へ自動で移動し、
  • 全員が画面内に収まるようにズームを自動調整し、
  • 補間パラメータとズーム範囲、余白をインスペクタだけで直感的に調整できる

という特徴を持つ、完全独立・汎用設計の追尾コンポーネントです。

プレイヤー2人の協力プレイ、プレイヤー+重要NPC、ボスとプレイヤー群など、「常に複数の重要オブジェクトを画面に収めたい」 シーンで非常に有効です。
他のスクリプトに依存していないため、別プロジェクトへのコピペやチーム内共有も容易で、ゲームカメラ周りの実装コストを大幅に削減できます。

応用として、

  • ターゲットの種類ごとに別コンポーネントを用意して、カメラを切り替える。
  • 特定のイベント中は TargetGroup を一時停止し、演出専用のカメラ制御に切り替える。
  • 3Dゲームで使う場合は、Perspective カメラ用に FOV や距離を調整するロジックに置き換える。

といった拡張も可能です。まずはこの記事のコードをベースに、自分のプロジェクトに合わせたカメラ挙動へとカスタマイズしてみてください。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!