【Cocos Creator 3.8】TeleportAI の実装:アタッチするだけで「プレイヤーの背後や死角にワープ移動する敵AI」を実現する汎用スクリプト

このガイドでは、任意のノードにアタッチするだけで、一定間隔でプレイヤーの背後や死角へワープ移動する AI を実装します。
敵キャラクターなどに取り付けることで、「歩かない瞬間移動タイプの敵」「視界外から突然現れる敵」といった挙動を簡単に再現できます。

外部の GameManager やシングルトンには一切依存せず、プレイヤーのノード参照や各種パラメータはすべてインスペクタから設定できるように設計します。


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

実現したい挙動

  • このコンポーネントをアタッチしたノード(敵など)が、一定間隔でプレイヤーの周囲にワープする。
  • ワープ先は主に「背後」や「死角」を狙う:
    • プレイヤーが向いている方向の逆側(背後)に出現しやすくする。
    • プレイヤーの視界角度(FOV)を設定し、その外側にワープすることで「死角」感を演出する。
  • ワープ時に、敵がプレイヤーから近すぎたり遠すぎたりしないよう、最小距離・最大距離を設定できる。
  • ワープするごとに、オプションでエフェクトノードの表示 / 非表示や、サウンド再生(将来拡張用のフック)を想定した簡単なイベントを用意。

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

  • プレイヤーノードは @property(Node) としてインスペクタから直接指定する。
  • プレイヤーの「向き」は、2Dゲームを想定し、
    • 主に プレイヤーノードの rotation / angle か、
    • もしくは「右向きベクトル」を Vec3.RIGHT として扱い、プレイヤーの worldRotation を使って回転させる。
  • ワープロジックはコンポーネント内部に完結させ、GameManager などの他スクリプトを一切参照しない。
  • エフェクト用ノード(ワープ時に一瞬表示したいパーティクルなど)は、任意指定(null 許容)として、防御的にチェックする。

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

以下のようなパラメータを用意します。

  • playerNode: Node
    • ワープの基準となるプレイヤーのノード。
    • 必須。未設定の場合はワープ処理を行わず、エラーログを出す。
  • teleportInterval: number
    • ワープを行う間隔(秒)。
    • 例: 2.0 にすると、約 2 秒ごとにワープ候補を生成。
    • 0 以下の値が設定された場合は、自動的に 0.1 秒にクランプして安全に動作させる。
  • minDistance: number
    • プレイヤーからの最小距離。これより近い位置にはワープしない。
    • 例: 1.0 〜 2.0 程度。
  • maxDistance: number
    • プレイヤーからの最大距離。これより遠い位置にはワープしない。
    • 例: 3.0 〜 6.0 程度。
    • minDistance > maxDistance になっている場合は、自動的に入れ替えて防御的に処理。
  • backPreference: number (0〜1)
    • どれくらい「背後」を優先するか。
    • 1.0 ならほぼ背後のみ、0.0 なら背後優先なし(全周ランダム)。
  • playerFovAngle: number
    • プレイヤーの視界角度(度数法)。
    • 例: 90 にすると、前方 90° を視界とみなす。
    • この角度の外側を「死角」として優先的にワープ位置に選ぶ。
  • randomHeightOffset: number
    • 2D/3D どちらでも使えるよう、Y 方向のランダムオフセットの最大値。
    • 0 の場合は、プレイヤーと同じ高さにワープ。
    • 例: 0.5 にすると、±0.5 の範囲でランダムに高さをずらす。
  • useWorldSpace: boolean
    • true: プレイヤーと敵の位置を ワールド座標 で扱う。
    • false: 親ノードを共有しているローカル座標として扱う。
    • 通常は true 推奨。
  • teleportEffectNode: Node | null
    • 任意指定。ワープ時に一瞬表示するエフェクトノード。
    • 指定されている場合、ワープの瞬間にそのノードを敵の位置に移動して有効化 → 一定時間後に自動で非表示。
  • effectDuration: number
    • teleportEffectNode を表示しておく時間(秒)。
    • 0 以下の場合は無視。
  • debugLog: boolean
    • ワープ位置やエラーなどのログをコンソールに出すかどうか。
    • 開発中は true、本番ビルドでは false 推奨。

TypeScriptコードの実装


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

