【Cocos Creator 3.8】TurretAI(固定砲台)の実装:アタッチするだけで「一定範囲内のターゲットを自動で追尾・照準」する汎用スクリプト

このガイドでは、Cocos Creator 3.8.7 + TypeScript で、ノードにアタッチするだけで「指定したターゲットを検知し、常に銃口(ノードの前方)を向け続ける」固定砲台コンポーネント TurretAI を実装します。

ターゲットの参照や検知範囲、回転速度などはすべてインスペクタから設定可能で、外部の GameManager やシングルトンに一切依存しない、シーン内どこでも再利用できる汎用設計になっています。


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

1. 要件整理

  • 砲台(Turret)は自分自身は移動しない
  • 指定した検知半径内にターゲットが入ったときのみ、ターゲットの方向に回転して銃口を向ける
  • ターゲットが範囲外に出た場合は、回転を停止(もしくはアイドル角度へ戻すオプション)。
  • 回転速度や検知半径は、インスペクタから調整可能
  • 2D/3D の両方で扱いやすいように、「どの軸を砲台の向きとして扱うか」を選択可能にする。
  • 外部スクリプトに依存しないため、ターゲットの指定も
    • 直接 Node を指定する方法
    • タグ(名前の一部)で自動探索する方法

    を併用できるようにする。

2. 動作イメージ

  • 毎フレーム update でターゲットとの距離と方向を計算。
  • 検知半径内であれば、現在の向きからターゲット方向へ補間回転(一定の角速度)。
  • 検知半径外であれば、オプションで初期角度へ戻るか、その場で停止。

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

以下のような @property を用意します。

  • ターゲット関連
    • targetNode: Node | null
      直接追尾したいターゲットノードを指定します。明示的に設定されている場合はこれを最優先で使用します。
    • autoSearchByName: boolean
      有効にすると、シーン内から名前に特定文字列を含むノードを自動検索します。
    • targetNameContains: string
      自動検索時に名前に含まれているべき文字列(例: "Enemy")。空文字列の場合は検索しません。
    • searchInterval: number
      自動検索を行う間隔(秒)。負荷を抑えるために 0.2〜1.0 秒程度を推奨します。
  • 検知・回転設定
    • detectRadius: number
      ターゲットを検知する半径(ワールド座標系、単位はユニット)。この距離以内にターゲットが入ったときのみ追尾します。
    • rotationSpeedDeg: number
      1 秒あたりの最大回転速度(度)。大きいほど素早くターゲットに向きます。
    • onlyRotateWhenInRange: boolean
      有効な場合、ターゲットが検知範囲外にいるときは回転しません。無効な場合は、常にターゲット方向に向き続けます(距離チェックのみ停止条件として使わない)。
  • 回転軸・向き設定
    • use2DMode: boolean
      2D ゲーム前提で Z 回転のみを扱うかどうか。
      – 有効: ノードの Z 軸回転を変更し、「+X 方向」または「+Y 方向」を砲身とみなします。
      – 無効: 3D 用として Y 軸回転(水平回転)のみを扱います。
    • forwardAxis2D: number(enum 的に 0: +X, 1: +Y)
      2D モード時、どの軸を「砲台の前方」とみなすかを選択します。
    • forwardAxis3D: number(enum 的に 0: +Z, 1: +X)
      3D モード時、どの軸を「砲台の前方」とみなすか。一般的な TPS/FPS では +Z を前方とします。
    • returnToInitialWhenNoTarget: boolean
      検知範囲内にターゲットがいないとき、初期角度にゆっくり戻るかどうか。
    • returnSpeedDeg: number
      初期角度へ戻るときの回転速度(度/秒)。
  • デバッグ表示
    • debugLog: boolean
      有効にすると、ターゲット未検出などの状況を console.log に出力します。

この設計により、

  • 「特定の敵ノードを直接指定して追尾」
  • 「名前に ‘Enemy’ を含む最初のノードを自動追尾」
  • 「2D/3D どちらのゲームでも使い回し」

といった柔軟な使い方が可能になります。


TypeScriptコードの実装


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

/**
 * TurretAI
 * - 固定砲台が、指定したターゲットに対して自動的に向きを合わせるコンポーネント
 * - 外部スクリプトに依存せず、インスペクタ設定だけで動作
 */
@ccclass('TurretAI')
export class TurretAI extends Component {

    // ===== ターゲット関連 =====

    @property({
        tooltip: '追尾するターゲットノード。\nここに直接指定されている場合は最優先で使用されます。'
    })
    public targetNode: Node | null = null;

