【Cocos Creator】アタッチするだけ!FlankTactic (回り込み)の実装方法【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】FlankTactic(回り込みAI)の実装:アタッチするだけで「プレイヤーを横から追い詰める」賢い移動を実現する汎用スクリプト

このコンポーネントは、ターゲット(多くの場合プレイヤー)に対して、真正面から直線的に突っ込むのではなく、「横方向にふくらみながら距離を詰める」いわゆる“回り込み”行動を簡単に実装するための汎用AI移動コンポーネントです。

ターゲットとなるノードをインスペクタで指定し、回り込みの強さや接近速度を調整するだけで、敵キャラクターなどに「少し賢く見える」動きを与えられます。外部のGameManagerやシングルトンに一切依存せず、このスクリプト単体をアタッチするだけで動作します。


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

1. 機能要件の整理

  • 指定したターゲット(例:プレイヤー)に向かって移動するが、真正面ではなく「横方向(サイド)」にずれながら接近する。
  • 2D/3Dどちらのプロジェクトでも使えるよう、「XZ平面の3D」「XY平面の2D」の両方をサポートする。
  • ターゲットが動いていても、常に相対位置から「回り込み方向」を計算し続ける。
  • インスペクタから調整可能なパラメータのみで挙動を制御し、他のカスタムスクリプトには依存しない。
  • ターゲットが未設定・存在しない場合は安全に何もしないよう防御的に実装し、警告ログを出す。

2. 回り込みのロジック概要

フレームごとに以下のような方向ベクトルを作ります:

  1. toTarget = ターゲット位置 − 自身の位置(ターゲットへの直線方向)
  2. forwardDir = toTarget を正規化したベクトル
  3. sideDir = forwardDir に対して垂直なベクトル(左右どちらか)
  4. moveDir = normalize(forwardDir + sideDir × flankStrength)

この moveDir に沿って移動することで、「前に進みつつ横にずれる」動きになります。
flankStrength が大きいほど、横成分が強くなり、より大きく膨らんだ軌道になります。

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

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

  • target: Node | null
    • 説明:追いかけるターゲット(通常はプレイヤー)のノード。
    • 役割:このノードの位置を基準に回り込み方向を計算する。
    • 注意:未設定の場合は移動しない。警告ログを出力。
  • moveSpeed: number
    • 説明:1秒あたりの移動速度(ワールド単位)。
    • 例:2.0〜10.0 くらいを想定。ゲームのスケールに応じて調整。
  • flankStrength: number
    • 説明:回り込み(横方向)成分の強さ。
    • 0 なら真正面に向かうだけ、1 で「前+横」が同程度、2 以上で大きく円を描くような軌道。
  • flankSide: number(enum風)
    • 説明:どちら側から回り込むか。
    • 値:-1 = 左側から回り込む、1 = 右側から回り込む。
  • stopDistance: number
    • 説明:ターゲットとの距離がこの値以下になったら停止する(近づきすぎ防止)。
    • 例:0.5〜2.0 くらい。
  • use2D: boolean
    • 説明:2Dゲーム用のXY平面モードを使うかどうか。
    • true:XY平面で計算(通常の2Dゲーム)。
    • false:XZ平面で計算(3Dゲームでの平面移動)。
  • enableAutoRotate: boolean
    • 説明:移動方向に応じてノードの回転を自動で合わせるかどうか。
    • 2Dの場合:Z回転(スプライトの向き)を調整。
    • 3Dの場合:Y軸まわりに回転して前方を向ける想定。
  • rotationLerp: number
    • 説明:回転追従のなめらかさ。0〜1 の補間係数。
    • 0.1 くらいで「ゆっくり追従」、1.0 で「即座に向き変更」。
  • debugDraw: boolean
    • 説明:エディタのプレビュー実行中に移動方向などのデバッグラインを描画するかどうか。
    • 注意:Gizmos ではなく、簡易的にログや簡単な描画に留める(ここではログのみにします)。

TypeScriptコードの実装


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

/**
 * FlankTactic
 * ターゲットに対して真正面ではなく、横方向に膨らみながら接近するAI移動コンポーネント。
 * 外部のゲームマネージャなどには一切依存せず、ターゲットNodeを指定するだけで動作します。
 */
@ccclass('FlankTactic')
export class FlankTactic extends Component {