/**
 * TeleportAI
 * プレイヤーの背後や死角にワープする汎用 AI コンポーネント
 */
@ccclass('TeleportAI')
export class TeleportAI extends Component {

    @property({
        type: Node,
        tooltip: 'ワープの基準となるプレイヤーノード。\nここで指定したノードの位置・向きを元にワープ位置を計算します。'
    })
    public playerNode: Node | null = null;

    @property({
        tooltip: 'ワープを行う間隔(秒)。\n例: 2.0 で約2秒ごとにワープします。0以下は自動的に0.1秒にクランプされます。'
    })
    public teleportInterval: number = 2.0;

    @property({
        tooltip: 'プレイヤーからの最小距離。\nこれより近い場所にはワープしません。'
    })
    public minDistance: number = 2.0;

    @property({
        tooltip: 'プレイヤーからの最大距離。\nこれより遠い場所にはワープしません。'
    })
    public maxDistance: number = 4.0;

    @property({
        range: [0, 1, 0.01],
        tooltip: '背後へのワープをどれくらい優先するか(0〜1)。\n1に近いほどプレイヤーの背後に出現しやすくなります。'
    })
    public backPreference: number = 0.8;

    @property({
        tooltip: 'プレイヤーの視界角度(度)。\nこの角度の外側(死角)にワープ位置を優先的に選びます。'
    })
    public playerFovAngle: number = 90;

    @property({
        tooltip: 'Y方向のランダムオフセットの最大値。\n0の場合はプレイヤーと同じ高さにワープします。'
    })
    public randomHeightOffset: number = 0.0;

    @property({
        tooltip: 'true: ワールド座標で計算します。\nfalse: 親ノードを共有している前提でローカル座標で計算します。'
    })
    public useWorldSpace: boolean = true;

    @property({
        type: Node,
        tooltip: 'ワープ時に表示するエフェクトノード(任意)。\n指定された場合、ワープの瞬間にこのノードを敵の位置に移動し、有効化します。'
    })
    public teleportEffectNode: Node | null = null;

    @property({
        tooltip: 'エフェクトノードを表示しておく時間(秒)。\n0以下の場合は即座に非表示にします。'
    })
    public effectDuration: number = 0.3;

    @property({
        tooltip: 'デバッグログを有効にするかどうか。開発中のみtrue推奨。'
    })
    public debugLog: boolean = false;

    // 内部用タイマー
    private _timer: number = 0;

    // 再利用用ベクトル(GC削減)
    private _tempVecA: Vec3 = new Vec3();
    private _tempVecB: Vec3 = new Vec3();

    onLoad() {
        // パラメータの防御的補正
        if (this.teleportInterval <= 0) {
            if (this.debugLog) {
                console.warn('[TeleportAI] teleportInterval が 0 以下のため 0.1 に補正します。');
            }
            this.teleportInterval = 0.1;
        }

        if (this.minDistance < 0) {
            if (this.debugLog) {
                console.warn('[TeleportAI] minDistance が 0 未満のため 0 に補正します。');
            }
            this.minDistance = 0;
        }

        if (this.maxDistance <= 0) {
            if (this.debugLog) {
                console.warn('[TeleportAI] maxDistance が 0 以下のため 1 に補正します。');
            }
            this.maxDistance = 1;
        }

        if (this.minDistance > this.maxDistance) {
            if (this.debugLog) {
                console.warn('[TeleportAI] minDistance > maxDistance のため値を入れ替えます。');
            }
            const tmp = this.minDistance;
            this.minDistance = this.maxDistance;
            this.maxDistance = tmp;
        }

        if (this.playerFovAngle <= 0) {
            // 0以下なら視界なし扱い(常に死角とみなす)
            if (this.debugLog) {
                console.warn('[TeleportAI] playerFovAngle が 0 以下のため 0 に補正します(全周死角扱い)。');
            }
            this.playerFovAngle = 0;
        }

        // エフェクトノードは任意なので存在チェックのみ
        if (this.teleportEffectNode) {
            // 最初は非表示にしておく
            this.teleportEffectNode.active = false;
        }
    }

    start() {
        if (!this.playerNode) {
            console.error('[TeleportAI] playerNode が設定されていません。このコンポーネントは動作しません。');
            return;
        }

        if (this.debugLog) {
            console.log('[TeleportAI] 開始しました。プレイヤー:', this.playerNode.name);
        }

        // 最初のワープまでのタイマーをリセット
        this._timer = 0;
    }