    @property({
        tooltip: '有効にすると、シーン内から名前に特定文字列を含むノードを一定間隔で自動検索します。'
    })
    public autoSearchByName: boolean = false;

    @property({
        tooltip: '自動検索時に、名前にこの文字列を含むノードを探します。\n例: "Enemy"。空文字の場合は検索しません。'
    })
    public targetNameContains: string = 'Enemy';

    @property({
        tooltip: '自動検索を行う間隔(秒)。\n0.2〜1.0秒程度を推奨します。',
        min: 0.1
    })
    public searchInterval: number = 0.5;

    // ===== 検知・回転設定 =====

    @property({
        tooltip: 'ターゲットを検知する半径(ワールド座標、ユニット)。\nこの距離以内にターゲットが入ったときのみ追尾します。',
        min: 0
    })
    public detectRadius: number = 10;

    @property({
        tooltip: 'ターゲットに向かうときの最大回転速度(度/秒)。\n大きいほど素早く向きます。',
        min: 0
    })
    public rotationSpeedDeg: number = 180;

    @property({
        tooltip: '有効な場合、ターゲットが検知半径外にいるときは回転しません。\n無効な場合は、距離に関係なく常にターゲット方向に向きます。'
    })
    public onlyRotateWhenInRange: boolean = true;

    // ===== 回転軸・向き設定 =====

    @property({
        tooltip: '2Dゲーム前提でZ回転のみを扱うかどうか。\n有効: Z回転(+X/+Yを前方とみなす)\n無効: 3D用にY回転(水平回転)を扱う'
    })
    public use2DMode: boolean = true;

    @property({
        tooltip: '2Dモード時、どの軸を「砲台の前方」とみなすか。\n0: +X 方向が前\n1: +Y 方向が前'
    })
    public forwardAxis2D: number = 0; // 0:+X, 1:+Y

    @property({
        tooltip: '3Dモード時、どの軸を「砲台の前方」とみなすか。\n0: +Z 方向が前(一般的)\n1: +X 方向が前'
    })
    public forwardAxis3D: number = 0; // 0:+Z, 1:+X

    @property({
        tooltip: '検知範囲内にターゲットがいないとき、初期角度に戻るかどうか。'
    })
    public returnToInitialWhenNoTarget: boolean = true;

    @property({
        tooltip: '初期角度へ戻るときの回転速度(度/秒)。',
        min: 0
    })
    public returnSpeedDeg: number = 90;

    // ===== デバッグ =====

    @property({
        tooltip: '有効にすると、ターゲット未検出などの状況をログ出力します。'
    })
    public debugLog: boolean = false;

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

    private _initialRotation: Quat = new Quat();
    private _timeSinceLastSearch: number = 0;
    private _cachedTransformWorldPos: Vec3 = new Vec3();
    private _cachedTargetWorldPos: Vec3 = new Vec3();

    onLoad() {
        // 初期回転を保存(ターゲットがいないときに戻すため)
        this.node.getWorldRotation(this._initialRotation);

        if (this.debugLog) {
            console.log('[TurretAI] onLoad: initial rotation =', this._initialRotation);
        }
    }

    start() {
        // 特別な標準コンポーネント依存はないが、
        // Transform(Node)は必須なので一応チェック
        if (!this.node) {
            console.error('[TurretAI] Node が存在しません。コンポーネントのアタッチ先を確認してください。');
        }

        // 初回にターゲットが設定されておらず、自動検索が有効なら即座に一度検索
        if (!this.targetNode && this.autoSearchByName) {
            this._searchTarget();
        }
    }

    update(deltaTime: number) {
        // ターゲットが未指定で、自動検索が有効なら一定間隔で探索
        if (!this.targetNode && this.autoSearchByName) {
            this._timeSinceLastSearch += deltaTime;
            if (this._timeSinceLastSearch >= this.searchInterval) {
                this._timeSinceLastSearch = 0;
                this._searchTarget();
            }
        }

        // ターゲットが存在しない場合
        if (!this.targetNode) {
            if (this.returnToInitialWhenNoTarget) {
                this._rotateBackToInitial(deltaTime);
            }
            return;
        }

        // ターゲットまでの距離を計算
        this.node.getWorldPosition(this._cachedTransformWorldPos);
        this.targetNode.getWorldPosition(this._cachedTargetWorldPos);

        const distance = Vec3.distance(this._cachedTransformWorldPos, this._cachedTargetWorldPos);

        const inRange = distance <= this.detectRadius;

        if (this.onlyRotateWhenInRange && !inRange) {
            // 範囲外の場合、初期角度へ戻るか停止
            if (this.returnToInitialWhenNoTarget) {
                this._rotateBackToInitial(deltaTime);
            }
            return;
        }

        // ターゲット方向に回転
        this._rotateTowardsTarget(deltaTime);
    }