    @property({
        type: Node,
        tooltip: '追いかけるターゲット(例:プレイヤー)のノード。未設定の場合は移動しません。'
    })
    public target: Node | null = null;

    @property({
        type: CCFloat,
        tooltip: '移動速度(1秒あたりのワールド単位)。数値が大きいほど速く移動します。'
    })
    public moveSpeed: number = 3.0;

    @property({
        type: CCFloat,
        tooltip: '回り込み(横方向)成分の強さ。0で真正面のみ、1で前+横が同程度、2以上で大きく膨らみます。'
    })
    public flankStrength: number = 1.0;

    @property({
        tooltip: 'どちら側から回り込むか。-1で左側、1で右側。'
    })
    public flankSide: number = 1; // -1: left, 1: right

    @property({
        type: CCFloat,
        tooltip: 'ターゲットとの距離がこの値以下になったら停止します。0以下なら常に接近し続けます。'
    })
    public stopDistance: number = 1.0;

    @property({
        type: CCBoolean,
        tooltip: 'trueなら2Dゲーム用のXY平面として計算します。falseなら3DゲームのXZ平面として計算します。'
    })
    public use2D: boolean = true;

    @property({
        type: CCBoolean,
        tooltip: '移動方向に応じてノードの回転を自動で合わせるかどうか。'
    })
    public enableAutoRotate: boolean = true;

    @property({
        type: CCFloat,
        tooltip: '回転の追従速度(0〜1)。1で即座に向きを合わせ、0.1程度でゆっくり追従します。'
    })
    public rotationLerp: number = 0.2;

    @property({
        type: CCBoolean,
        tooltip: 'trueにすると、実行中に移動方向ベクトルなどをログ出力します(デバッグ用)。'
    })
    public debugDraw: boolean = false;

    // 内部で再利用する一時ベクトル(GC削減)
    private _tempVecA: Vec3 = new Vec3();
    private _tempVecB: Vec3 = new Vec3();

    onLoad() {
        // flankSide の値を -1 or 1 に正規化しておく
        if (this.flankSide === 0) {
            this.flankSide = 1;
        } else {
            this.flankSide = this.flankSide > 0 ? 1 : -1;
        }

        if (!this.target) {
            console.warn('[FlankTactic] target が設定されていません。ターゲットが設定されるまで移動しません。');
        }
    }

    start() {
        // ここでは特に初期化は不要だが、将来の拡張に備えて残しておく
    }

    update(deltaTime: number) {
        // ターゲットがいない場合は何もしない
        if (!this.target) {
            return;
        }

        const selfNode = this.node;

        // 自身とターゲットのワールド座標を取得
        const selfPos = selfNode.worldPosition;
        const targetPos = this.target.worldPosition;

        // ターゲットへのベクトルを計算
        Vec3.subtract(this._tempVecA, targetPos, selfPos); // toTarget = target - self

        // 2DモードならZを0に、3DモードならYを0にして平面上に投影
        if (this.use2D) {
            this._tempVecA.z = 0;
        } else {
            this._tempVecA.y = 0;
        }

        const distance = this._tempVecA.length();

        // stopDistance が正で、かつ十分近づいている場合は停止
        if (this.stopDistance > 0 && distance <= this.stopDistance) {
            return;
        }

        // 距離がほぼ0の場合は方向が定まらないので何もしない
        if (distance < 0.0001) {
            return;
        }

        // forwardDir = ターゲットへの正規化ベクトル
        const forwardDir = this._tempVecA;
        forwardDir.normalize();

        // sideDir を計算(forwardに対する垂直方向)
        const sideDir = this._tempVecB;

        if (this.use2D) {
            // XY平面での垂直ベクトル(左or右)
            // forward = (x, y, 0) に対して、left = (-y, x, 0)
            sideDir.set(-forwardDir.y, forwardDir.x, 0);
        } else {
            // XZ平面での垂直ベクトル(左or右)
            // forward = (x, 0, z) に対して、left = (-z, 0, x)
            sideDir.set(-forwardDir.z, 0, forwardDir.x);
        }

        sideDir.normalize();
        sideDir.multiplyScalar(this.flankStrength * this.flankSide);

        // moveDir = forward + side を正規化
        const moveDir = new Vec3();
        Vec3.add(moveDir, forwardDir, sideDir);

        // 万が一ゼロベクトルになったら、forwardのみで動く
        if (moveDir.length() < 0.0001) {
            moveDir.set(forwardDir);
        } else {
            moveDir.normalize();
        }

        // 実際の移動量 = moveDir * moveSpeed * deltaTime
        const moveDelta = new Vec3();
        Vec3.multiplyScalar(moveDelta, moveDir, this.moveSpeed * deltaTime);

        // 現在位置に加算して新しい位置を設定
        const newPos = new Vec3();
        Vec3.add(newPos, selfPos, moveDelta);
        selfNode.setWorldPosition(newPos);

        // 自動回転
        if (this.enableAutoRotate) {
            this._applyAutoRotation(moveDir, deltaTime);
        }

        // デバッグログ
        if (this.debugDraw) {
            console.log('[FlankTactic] moveDir =', moveDir.toString(), 'distance =', distance.toFixed(3));
        }
    }