    update(deltaTime: number) {
        if (!this.playerNode) {
            // 毎フレームエラーを出すと煩雑なので、一度だけ onLoad/start で出している。
            return;
        }

        this._timer += deltaTime;
        if (this._timer >= this.teleportInterval) {
            this._timer = 0;
            this.performTeleport();
        }
    }

    /**
     * 実際にワープを行う処理
     */
    private performTeleport() {
        const owner = this.node;
        const player = this.playerNode!;
        const outPos = this._tempVecA;

        const success = this.calculateTeleportPosition(player, outPos);
        if (!success) {
            if (this.debugLog) {
                console.warn('[TeleportAI] 有効なワープ位置を計算できませんでした。');
            }
            return;
        }

        // 位置の適用
        if (this.useWorldSpace) {
            owner.worldPosition = outPos;
        } else {
            owner.position = outPos;
        }

        if (this.debugLog) {
            console.log('[TeleportAI] ワープしました。新しい位置:', outPos);
        }

        // エフェクトを再生
        this.playTeleportEffect();
    }

    /**
     * プレイヤーの背後・死角を優先したワープ位置を計算
     * @param player プレイヤーノード
     * @param outPos 計算結果の位置を格納する Vec3
     * @returns 成功したら true
     */
    private calculateTeleportPosition(player: Node, outPos: Vec3): boolean {
        // プレイヤーの位置を取得
        const playerPos = this._tempVecB;

        if (this.useWorldSpace) {
            player.getWorldPosition(playerPos);
        } else {
            player.getPosition(playerPos);
        }

        // プレイヤーの「前方向」を計算(2D想定: XZ平面 or XY平面)
        // ここでは「右方向(Vec3.RIGHT)をプレイヤーの回転で回したもの」を前方向とみなす。
        const forward = this.getPlayerForward(player);

        // ランダムな距離
        const distance = math.lerp(this.minDistance, this.maxDistance, Math.random());

        // ワープ角度の決定
        const angleRad = this.chooseTeleportAngle(forward);

        // 2D(XY)前提での方向ベクトルを作成(右手座標系: cos= x, sin= y)
        const dir = new Vec3(Math.cos(angleRad), Math.sin(angleRad), 0);

        // ワープ先位置 = プレイヤー位置 + dir * distance
        Vec3.scaleAndAdd(outPos, playerPos, dir, distance);

        // 高さオフセット(Y)を加える(XY2Dなら不要だが、3Dや上下演出用に残す)
        if (this.randomHeightOffset > 0) {
            const offset = (Math.random() * 2 - 1) * this.randomHeightOffset;
            outPos.y += offset;
        }

        return true;
    }

    /**
     * プレイヤーの前方向ベクトル(2D想定)を取得
     * ここでは「ローカルの右方向(Vec3.RIGHT)」を回転させて前方向とみなす。
     */
    private getPlayerForward(player: Node): Vec3 {
        const forward = new Vec3(1, 0, 0); // 右方向を基準
        const rot = new Quat();

        if (this.useWorldSpace) {
            player.getWorldRotation(rot);
        } else {
            player.getRotation(rot);
        }

        // forward を rot で回転させる
        Vec3.transformQuat(forward, forward, rot);
        // 2D(XY)用にZ成分は無視
        forward.z = 0;
        if (!forward.equals(Vec3.ZERO)) {
            forward.normalize();
        }
        return forward;
    }