    /**
     * シーン内から名前に特定文字列を含むノードを検索する
     */
    private _searchTarget() {
        if (!this.targetNameContains || this.targetNameContains.trim().length === 0) {
            if (this.debugLog) {
                console.log('[TurretAI] targetNameContains が空のため、自動検索をスキップします。');
            }
            return;
        }

        const scene = director.getScene();
        if (!scene) {
            console.error('[TurretAI] シーンが読み込まれていません。ターゲット検索に失敗しました。');
            return;
        }

        const rootNodes = scene.children;
        let found: Node | null = null;

        // 幅広く探索するため、シーン全体を DFS で検索
        const stack: Node[] = [...rootNodes];
        while (stack.length > 0) {
            const node = stack.pop()!;
            if (node.name.indexOf(this.targetNameContains) !== -1) {
                found = node;
                break;
            }
            for (const child of node.children) {
                stack.push(child);
            }
        }

        if (found) {
            this.targetNode = found;
            if (this.debugLog) {
                console.log(`[TurretAI] ターゲットを検出しました: ${found.name}`);
            }
        } else {
            if (this.debugLog) {
                console.log(`[TurretAI] ターゲット名に "${this.targetNameContains}" を含むノードが見つかりませんでした。`);
            }
        }
    }

    /**
     * ターゲット方向に回転させる(2D/3D 両対応)
     */
    private _rotateTowardsTarget(deltaTime: number) {
        // 方向ベクトルを計算
        this.node.getWorldPosition(this._cachedTransformWorldPos);
        this.targetNode!.getWorldPosition(this._cachedTargetWorldPos);

        const dir = new Vec3();
        Vec3.subtract(dir, this._cachedTargetWorldPos, this._cachedTransformWorldPos);

        if (this.use2DMode) {
            this._rotate2D(dir, deltaTime);
        } else {
            this._rotate3D(dir, deltaTime);
        }
    }

    /**
     * 2D モードでの回転処理(Z 回転のみ)
     */
    private _rotate2D(direction: Vec3, deltaTime: number) {
        // Z平面上に投影(Z成分は無視)
        direction.z = 0;
        if (direction.lengthSqr() === 0) {
            return;
        }
        direction.normalize();

        // forwardAxis2D に応じて目標角度を計算
        // +X を前方とみなす場合: atan2(dir.y, dir.x)
        // +Y を前方とみなす場合: atan2(dir.x, dir.y) - 90度 などでも可だが、
        // ここでは +Y 前方を「+X 前方から 90度回転したもの」として扱う
        let targetAngleDeg = 0;

        if (this.forwardAxis2D === 0) {
            // +X 前方
            const rad = Math.atan2(direction.y, direction.x);
            targetAngleDeg = math.toDegree(rad);
        } else {
            // +Y 前方: +X 前方から 90度回転したとみなす
            const rad = Math.atan2(direction.x, direction.y);
            targetAngleDeg = math.toDegree(rad);
        }

        // 現在の Z 回転角度を取得
        const currentRot = this.node.eulerAngles;
        const currentZ = currentRot.z;

        // 角度差を -180〜180 の範囲に正規化
        let deltaAngle = targetAngleDeg - currentZ;
        deltaAngle = math.repeat(deltaAngle + 180, 360) - 180;

        // このフレームで回転できる最大角度
        const maxStep = this.rotationSpeedDeg * deltaTime;

        // 実際に回転させる角度
        const step = math.clamp(deltaAngle, -maxStep, maxStep);

        const newZ = currentZ + step;
        this.node.setRotationFromEuler(currentRot.x, currentRot.y, newZ);
    }

    /**
     * 3D モードでの回転処理(Y 回転のみ・水平回転)
     */
    private _rotate3D(direction: Vec3, deltaTime: number) {
        // 水平面に投影(Y成分は無視)
        direction.y = 0;
        if (direction.lengthSqr() === 0) {
            return;
        }
        direction.normalize();

        // forwardAxis3D に応じて目標角度を計算
        // +Z 前方: atan2(dir.x, dir.z)
        // +X 前方: atan2(dir.z, dir.x)
        let targetAngleDegY = 0;

        if (this.forwardAxis3D === 0) {
            // +Z 前方
            const rad = Math.atan2(direction.x, direction.z);
            targetAngleDegY = math.toDegree(rad);
        } else {
            // +X 前方
            const rad = Math.atan2(direction.z, direction.x);
            targetAngleDegY = math.toDegree(rad);
        }

        // 現在の Y 回転角度を取得
        const currentRot = this.node.eulerAngles;
        const currentY = currentRot.y;

        // 角度差を -180〜180 に正規化
        let deltaAngle = targetAngleDegY - currentY;
        deltaAngle = math.repeat(deltaAngle + 180, 360) - 180;

        const maxStep = this.rotationSpeedDeg * deltaTime;
        const step = math.clamp(deltaAngle, -maxStep, maxStep);

        const newY = currentY + step;
        this.node.setRotationFromEuler(currentRot.x, newY, currentRot.z);
    }