    /**
     * 移動方向に応じてノードの回転を自動で合わせる。
     * 2D: Z回転を方向に合わせる
     * 3D: Y軸回りに回転して前方を向ける(前方は +Z を想定)
     */
    private _applyAutoRotation(moveDir: Vec3, deltaTime: number) {
        const node = this.node;

        if (this.use2D) {
            // 2Dの場合:XY平面で方向ベクトルから角度を計算し、Z回転に反映
            // atan2(y, x) を度数に変換
            const angleRad = Math.atan2(moveDir.y, moveDir.x);
            const angleDeg = math.toDegree(angleRad);

            // Cocosの2Dスプライトは通常、右向きが0度なのでそのまま適用
            const currentEuler = node.eulerAngles;
            const targetEuler = new Vec3(currentEuler.x, currentEuler.y, angleDeg);

            // 線形補間でなめらかに回転
            const lerpFactor = math.clamp01(this.rotationLerp);
            const newEuler = new Vec3(
                math.lerp(currentEuler.x, targetEuler.x, lerpFactor),
                math.lerp(currentEuler.y, targetEuler.y, lerpFactor),
                math.lerp(currentEuler.z, targetEuler.z, lerpFactor)
            );
            node.setRotationFromEuler(newEuler);
        } else {
            // 3Dの場合:XZ平面の方向からY軸回転を計算
            const angleRad = Math.atan2(moveDir.x, moveDir.z); // Zを前方とする
            const angleDeg = math.toDegree(angleRad);

            const currentEuler = node.eulerAngles;
            const targetEuler = new Vec3(currentEuler.x, angleDeg, currentEuler.z);

            const lerpFactor = math.clamp01(this.rotationLerp);
            const newEuler = new Vec3(
                math.lerp(currentEuler.x, targetEuler.x, lerpFactor),
                math.lerp(currentEuler.y, targetEuler.y, lerpFactor),
                math.lerp(currentEuler.z, targetEuler.z, lerpFactor)
            );
            node.setRotationFromEuler(newEuler);
        }
    }
}

コードの主要ポイント解説

  • onLoad()
    • flankSide を -1 または 1 に正規化しています。
    • target が未設定の場合、警告ログを出しておきます(実行時に気づきやすくするため)。
  • update(deltaTime)
    • ターゲットが存在しない場合は即 return し、安全に何もしません。
    • 自身とターゲットのワールド座標を取得し、差分から toTarget ベクトルを計算します。
    • use2D に応じて、XY または XZ 平面に投影してから距離と方向を求めます。
    • stopDistance 以内に入ったら停止し、これ以上近づかないようにします。
    • forward(ターゲット方向)と side(垂直方向)を合成して moveDir を作り、正規化して移動方向にします。
    • 移動量 = moveDir × moveSpeed × deltaTime を現在位置に加算し、新しいワールド座標として設定します。
    • enableAutoRotate が true の場合は、_applyAutoRotation() で向きを合わせます。
  • _applyAutoRotation(moveDir, deltaTime)
    • 2Dモードでは、atan2(y, x) からZ回転角を算出し、スプライトの向きを移動方向に合わせます。
    • 3Dモードでは、XZ平面上の方向からY軸回転を算出し、+Z を前方とするモデルが移動方向を向くように調整します。
    • rotationLerp を用いて現在のオイラー角との線形補間を行い、急激な向き変更を避けて自然な回転にします。