    /**
     * 背後・死角を優先したワープ角度(ラジアン)を決定する。
     * @param forward プレイヤーの前方向ベクトル
     */
    private chooseTeleportAngle(forward: Vec3): number {
        // プレイヤーの向きの角度(ラジアン)
        const playerAngle = Math.atan2(forward.y, forward.x);

        // 背後方向 = プレイヤー角度 + 180°
        const backAngle = playerAngle + Math.PI;

        // 背後をどれくらい優先するか (0〜1)
        const r = Math.random();
        let baseAngle: number;

        if (r < this.backPreference) {
            // 背後付近のランダム角度(±45°程度)
            const spread = math.toRadian(45);
            baseAngle = backAngle + (Math.random() * 2 - 1) * spread;
        } else {
            // 死角(視界外)を優先的に選択
            const halfFov = math.toRadian(this.playerFovAngle) * 0.5;

            if (this.playerFovAngle > 0 && this.playerFovAngle < 360) {
                // 視界外の角度レンジを2つに分けてランダム選択
                // [playerAngle + halfFov, playerAngle + PI] と [playerAngle - PI, playerAngle - halfFov]
                const chooseBackSide = Math.random() < 0.5;

                if (chooseBackSide) {
                    const minA = playerAngle + halfFov;
                    const maxA = playerAngle + Math.PI;
                    baseAngle = math.lerp(minA, maxA, Math.random());
                } else {
                    const minA = playerAngle - Math.PI;
                    const maxA = playerAngle - halfFov;
                    baseAngle = math.lerp(minA, maxA, Math.random());
                }
            } else {
                // FOVが0 or 360 なら全周ランダム
                baseAngle = Math.random() * Math.PI * 2;
            }
        }

        return baseAngle;
    }

    /**
     * ワープエフェクトの再生
     */
    private playTeleportEffect() {
        if (!this.teleportEffectNode) {
            return;
        }

        const effect = this.teleportEffectNode;

        // エフェクトノードを敵の位置に移動
        if (this.useWorldSpace) {
            effect.setWorldPosition(this.node.worldPosition);
        } else {
            effect.setPosition(this.node.position);
        }

        effect.active = true;

        if (this.effectDuration <= 0) {
            // 即座に非表示
            effect.active = false;
            return;
        }

        // 一定時間後に非表示にする(tween を利用)
        tween(effect)
            .delay(this.effectDuration)
            .call(() => {
                effect.active = false;
            })
            .start();
    }
}

主要メソッドの解説

  • onLoad()
    • インスペクタから設定された値を防御的に補正(負の値の修正、min/max 入れ替えなど)。
    • teleportEffectNode が指定されていれば、最初は非表示にしておく。
  • start()
    • playerNode が設定されていない場合は console.error を出し、以降の処理は行わない。
    • ワープ用タイマーを初期化。
  • update(deltaTime)
    • 毎フレーム _timer を加算し、teleportInterval を超えたら performTeleport() を呼び出す。
    • playerNode が未設定の場合は何もしない。
  • performTeleport()
    • calculateTeleportPosition() でワープ先を計算。
    • 成功したら、自身のノードの position / worldPosition を更新。
    • ワープ後に playTeleportEffect() でエフェクトを再生。
  • calculateTeleportPosition()
    • プレイヤーの位置と向きを取得。
    • プレイヤーの前方向ベクトルを getPlayerForward() で計算。
    • chooseTeleportAngle() で「背後・死角」を優先した角度(ラジアン)を決定。
    • ランダムな距離(minDistance〜maxDistance)と組み合わせて、ワープ先座標を算出。
  • getPlayerForward()
    • プレイヤーの回転を取得し、Vec3.RIGHT をその回転で変換して前方向ベクトルを得る。
    • 2D ゲームを想定して Z 成分は無視し、XY のみを使用。
  • chooseTeleportAngle()
    • backPreference に応じて、背後付近(±45°)を優先的に選択。
    • それ以外の場合は、playerFovAngle で定義された視界外(死角)からランダムに角度を選択。
    • playerFovAngle が 0 or 360 の場合は全周ランダム。
  • playTeleportEffect()
    • teleportEffectNode が指定されていれば、敵の位置へ移動し active = true
    • effectDuration 秒後に自動で非表示にするため、tween を使用。

使用手順と動作確認

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

  1. Assets パネルで右クリック → Create → TypeScript を選択します。
  2. ファイル名を TeleportAI.ts に変更します。
  3. 作成された TeleportAI.ts をダブルクリックして開き、中身をすべて削除してから、上記の TypeScript コードを丸ごと貼り付けて保存します。

2. テスト用シーンとノードの準備

ここでは 2D ゲーム(Canvas 上のスプライト)を例に説明しますが、3D でも同様に使えます。

  1. Hierarchy パネルで右クリック → Create → 2D Object → Sprite を選択し、プレイヤー用ノードを作成します。
    • 名前を Player に変更します。
    • Inspector で Sprite の画像を設定しておくと視認しやすくなります。
  2. 同様に、敵用ノードを作成します。
    • Hierarchy で右クリック → Create → 2D Object → Sprite
    • 名前を Enemy に変更します。
    • 色や画像を変えてプレイヤーと区別しやすくしておきます。

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

  1. Hierarchy で Enemy ノードを選択します。
  2. Inspector の一番下にある Add Component ボタンをクリックします。
  3. Custom カテゴリ(またはスクリプト一覧)から TeleportAI を選択してアタッチします。