    /**
     * ターゲット不在時に初期角度へ戻す
     */
    private _rotateBackToInitial(deltaTime: number) {
        if (!this.returnToInitialWhenNoTarget) {
            return;
        }

        // 現在の回転
        const currentRot = this.node.rotation;
        const targetRot = this._initialRotation;

        // クォータニオン間の角度差を求める
        const angleRad = Quat.getAngle(currentRot, targetRot);
        const angleDeg = math.toDegree(angleRad);

        if (angleDeg < 0.1) {
            // ほぼ一致しているので終了
            this.node.setWorldRotation(targetRot);
            return;
        }

        const maxStepDeg = this.returnSpeedDeg * deltaTime;
        const t = math.clamp(maxStepDeg / angleDeg, 0, 1);

        const newRot = new Quat();
        Quat.slerp(newRot, currentRot, targetRot, t);
        this.node.setWorldRotation(newRot);
    }
}

コードの要点解説

  • onLoad
    – 砲台ノードの初期回転_initialRotation に保存します。
    – 後でターゲットがいないときに、この角度へ戻すために使用します。
  • start
    – 特別な標準コンポーネント依存はありませんが、this.node の存在をチェックして防御的にログ出力。
    targetNode が未設定かつ autoSearchByName が有効な場合、起動直後に 1 回だけターゲット検索を行います。
  • update
    • 自動検索が有効な場合、searchInterval ごとにシーン全体からターゲット候補を探します。
    • ターゲットが見つからない場合は、returnToInitialWhenNoTarget が有効なら初期角度へ戻る処理を行います。
    • ターゲットが存在するときは、距離を計算し、
      • onlyRotateWhenInRange が有効かつ範囲外 → 初期角度へ戻る/停止
      • それ以外 → _rotateTowardsTarget でターゲット方向へ回転
  • _searchTarget
    director.getScene() からシーンルートを取得し、DFS で全ノードを走査して、nametargetNameContains を含む最初のノードをターゲットにします。
    – 見つからなかった場合は、debugLog が有効ならログを出します。
  • _rotate2D
    – 2D 用に、Z 回転のみを変更します。
    forwardAxis2D によって、「+X 前方」か「+Y 前方」かを切り替え、atan2 から目標角度を求めます。
    – 現在角度との差を -180〜180 度に正規化し、rotationSpeedDeg に基づいて 1 フレームで回転できる最大角度を制限して滑らかに回転させます。
  • _rotate3D
    – 3D 用に、水平面(Y 軸)での回転のみを扱います。
    – 方向ベクトルから Y 軸回りの角度を求め、2D と同様に角速度を制限しながら回転します。
  • _rotateBackToInitial
    – 現在の回転と初期回転の間の角度差をクォータニオンで計算し、returnSpeedDeg に応じて Quat.slerp(球面線形補間)で徐々に初期角度へ戻します。

使用手順と動作確認

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

  1. エディタ左下の Assets パネルで、スクリプトを置きたいフォルダ(例: assets/scripts)を選択します。
  2. 右クリック → CreateTypeScript を選択します。
  3. ファイル名を TurretAI.ts と入力して作成します。
  4. 作成された TurretAI.ts をダブルクリックし、先ほどのコード全体をコピー&ペーストして上書き保存します。

2. テスト用ノード(砲台)の作成

2D ゲームを例に説明します(3D でも基本は同じです)。

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択し、砲台用のノードを作成します。
  2. 作成されたノードの名前を分かりやすく Turret などに変更します。
  3. Inspector で Sprite の画像(砲台の見た目)を設定します。
    • 砲身がどの方向を向いている画像かを確認しておきます。
      • 右向き(+X)なら、forwardAxis2D = 0(+X) を使う想定。
      • 上向き(+Y)なら、forwardAxis2D = 1(+Y) を使う想定。

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

  1. Hierarchy で先ほど作成した Turret ノードを選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom ScriptTurretAI を選択して追加します。
    • もし一覧に TurretAI が表示されない場合は、
      • スクリプト名と @ccclass('TurretAI') が一致しているか確認。
      • エディタメニューの ProjectDeveloperVS Codeでスクリプトを再コンパイル などでビルドをやり直します。