使用手順と動作確認

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

  1. Assets パネルで右クリックします。
  2. Create > TypeScript を選択します。
  3. ファイル名を FlankTactic.ts にします。
  4. 作成された FlankTactic.ts をダブルクリックしてエディタ(VSCode など)で開き、本文のコードをすべて貼り付けて上書き保存します。

2. テスト用シーンの準備(2Dの場合の例)

  1. Hierarchy パネルで右クリックし、Create > 2D Object > Sprite を選択して、プレイヤー用のノード(例:Player)を作成します。
  2. 同様にして、敵キャラクター用のノード(例:Enemy)となる Sprite を作成します。
  3. シーン上で PlayerEnemy の位置を適当に離して配置します(例:Player を (0,0)、Enemy を (-300, 0) など)。

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

  1. Hierarchy で Enemy ノードを選択します。
  2. Inspector の下部にある Add Component ボタンをクリックします。
  3. Custom カテゴリから FlankTactic を選択して追加します。

4. プロパティの設定

Enemy の Inspector に表示された FlankTactic の各プロパティを設定します。

  • Target:
    • Hierarchy から Player ノードをドラッグ&ドロップして、このフィールドにセットします。
  • Move Speed:
    • 例:36 くらいに設定して、動きが見やすい速度にします。
  • Flank Strength:
    • まずは 1.0 に設定して、自然な回り込みを確認してみます。
    • より大きく円を描くように回り込ませたい場合は 2.03.0 に上げてみてください。
  • Flank Side:
    • 1: 右側から回り込む。
    • -1: 左側から回り込む。
    • 左右の違いを見たい場合は、値を切り替えて再生してみましょう。
  • Stop Distance:
    • 例:1.0 に設定すると、ターゲットにほぼ接触したら停止します。
    • 常にぐるぐる回り込ませたい場合は 0 か、かなり小さい値にしておきます。
  • Use 2D:
    • 2Dプロジェクトの場合は true のままでOKです。
  • Enable Auto Rotate:
    • スプライトの向きも移動方向に合わせたい場合は true にします。
    • ビルボードなどで常に一定の向きにしたい場合は false にします。
  • Rotation Lerp:
    • 例:0.20.3 くらいにすると、なめらかに向きを変えます。
    • 素早く向きを変えたい場合は 0.81.0 にします。
  • Debug Draw:
    • 挙動を確認したいときは true にして、Console に出るログを見ながらチューニングします。

5. 再生して動作確認

  1. エディタ上部の Preview(再生ボタン)をクリックしてゲームを再生します。
  2. シーンビューまたはゲームビューで、EnemyPlayer に対して真正面ではなく、横方向に膨らみながら接近していく様子を確認します。
  3. Flank Strength を変えながら再生し直し、軌道の違いを確認してみてください。
  4. Flank Side1-1 で切り替えて、左右どちらから回り込むかを比較してみましょう。

6. 3Dプロジェクトでの利用例

3Dでキャラクターが地面上を移動するゲームでも、ほぼ同じ手順で使えます。

  1. 3Dシーンで Player(例:Capsule、Characterモデルなど)と Enemy を配置します。
  2. EnemyFlankTactic をアタッチします。
  3. Use 2Dfalse に設定します(XZ平面で計算するため)。
  4. その他のプロパティ(Move Speed, Flank Strength など)を2Dと同様に調整します。
  5. 再生すると、Enemy が地面上を回り込みながら Player に接近していく挙動を確認できます。

まとめ

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

  • ターゲットとなるノードをインスペクタで指定するだけ
  • 外部のGameManagerやシングルトンに一切依存しない
  • 2D/3Dどちらにも対応
  • 回り込みの強さ・左右・停止距離・自動回転などをプロパティで柔軟に調整可能

という特徴を持つ、汎用的な「回り込みAI移動」スクリプトです。

敵キャラクターにこのコンポーネントをアタッチするだけで、「ただ真っ直ぐ突っ込んでくるだけ」の単調なAIから一歩進んだ、横から攻めてくるような賢い印象の挙動を簡単に実現できます。
複数の敵に対して FlankStrengthFlankSide をランダムに変えることで、それぞれ違う軌道でプレイヤーを包囲するような演出も、このコンポーネント単体で完結して行えます。

プロジェクトに組み込んだら、まずは1体の敵で挙動を確認し、その後はPrefab化して量産することで、ゲーム全体のAI実装を効率的に進められるはずです。

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をコピーしました!