4. インスペクタでプロパティを設定

Enemy ノードを選択した状態で、Inspector に表示される TeleportAI コンポーネントの各プロパティを設定します。

  • Player Node:
    • Hierarchy から Player ノードをドラッグ&ドロップして、このフィールドにセットします。
  • Teleport Interval:
    • 例: 2 と入力すると、約 2 秒ごとにワープします。
  • Min Distance / Max Distance:
    • 例: Min = 2, Max = 4 に設定。
    • これでプレイヤーから 2〜4 単位の距離にワープするようになります。
  • Back Preference:
    • 例: 0.8 にすると、ほとんど背後に出現するようになります。
  • Player Fov Angle:
    • 例: 90 とすると、前方 90° を視界とみなし、それ以外を死角として優先的にワープします。
  • Random Height Offset:
    • 2D XY のみで使う場合は 0 のままで構いません。
    • 上下に少しランダムさを出したい場合は 0.5 などに設定します。
  • Use World Space:
    • 通常は チェックを入れたまま(true) にしておきます。
    • Player と Enemy が同じ親ノードのローカル座標で動いている場合は、false にしても動作します。
  • Teleport Effect Node(任意):
    • ワープ時にエフェクトを出したい場合、別途エフェクト用のノードを用意してここに指定します。
    • 例:
      1. Hierarchy で右クリック → Create → 2D Object → SpriteTeleportEffect ノードを作成。
      2. 色を薄い青などにして、少し小さめのスプライトにする。
      3. 最初は active = false にしておく(Inspector のチェックを外す)。
      4. Enemy の TeleportAI の Teleport Effect Node に、この TeleportEffect ノードをドラッグ&ドロップ。
  • Effect Duration:
    • 例: 0.3 とすると、ワープのたびに 0.3 秒間だけエフェクトが表示されます。
  • Debug Log:
    • 挙動を確認したい場合は チェックを入れて true にします。
    • Console にワープ位置などが出力されます。

5. プレイヤーの向き(回転)を確認

TeleportAI はプレイヤーの「前方向」を基準に背後や死角を計算します。

  • 2D ゲームの場合:
    • Player ノードを選択し、Inspector の Rotation (または Angle) を変えると、前方向が変わります。
    • シーン再生中に Player を回転させると、Enemy のワープ位置がそれに応じて変わることを確認できます。

6. 再生して動作確認

  1. エディタ上部の ▶(Play) ボタンをクリックしてシーンを再生します。
  2. 数秒ごとに Enemy ノードが Player の周囲にワープする様子が確認できます。
  3. Back Preference を 1.0 に近づけると、より背後に出現しやすくなります。
  4. Player Fov Angle を変えることで、「前方にはほとんど現れず、側面〜背後に現れる」ような死角感を調整できます。
  5. Teleport Effect Node を設定している場合は、ワープの瞬間にエフェクトが一瞬表示されることを確認します。

まとめ

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

  • プレイヤーノードをインスペクタで指定するだけで、
  • 一定間隔でプレイヤーの背後や死角にワープする挙動を自動的に実現

できるように設計されています。

外部の GameManager やシングルトンに一切依存せず、このスクリプト単体で完結しているため、

  • 別プロジェクトへの移植
  • 他の敵キャラクターへの再利用
  • 複数の TeleportAI を同一シーンで同時使用

といったケースでも、そのままドラッグ&ドロップで使い回すことができます。

応用例としては、

  • teleportInterval を短くして「高速テレポートボス」を作る
  • maxDistance を大きくして「遠くから突然迫ってくる敵」を演出する
  • Teleport Effect Node にパーティクルやアニメーションを設定して、派手なワープ演出を加える

などが考えられます。

このように、汎用的で外部依存のないコンポーネントを積み重ねていくことで、Cocos Creator 3.8 でのゲーム開発をシンプルかつ効率的に進めることができます。ぜひプロジェクトに組み込んで、さまざまな敵 AI 演出に活用してみてください。