4. ターゲット(敵ノード)の作成

  1. Hierarchy パネルで右クリック → Create2D ObjectSprite を選択し、敵用ノードを作成します。
  2. 名前を Enemy_1 などに変更します(Enemy を含めておくと自動検索の例に使えます)。
  3. 敵ノードの位置を、砲台から少し離れた場所に配置します。
    • 例: 砲台 (0, 0)、敵 (300, 100) など。

5. TurretAI のプロパティ設定例(直接ターゲット指定)

もっともシンプルな「直接ターゲット指定」の例です。

  1. Turret ノードを選択し、InspectorTurretAI コンポーネントを確認します。
  2. 以下のように設定します(2D 右向き砲台の想定):
    • targetNode: Hierarchy から Enemy_1 をドラッグ&ドロップ
    • autoSearchByName: チェック オフ
    • targetNameContains: (何も入力しなくて良い)
    • searchInterval: 0.5(デフォルトのまま)
    • detectRadius: 1000(まずは広めにして常に追尾するかを確認)
    • rotationSpeedDeg: 180
    • onlyRotateWhenInRange: チェック オフ(距離に関係なく向くように)
    • use2DMode: チェック オン
    • forwardAxis2D: 0(スプライトが右向きの場合)
    • forwardAxis3D: 0(この例では使用されません)
    • returnToInitialWhenNoTarget: チェック オン
    • returnSpeedDeg: 90
    • debugLog: 動作確認したい場合はオン
  3. Preview(再生ボタン)でゲームを実行します。
  4. 実行中に Scene ビューまたは Game ビューで Enemy_1 をドラッグして動かしてみてください。
    • 砲台が常に敵方向に回転し、銃口を向け続けることが確認できます。

6. TurretAI のプロパティ設定例(名前による自動ターゲット探索)

次に、「敵を直接指定せず、名前に Enemy を含むノードを自動で見つけて追尾」する例です。

  1. Turret ノードの TurretAI コンポーネントで、以下のように設定します。
    • targetNode: null(空のままにする)
    • autoSearchByName: チェック オン
    • targetNameContains: Enemy
    • searchInterval: 0.5(または 0.2 など)
    • detectRadius: 500
    • rotationSpeedDeg: 180
    • onlyRotateWhenInRange: チェック オン
    • 他の設定は先ほどと同様
  2. ゲームを再生すると、シーン内にある Enemy を名前に含むノードが自動的に検出され、範囲内に入ると砲台が追尾します。
  3. 敵ノードを範囲外(detectRadius より遠い位置)に移動させると、砲台が初期角度へ戻る動きが確認できます。

7. 3D ゲームでの利用(簡易手順)

3D の場合も基本は同じですが、以下の点を変更します。

  1. Hierarchy で Create3D ObjectNode または 3D Model を作成し、砲台モデルを置きます。
  2. 砲台ノードに TurretAI をアタッチします。
  3. Inspector で以下のように設定します。
    • use2DMode: チェック オフ
    • forwardAxis3D: モデルの前方が +Z なら 0、+X なら 1
    • 他のプロパティは 2D と同様に設定
  4. 敵キャラ(3D モデル)用ノードを作成し、targetNode に指定するか、名前に Enemy を含めて自動検索させます。
  5. 再生して、敵が砲台の周囲を動くと、砲台が水平に回転して追尾することを確認できます。

まとめ

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

  • 単一のスクリプトファイルだけで完結しており、外部の GameManager やシングルトンに依存しません。
  • ターゲット指定・自動検索・検知半径・回転速度・2D/3D 切り替えなど、すべてインスペクタから調整可能です。
  • 2D のタワーディフェンス、3D の固定砲台、監視カメラ、追尾レーザーなど、さまざまなシーンでそのまま再利用できます。

応用としては、

  • 砲台がターゲットを向いているときだけ弾を発射する「射撃コンポーネント」を別スクリプトで追加する。
  • 検知範囲を detectRadius ではなく、コライダー(CircleCollider 等)と連動させたい場合は、コライダーの OnTriggerEnter/Exit と組み合わせる。
  • ターゲットを単一ではなく、シーン内の複数候補から「最も近い敵」などを選ぶロジックを _searchTarget に拡張する。

まずは本記事の TurretAI をそのままプロジェクトに組み込み、砲台ノードにアタッチして挙動を確認してみてください。
その上で、ゲームの仕様に合わせてプロパティやロジックを少しずつカスタマイズしていけば、汎用的かつ再利用性の高い「固定砲台 AI」として、さまざまなタイトルで活躍してくれます。